diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index a90aa3c..f077b5e 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -2,4 +2,6 @@ ff20d1e5015789dcee10b16174c8bd1bda5c15e9 38c0dd5ee7395dcbb477ab96fc5a1b521bcb51e3 9c54d2588091da84b8844ba36acc4c5a6903fc4f af43fef670327a7fde0d17664c94599f107613ff -20d4aef214e6a3d0ba828bd50ffe334a1c4f7111 \ No newline at end of file +20d4aef214e6a3d0ba828bd50ffe334a1c4f7111 +7890d40663d42e82ce8d2386c105c8502c6442c4 +f6085708912c611e0533feb66757eff42a62ce11 \ No newline at end of file diff --git a/.gitignore b/.gitignore index a28987d..c148b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,7 @@ _deps !/ltrace/ltrace/assets/**/* /tools/deploy/Assets/Tesseract-OCR/ /tools/deploy/Resources/manual/ +/tools/deploy/GeoSlicerManual/site/ # Cython build files *.c diff --git a/.gitmodules b/.gitmodules index 6ce9408..b5c9537 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,9 @@ [submodule "src/submodules/pnflow"] path = src/submodules/pnflow url = git@github.com:ltracegeo/pnflow.git +[submodule "src/submodules/py_pore_flow"] + path = src/submodules/py_pore_flow + url = git@bitbucket.org:ltrace/py_pore_flow.git +[submodule "src/submodules/pyflowsolver"] + path = src/submodules/pyflowsolver + url = git@github.com:Arenhart/PyFlowSolver.git diff --git a/CTestConfig.cmake b/CTestConfig.cmake deleted file mode 100644 index 30db31e..0000000 --- a/CTestConfig.cmake +++ /dev/null @@ -1,7 +0,0 @@ -set(CTEST_PROJECT_NAME "SlicerLTrace") -set(CTEST_NIGHTLY_START_TIME "3:00:00 UTC") - -set(CTEST_DROP_METHOD "http") -set(CTEST_DROP_SITE "slicer.cdash.org") -set(CTEST_DROP_LOCATION "/submit.php?project=SlicerPreview") -set(CTEST_DROP_SITE_CDASH TRUE) diff --git a/pyproject.toml b/pyproject.toml index 54f3853..8b2f92e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [build-system] requires = [ - "setuptools==59.8.0", + "setuptools==60.2.0", "wheel==0.37.0", "Cython==0.29.28", "vswhere==1.3.0", "patch==1.16", - "packaging==21.2" + "packaging==23.2" ] [tool.autopep8] @@ -49,6 +49,7 @@ authorized_licenses = [ "3-Clause BSD", "Apache 2.0", "Apache License 2.0", + "Apache License, Version 2.0", "Apache Software", "BSD", "FreeBSD", @@ -76,5 +77,5 @@ unauthorized_licenses = [ # The packages below are listed as "Other/Proprietary" # They use Intel licenses that allow distribution mkl = ">=2022.2.1" -tbb = ">=2021.12.0" +tbb = ">=2021.9.0" intel-openmp = ">=2022.2.1" diff --git a/src/ltrace/ltrace/algorithms/measurements.py b/src/ltrace/ltrace/algorithms/measurements.py index 7293498..10f8763 100644 --- a/src/ltrace/ltrace/algorithms/measurements.py +++ b/src/ltrace/ltrace/algorithms/measurements.py @@ -1,9 +1,10 @@ +import logging import math from collections import namedtuple from multiprocessing import Process, Queue, Value from queue import Empty from threading import Thread -from typing import List +from typing import List, Callable import cv2 import numba as nb @@ -52,6 +53,14 @@ CLASS_LABEL_SUFFIX = ["[label]", "[ID]", "[CID]"] +GENERIC_PROPERTIES = [ + "area (m²)", + "insc diam (mm)", + "azimuth (°)", + "Circularity", + "Perimeter over Area (1/mm)", +] + def get_pore_size_class_label_field(fields): for suffix in ["_label", *CLASS_LABEL_SUFFIX]: @@ -434,7 +443,7 @@ def object_consumer(operator, queue, results, visited): results.put(None) -def _executor_task(func: object, tasks: Queue, sender: Queue): +def _executor_task(func: Callable, tasks: Queue, sender: Queue): processed = 0 valid_results = [] while True: @@ -470,7 +479,7 @@ def _separator_task(im: np.ndarray, tasks: Queue, pool_size: int): tasks.put_nowait((-1, None)) -def calculate_statistics_on_segments(im: np.ndarray, operator: object, callback=None) -> None: +def calculate_statistics_on_segments(im: np.ndarray, operator: object, callback=None): from timeit import default_timer as timer tstart = timer() @@ -757,19 +766,26 @@ def __init__(self, im, spacing, direction, size_filter=0): else: self.__anglesFromReferenceDirection = mock - def __call__(self, label, pointsInRAS): - pointsInRAS = blobers(pointsInRAS, r=self.radius) - if pointsInRAS.shape[0] < 3: + def __call__(self, label, pointsInRasBorder): + # deprecated API + return self.calculate(label, pointsInRasBorder) + + def calculate(self, label, pointsInRasBorder): + pointsInRasBorder = blobers(pointsInRasBorder, r=self.radius) + if pointsInRasBorder.shape[0] < 3: return None - voxelCount = len(pointsInRAS) + return self.strict_calculate(label, pointsInRasBorder) + + def strict_calculate(self, label, pointsInRasBorder): + voxelCount = len(pointsInRasBorder) area_mm = voxelCount * self.voxel_size try: - chull = ConvexHull(pointsInRAS) + chull = ConvexHull(pointsInRasBorder) perimeter = chull.area # yes, for 2D, the area is the perimeter - diameter, major_angle, min_feret, minor_angle, _ = rotating_calipers(pointsInRAS[chull.vertices]) + diameter, major_angle, min_feret, minor_angle, _ = rotating_calipers(pointsInRasBorder[chull.vertices]) eccen = np.sqrt(1 - min_feret / diameter) elong = np.sqrt(diameter / min_feret) @@ -935,11 +951,11 @@ def zero_origin(vector): def get_angle_between(v1, v2): """Returns the angle in radians between vectors 'v1' and 'v2':: - >>> angle_between((1, 0, 0), (0, 1, 0)) + angle_between((1, 0, 0), (0, 1, 0)) 1.5707963267948966 - >>> angle_between((1, 0, 0), (1, 0, 0)) + angle_between((1, 0, 0), (1, 0, 0)) 0.0 - >>> angle_between((1, 0, 0), (-1, 0, 0)) + angle_between((1, 0, 0), (-1, 0, 0)) 3.141592653589793 """ v1_u = unit_vector(zero_origin(v1)) @@ -1022,17 +1038,21 @@ def randomize_colors(im, keep_vals=[0]): neither will they be in the output. Examples -------- - >>> import scipy as sp - >>> sp.random.seed(0) - >>> im = sp.random.randint(low=0, high=5, size=[4, 4]) - >>> print(im) - [[4 0 3 3] + + import scipy as sp + sp.random.seed(0) + im = sp.random.randint(low=0, high=5, size=[4, 4]) + print(im) + + $ [[4 0 3 3] [3 1 3 2] [4 0 0 4] [2 1 0 1]] - >>> im_rand = randomize_colors(im) - >>> print(im_rand) - [[2 0 4 4] + + im_rand = randomize_colors(im) + print(im_rand) + + [[2 0 4 4] [4 1 4 3] [2 0 0 2] [3 1 0 1]] @@ -1105,11 +1125,12 @@ def sidewall_sample_instance_properties(instance_mask, spacing): return properties -def generic_instance_properties(instance_mask, spacing, shape=None, offset=None): +def generic_instance_properties(instance_mask, selected_measurements, spacing, shape=None, offset=None): """ Calculates the instance generic properties. :param instance_mask: the binary 2D array containing the instance to be evaluated + :param selected_measurements: binary array that controls which measurement to calculate :param spacing: the spacing from the related volume :return: a dictionary containing the generic properties of the instance """ @@ -1119,44 +1140,75 @@ def generic_instance_properties(instance_mask, spacing, shape=None, offset=None) if offset is None: offset = (0, 0) + properties = {} + for index in range(len(selected_measurements)): + if selected_measurements[index]: + properties[GENERIC_PROPERTIES[index]] = -1 - properties = {"area (m²)": -1, "insc diam (mm)": -1, "azimuth (°)": -1} contours, _ = cv2.findContours(instance_mask.astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE) markAreaInPixels = np.count_nonzero(instance_mask) if markAreaInPixels <= 0: raise ValueError("Detected Label with invalid area.") - try: - pixelArea = spacing[0] * spacing[1] - markAreaInMillimeters = markAreaInPixels * pixelArea - markAreaInMeters = markAreaInMillimeters / (10**6) - properties["area (m²)"] = np.round(markAreaInMeters, 8) - except Exception as e: - raise ValueError("Area calculation failed.") - - try: - if any(instance_mask[:, 0] & instance_mask[:, -1]): - concatenated_mask = np.concatenate([instance_mask, instance_mask], axis=1) - padded_mask = np.pad(concatenated_mask, pad_width=1, mode="constant", constant_values=0) - else: - padded_mask = np.pad(instance_mask, pad_width=1, mode="constant", constant_values=0) - dt = ndimage.distance_transform_edt(padded_mask, sampling=spacing) - max_radius = instance_mask.shape[1] * spacing[1] / 2 - radius = np.max(dt, initial=0, where=dt < max_radius) - diameter = 2 * radius - properties["insc diam (mm)"] = diameter - except Exception as e: - raise ValueError("Diameter calculation failed.") + if GENERIC_PROPERTIES[0] in properties.keys(): + try: + pixelArea = spacing[0] * spacing[1] + markAreaInMillimeters = markAreaInPixels * pixelArea + markAreaInMeters = markAreaInMillimeters / (10**6) + properties["area (m²)"] = np.round(markAreaInMeters, 8) + except Exception as e: + properties["area (m²)"] = np.nan + logging.warning("Area calculation failed.") + + if GENERIC_PROPERTIES[1] in properties.keys(): + try: + if any(instance_mask[:, 0] & instance_mask[:, -1]): + concatenated_mask = np.concatenate([instance_mask, instance_mask], axis=1) + padded_mask = np.pad(concatenated_mask, pad_width=1, mode="constant", constant_values=0) + else: + padded_mask = np.pad(instance_mask, pad_width=1, mode="constant", constant_values=0) + dt = ndimage.distance_transform_edt(padded_mask, sampling=spacing) + max_radius = instance_mask.shape[1] * spacing[1] / 2 + radius = np.max(dt, initial=0, where=dt < max_radius) + diameter = 2 * radius + properties["insc diam (mm)"] = diameter + except Exception as e: + properties["insc diam (mm)"] = np.nan + logging.warning("Diameter calculation failed.") + + if GENERIC_PROPERTIES[2] in properties.keys(): + try: + markContour = max(contours, key=cv2.contourArea) + moments = cv2.moments(markContour) + center_x = int(np.round(moments["m10"] / moments["m00"])) + offset[1] + azimuth_in_degrees = 360 * center_x / (shape[1] - 1) + properties["azimuth (°)"] = int(np.round(azimuth_in_degrees)) + except Exception as e: + properties["azimuth (°)"] = np.nan + logging.warning("Azimuth calculation failed.") + + if GENERIC_PROPERTIES[3] in properties.keys(): + try: + countourPerimeter = cv2.arcLength(contours[0], True) + countourArea = cv2.contourArea(contours[0]) + properties[GENERIC_PROPERTIES[3]] = 4 * np.pi * countourArea / countourPerimeter**2 + except Exception as e: + properties[GENERIC_PROPERTIES[3]] = np.nan + logging.warning("Circularity calculation failed.") + + if GENERIC_PROPERTIES[4] in properties.keys(): + try: + rescaledContour = contours[0] + rescaledContour[:, :, 0] = rescaledContour[:, :, 0] * spacing[0] + rescaledContour[:, :, 1] = rescaledContour[:, :, 1] * spacing[1] - try: - markContour = max(contours, key=cv2.contourArea) - moments = cv2.moments(markContour) - center_x = int(np.round(moments["m10"] / moments["m00"])) + offset[1] - azimuth_in_degrees = 360 * center_x / (shape[1] - 1) - properties["azimuth (°)"] = int(np.round(azimuth_in_degrees)) - except Exception as e: - raise ValueError("Azimuth calculation failed.") + rescaledPerimeter = cv2.arcLength(rescaledContour, True) + rescaledArea = cv2.contourArea(rescaledContour) + properties[GENERIC_PROPERTIES[4]] = rescaledPerimeter / rescaledArea + except Exception as e: + properties[GENERIC_PROPERTIES[4]] = np.nan + logging.warning("Perimeter over Area calculation failed.") return properties @@ -1178,7 +1230,7 @@ def crop_to_content(image, padding=0): return instanceMask, indices, offset -def instancesPropertiesDataFrame(labelMap): +def instancesPropertiesDataFrame(labelMap, selectedMeasurements=[1, 1, 1]): propertiesList = [] array = slicer.util.arrayFromVolume(labelMap) labels = np.unique(array) @@ -1213,7 +1265,7 @@ def instancesPropertiesDataFrame(labelMap): try: instanceProperties = generic_instance_properties( - instanceMask, inverted_2d_spacing, arraySliceCopy.shape, offset + instanceMask, selectedMeasurements, inverted_2d_spacing, arraySliceCopy.shape, offset ) except ValueError as err: # logging.debug(f"{err}\n{traceback.print_exc()}") # hide this error from the user for while; it's not critical; we must handle logging filters; @@ -1222,9 +1274,11 @@ def instancesPropertiesDataFrame(labelMap): instanceProperties["depth (m)"] = instance_depth(labelMap, label) instanceProperties["label"] = label propertiesList.append(instanceProperties) - propertiesDataFrame = pd.DataFrame( - propertiesList, columns=["depth (m)", "area (m²)", "label", "azimuth (°)", "insc diam (mm)"] - ) + + measurementColumns = [ + GENERIC_PROPERTIES[index] for index in range(len(selectedMeasurements)) if selectedMeasurements[index] + ] + propertiesDataFrame = pd.DataFrame(propertiesList, columns=["depth (m)", "label"] + measurementColumns) return propertiesDataFrame diff --git a/src/ltrace/ltrace/flow/framework.py b/src/ltrace/ltrace/flow/framework.py index b297a57..780377f 100644 --- a/src/ltrace/ltrace/flow/framework.py +++ b/src/ltrace/ltrace/flow/framework.py @@ -49,16 +49,10 @@ def __init__(self): flowLayout = qt.QHBoxLayout(self) self.backButton = qt.QPushButton("\u2190 Back") self.backButton.setFixedHeight(40) - self.skipButton = qt.QPushButton("Skip \u21e2") + self.skipButton = qt.QPushButton("Skip \u21d2") self.skipButton.setFixedHeight(40) self.nextButton = qt.QPushButton("Next \u2192") - self.nextButton.setStyleSheet( - f""" - QPushButton:enabled {{ - background-color: {"#003265" if helpers.themeIsDark() else "#8eb3ff"}; - }} - """ - ) + self.nextButton.setProperty("class", "actionButtonBackground") self.nextButton.setFixedHeight(40) flowLayout.addWidget(self.backButton, 1) @@ -89,12 +83,12 @@ def __init__(self): QListWidget::item:selected {{ padding-left: 10px; font-weight: bold; - background-color: {'#242e38' if isDark else '#d9ebff'}; - border-left: 6px solid #36a0fe; + background-color: {'#37403A' if isDark else '#d9ebff'}; + border-left: 6px solid #26C252; color: {'#ffffff' if isDark else '#000000'}; }} QListWidget::item:hover {{ - background-color: {'#242e38' if isDark else '#d9ebff'}; + background-color: {'#26C252' if isDark else '#d9ebff'}; }} """ ) diff --git a/src/ltrace/ltrace/flow/thin_section.py b/src/ltrace/ltrace/flow/thin_section.py index 4d7dcb5..b30fb12 100644 --- a/src/ltrace/ltrace/flow/thin_section.py +++ b/src/ltrace/ltrace/flow/thin_section.py @@ -1,3 +1,5 @@ +import sys + from ltrace.flow.framework import ( FlowWidget, FlowState, @@ -8,19 +10,24 @@ onSegmentEditorEnter, onSegmentEditorExit, ) -from QEMSCANLoader import QEMSCANLoaderLogic, QEMSCANLoaderWidget, Callback -from SegmentInspector import SegmentInspectorLogic -from ThinSectionLoader import ThinSectionLoaderLogic, ThinSectionLoaderWidget + from ltrace import assets_utils as assets from ltrace.slicer import helpers, widgets from ltrace.slicer.widget.global_progress_bar import LocalProgressBar from ltrace.slicer.widget.pixel_size_editor import PixelSizeEditor from ltrace.units import global_unit_registry as ureg +from ltrace.utils.callback import Callback from ltrace.utils.ProgressBarProc import ProgressBarProc -import Segmenter + import ctk import qt import slicer +import importlib + + +Segmenter = helpers.LazyLoad("Segmenter") +QEMSCANLoader = helpers.LazyLoad("QEMSCANLoader") +ThinSectionLoader = helpers.LazyLoad("ThinSectionLoader") class Load(FlowStep): @@ -79,12 +86,12 @@ def exit(self): def next(self): with ProgressBarProc() as pb: - logic = ThinSectionLoaderLogic() + logic = slicer.util.getModuleLogic("ThinSectionLoader") path = self.ppFileSelector.currentPath pb.setMessage("Loading PP image") pb.setProgress(0) - params = ThinSectionLoaderWidget.LoadParameters(path) + params = ThinSectionLoader.ThinSectionLoaderWidget.LoadParameters(path) imageInfo = logic.load(params) pp = imageInfo["node"] @@ -98,7 +105,7 @@ def next(self): pb.setMessage("Loading PX image") pb.setProgress(50) path = self.pxFileSelector.currentPath - params = ThinSectionLoaderWidget.LoadParameters(path, automaticImageSpacing=False) + params = ThinSectionLoader.ThinSectionLoaderWidget.LoadParameters(path, automaticImageSpacing=False) imageInfo = logic.load(params) px = imageInfo["node"] @@ -225,7 +232,7 @@ def setup(self): moduleWidget = slicer.modules.thinsectionregistration.createNewWidgetRepresentation() widget = moduleWidget.self() widget.imagesFrame.visible = False - widget.buttonsFrame.visible = False + widget.applyCancelButtons.visible = False return moduleWidget def enter(self): @@ -565,10 +572,23 @@ def setup(self): self.segmentSelector.targetBox.hide() layout.addRow(self.segmentSelector) + progressLayout = qt.QHBoxLayout() self.progressBar = LocalProgressBar() - layout.addRow(self.progressBar) + progressLayout.addWidget(self.progressBar, 1) + + self.cancelButton = qt.QPushButton("Cancel") + self.cancelButton.visible = False + self.cancelButton.setToolTip("Cancel the auto-labeling.") + self.cancelButton.setFixedHeight(30) + progressLayout.addWidget(self.cancelButton, 0) + + layout.addRow(progressLayout) self.segmentSelector.segmentSelectionChanged.connect(self.onSegmentSelected) + self.cancelButton.clicked.connect(self.onCancel) + + self.logic = slicer.util.getModuleLogic("SegmentInspector") + self.logic.inspector_process_finished.connect(self.onFinish) return widget @@ -604,7 +624,7 @@ def next(self): if method == self.WATERSHED: params = { "method": "snow", - "sigma": 0.0423, + "sigma": 0.005, "d_min_filter": 5.0, "size_min_threshold": 0.0, "direction": [], @@ -614,7 +634,6 @@ def next(self): elif method == self.SEPARATE_OBJECTS: params = {"method": "islands", "size_min_threshold": 0.0, "direction": []} - self.logic = SegmentInspectorLogic() self.cliNode = self.logic.runSelectedMethod( mainNode, segments=self.segmentSelector.getSelectedSegments(), @@ -626,9 +645,9 @@ def next(self): ) if self.cliNode is None: return - self.logic.inspector_process_finished.connect(self.onFinish) self.progressBar.setCommandLineModuleNode(self.cliNode) self.nav.setButtonsState(self.BACK_ON_STATE, self.SKIP_OFF_STATE, self.NEXT_IN_PROGRESS_STATE) + self.cancelButton.visible = True def onFinish(self): if self.cliNode.GetStatusString() == "Completed": @@ -637,7 +656,11 @@ def onFinish(self): parent = shNode.GetItemParent(shNode.GetItemByDataNode(self.state.labelmap)) shNode.SetItemParent(parent, self.state.dir) self.onSegmentSelected(self.segmentSelector.getSelectedSegments()) + self.cancelButton.visible = False self.nav.next() + else: + self.onSegmentSelected(self.segmentSelector.getSelectedSegments()) + self.cancelButton.visible = False def onSegmentSelected(self, segmentList): if segmentList: @@ -645,6 +668,10 @@ def onSegmentSelected(self, segmentList): else: self.nav.setButtonsState(self.BACK_ON_STATE, self.SKIP_OFF_STATE, self.NEXT_OFF_STATE) + def onCancel(self): + if self.cliNode is not None: + self.cliNode.Cancel() + class LabelEditor(FlowStep): HELP = """ @@ -677,8 +704,7 @@ def setup(self): widget.input_collapsible.visible = False widget.output_collapsible.visible = False - widget.save_button.visible = False - widget.cancel_button.visible = False + widget.applyCancelButtons.visible = False widget.labelmapGenerated = self.onFinish widget.throat_analysis_checkbox.setChecked(False) @@ -777,7 +803,7 @@ def setup(self): self.imageSpacingLineEdit.setToolTip("Pixel size in millimeters") layout.addRow("Pixel size (mm):", self.imageSpacingLineEdit) - self.logic = QEMSCANLoaderLogic() + self.logic = slicer.util.getModuleLogic("QEMSCANLoader") return widget @@ -790,7 +816,7 @@ def exit(self): def next(self): with ProgressBarProc() as pb: - loadParameters = QEMSCANLoaderWidget.LoadParameters( + loadParameters = QEMSCANLoader.QEMSCANLoaderWidget.LoadParameters( callback=Callback(on_update=lambda message, percent, processEvents=True: pb.nextStep(percent, message)), lookupColorTableNode=None, fillMissing=True, diff --git a/src/ltrace/ltrace/image/io.py b/src/ltrace/ltrace/image/io.py index 23c5d33..7ee3a24 100644 --- a/src/ltrace/ltrace/image/io.py +++ b/src/ltrace/ltrace/image/io.py @@ -1,10 +1,10 @@ import os -import pathlib import cv2 from natsort import natsorted import numpy as np from PIL import Image, ExifTags +from pathlib import Path import slicer @@ -20,6 +20,7 @@ def load_from_files(dirpath: str): def volume_from_image(filepath): new_node = slicer.util.loadVolume(str(filepath), properties={"singleFile": True}) + new_node.SetName(Path(filepath).stem) with Image.open(filepath) as image: if image.format == "PNG": diff --git a/src/ltrace/ltrace/image/las.py b/src/ltrace/ltrace/image/las.py index 7bdc767..07d5eb2 100644 --- a/src/ltrace/ltrace/image/las.py +++ b/src/ltrace/ltrace/image/las.py @@ -1,21 +1,14 @@ +import datetime import numpy as np -import os -import lasio -import slicer -from ltrace.slicer.helpers import ( - getVolumeNullValue, - arrayFromVisibleSegmentsBinaryLabelmap, -) -from ImageLogExportLib.ImageLogCSV import _arrayPartsFromNode -from pathlib import Path import pandas as pd -import datetime -import re import logging -from dataclasses import dataclass -from DLISImportLib.DLISImportLogic import WELL_NAME_TAG, UNITS_TAG - import ltrace.image.lasGeologFile as lasGeologFile +import re +import slicer + +from ltrace.constants import DLISImportConst +from ltrace.image.optimized_transforms import ANP_880_2022_DEFAULT_NULL_VALUE +from ltrace.slicer.helpers import getVolumeNullValue, arrayFromVisibleSegmentsBinaryLabelmap, arrayPartsFromNode def retrieve_depth_curve(node_list): @@ -126,7 +119,7 @@ def add_curve(lasfile, las_data, las_info): data = las_data["data"].squeeze() - data[data == las_info["null_value"]] = -999.25 # TODO MUSA-75 Update invalid data handling policy + data[data == las_info["null_value"]] = ANP_880_2022_DEFAULT_NULL_VALUE if data.ndim > 1: if data.ndim == 1: @@ -153,7 +146,7 @@ def _extract_las_data_from_node(node): las_data["step"] = spacing[2] las_data["origin"] = origin[2] elif isinstance(node, slicer.vtkMRMLTableNode): - depths, las_data["data"] = _arrayPartsFromNode(node) + depths, las_data["data"] = arrayPartsFromNode(node) las_data["step"] = depths[1] - depths[0] las_data["origin"] = depths[0] else: @@ -173,15 +166,15 @@ def extract_las_info_from_node(node): units_from_name = units_search.group(1) if units_search else "NONE" units = units_from_name - if node.GetAttribute(UNITS_TAG) is not None: - units = node.GetAttribute(UNITS_TAG) + if node.GetAttribute(DLISImportConst.UNITS_TAG) is not None: + units = node.GetAttribute(DLISImportConst.UNITS_TAG) if units_from_name == "NONE": logging.info( - f"Node name ({node.GetName()}) doesn't include its units ({node.GetAttribute(UNITS_TAG)}) in it." + f"Node name ({node.GetName()}) doesn't include its units ({node.GetAttribute(DLISImportConst.UNITS_TAG)}) in it." ) - elif node.GetAttribute(UNITS_TAG) != units_from_name: + elif node.GetAttribute(DLISImportConst.UNITS_TAG) != units_from_name: logging.warning( - f"Units informed in {node.GetName()} ({node.GetAttribute(UNITS_TAG)}) metadata are different from the units implied by the node name ({units_from_name}). {node.GetAttribute(UNITS_TAG)} will be considered as the units." + f"Units informed in {node.GetName()} ({node.GetAttribute(DLISImportConst.UNITS_TAG)}) metadata are different from the units implied by the node name ({units_from_name}). {node.GetAttribute(DLISImportConst.UNITS_TAG)} will be considered as the units." ) else: units = units_from_name @@ -192,15 +185,15 @@ def extract_las_info_from_node(node): # well name well_from_node_name = node.GetName().split("_")[0] if len(node.GetName().split("_")) > 1 else "" - if node.GetAttribute(WELL_NAME_TAG) is not None: - las_info["well_name"] = node.GetAttribute(WELL_NAME_TAG) + if node.GetAttribute(DLISImportConst.WELL_NAME_TAG) is not None: + las_info["well_name"] = node.GetAttribute(DLISImportConst.WELL_NAME_TAG) if well_from_node_name == "": logging.info( - f"Node name ({node.GetName()}) doesn't have the well name ({node.GetAttribute(WELL_NAME_TAG)}) prepended to it." + f"Node name ({node.GetName()}) doesn't have the well name ({node.GetAttribute(DLISImportConst.WELL_NAME_TAG)}) prepended to it." ) - elif node.GetAttribute(WELL_NAME_TAG) != well_from_node_name: + elif node.GetAttribute(DLISImportConst.WELL_NAME_TAG) != well_from_node_name: logging.warning( - f"Well name informed in {node.GetName()} ({node.GetAttribute(WELL_NAME_TAG)}) metadata is different from the well name implied by the node name ({well_from_node_name}). {node.GetAttribute(WELL_NAME_TAG)} will be considered as the well name." + f"Well name informed in {node.GetName()} ({node.GetAttribute(DLISImportConst.WELL_NAME_TAG)}) metadata is different from the well name implied by the node name ({well_from_node_name}). {node.GetAttribute(DLISImportConst.WELL_NAME_TAG)} will be considered as the well name." ) else: las_info["well_name"] = well_from_node_name @@ -209,10 +202,10 @@ def extract_las_info_from_node(node): las_info["data_name"] = node.GetName().replace("[" + units + "]", "") new_data_name = las_info["data_name"] - if node.GetAttribute(WELL_NAME_TAG): - new_data_name = las_info["data_name"].replace(node.GetAttribute(WELL_NAME_TAG) + "_", "") + if node.GetAttribute(DLISImportConst.WELL_NAME_TAG): + new_data_name = las_info["data_name"].replace(node.GetAttribute(DLISImportConst.WELL_NAME_TAG) + "_", "") if len(new_data_name) == 0: - new_data_name = node.GetAttribute(WELL_NAME_TAG) + new_data_name = node.GetAttribute(DLISImportConst.WELL_NAME_TAG) las_info["data_name"] = new_data_name las_info["null_value"] = ( diff --git a/src/ltrace/ltrace/image/optimized_transforms.py b/src/ltrace/ltrace/image/optimized_transforms.py index c78f35f..a81ef73 100644 --- a/src/ltrace/ltrace/image/optimized_transforms.py +++ b/src/ltrace/ltrace/image/optimized_transforms.py @@ -7,11 +7,12 @@ import porespy -DEFAULT_NULL_VALUE = set((-999.25, -9999.00, -9999.25)) +DEFAULT_NULL_VALUES = set((-999.25, -9999.00, -9999.25)) +ANP_880_2022_DEFAULT_NULL_VALUE = -999.25 @nb.jit(nopython=True) -def trimPointSearch(data, nullvalue=DEFAULT_NULL_VALUE, reverse=False): +def trimPointSearch(data, nullvalue=DEFAULT_NULL_VALUES, reverse=False): end_ptr = len(data) indexing = range(end_ptr) if not reverse else range(end_ptr - 1, 0, -1) for i in indexing: @@ -22,7 +23,7 @@ def trimPointSearch(data, nullvalue=DEFAULT_NULL_VALUE, reverse=False): @nb.jit(nopython=True) -def trimPointSearch2d(data, nullvalue=DEFAULT_NULL_VALUE, reverse=False): +def trimPointSearch2d(data, nullvalue=DEFAULT_NULL_VALUES, reverse=False): end_ptr = len(data) rows = range(end_ptr) if not reverse else range(end_ptr - 1, 0, -1) for row in rows: @@ -160,21 +161,21 @@ def min_max(arr): return local_min, local_max -def handle_null_values(image, nullValue: set): +def handle_null_values(image, nullValues: set): offset = 0.000001 # for each null value in null value set, replace it with nan working_image = image.astype(np.float32) - for v in nullValue: + for v in nullValues: working_image[working_image == v] = np.nan - # if no values were replaced, return a dummy null value + # if no values were replaced, return our standard invalid value if not np.isnan(np.min(working_image)): - return list(nullValue)[0] + return ANP_880_2022_DEFAULT_NULL_VALUE tempArrayMaxValue = np.nan tempArrayMinValue = np.nan - newNullValue = list(nullValue)[0] + newNullValue = ANP_880_2022_DEFAULT_NULL_VALUE try: tempArrayMaxValue = np.nanmax(working_image) tempArrayMinValue = np.nanmin(working_image) diff --git a/src/ltrace/ltrace/pore_networks/functions.py b/src/ltrace/ltrace/pore_networks/functions.py index 140420a..1ef9246 100644 --- a/src/ltrace/ltrace/pore_networks/functions.py +++ b/src/ltrace/ltrace/pore_networks/functions.py @@ -496,7 +496,7 @@ def _get_paired_throats_table(geo_pore): if not geo_throat: qt.QMessageBox.information( - slicer.util.mainWindow(), "Table parsing failed", "No throats table found in pores table folder." + slicer.modules.AppContextInstance.mainWindow, "Table parsing failed", "No throats table found in pores table folder." ) return False return geo_throat diff --git a/src/ltrace/ltrace/pore_networks/functions_simulation.py b/src/ltrace/ltrace/pore_networks/functions_simulation.py index 6c9b793..a50ccc9 100644 --- a/src/ltrace/ltrace/pore_networks/functions_simulation.py +++ b/src/ltrace/ltrace/pore_networks/functions_simulation.py @@ -1,11 +1,12 @@ import os import csv - import numpy as np import openpnm + from pypardiso import spsolve from scipy.sparse import csr_matrix from numba import njit, prange +from pyflowsolver import NetworkManager, DarcySolver def manual_valvatne_blunt(pore_network): @@ -336,66 +337,48 @@ def single_phase_permeability( elif solver == "pyflowsolver": conn = perm.network["throat.conns"].astype(np.int32) cond = perm.network["throat.conductance"].astype(np.float64) - r = _get_sparse_system(conn, cond, inlets, outlets) - sparse_val, sparse_col_idx, sparse_row_ptr, b, mid_to_total_indexes = r - if preconditioner == "inverse_diagonal": - P_val, P_col_idx, P_row_ptr = _get_diagonal_preconditioner( - A_val=sparse_val, - A_col_idx=sparse_col_idx, - A_row_ptr=sparse_row_ptr, - threads=1, - ) - else: - raise Exception - x, error, iterations = _solve_pcg( - sparse_val, - sparse_col_idx, - sparse_row_ptr, - P_val, - P_col_idx, - P_row_ptr, - b, - max_iterations=sparse_val.size**2, # sqrt(n) for n x n system - target_error=target_error, # 1.0e-6 - X0=np.zeros(b.size, dtype=np.float64), - threads=1, + network_manager = NetworkManager( + conn=conn, + cond=cond, + inlets=inlets, + outlets=outlets, ) + network_manager.generate_sparse_system() + a_sparse_array, b_array = network_manager.get_sparse_system() + + darcy_solver = DarcySolver() + darcy_solver.set_linear_system(a_sparse_array, b_array) + darcy_solver.generate_preconditioner(preconditioner) + x, error, iterations = darcy_solver.solve_pcg() + + pressure = network_manager.get_pressure_list(x) - pressure = np.zeros(inlets.size, dtype=np.float64) - pressure[mid_to_total_indexes] = x * np.float64(101325) - for i in range(inlets.size): - if inlets[i] == 1: - pressure[i] = np.float64(101325) - elif outlets[i] == 1: - pressure[i] = np.float64(0) elif solver == "pypardiso": conn = perm.network["throat.conns"].astype(np.int32) cond = perm.network["throat.conductance"].astype(np.float64) - r = _get_sparse_system(conn, cond, inlets, outlets) - sparse_val, sparse_col_idx, sparse_row_ptr, b, mid_to_total_indexes = r - A = csr_matrix( + network_manager = NetworkManager( + conn=conn, + cond=cond, + inlets=inlets, + outlets=outlets, + ) + network_manager.generate_sparse_system() + a_sparse_array, b_array = network_manager.get_sparse_system() + A_csr = csr_matrix( ( - sparse_val, - sparse_col_idx, - np.append(sparse_row_ptr, sparse_val.size), + a_sparse_array["val"], + a_sparse_array["col_idx"], + np.append(a_sparse_array["row_ptr"], a_sparse_array["val"].size), ) ) - x = spsolve(A, b) - pressure = np.zeros(inlets.size, dtype=np.float64) - pressure[mid_to_total_indexes] = x * np.float64(101325) - for i in range(inlets.size): - if inlets[i] == 1: - pressure[i] = np.float64(101325) - elif outlets[i] == 1: - pressure[i] = np.float64(0) + x = spsolve(A_csr, b_array) + pressure = network_manager.get_pressure_list(x) project = perm.project pore_dict = {} throat_dict = {} for l in range(len(project)): for p in project[l].props(): - # if slicer_is_in_developer_mode(): - # print(p, type(project[l][p]), project[l][p]) prop_array = project[l][p] if prop_array.ndim == 1: if p[:4] == "pore": @@ -420,133 +403,6 @@ def single_phase_permeability( return (perm, pore_dict, throat_dict) -@njit -def _get_sparse_system(conn, cond, inlets, outlets): - # network must have only connected pores - # conn array(n, 2) - - inlets *= np.int32(1) - outlets - border = inlets + outlets - - n_p_total = inlets.size - n_p_in = inlets.sum() - n_p_out = outlets.sum() - n_p_mid = n_p_total - n_p_in - n_p_out - n_t = cond.size - - mid_to_total_indexes = np.zeros((n_p_mid), dtype=np.int32) - total_to_mid_indexes = np.zeros((n_p_total), dtype=np.int32) - - pore_index_filled = 0 - for p in range(n_p_total): - if border[p] == 0: - mid_to_total_indexes[pore_index_filled] = p - total_to_mid_indexes[p] = pore_index_filled - pore_index_filled += 1 - if pore_index_filled != mid_to_total_indexes.size: - raise Exception - - sparse_row_counter = np.zeros((n_p_mid), dtype=np.int32) - n_mid_t = 0 - for t in range(n_t): - i = conn[t, 0] - j = conn[t, 1] - if (border[i] == 0) and (border[j] == 0): - n_mid_t += 1 - sparse_row_counter[total_to_mid_indexes[i]] += 1 - sparse_row_counter[total_to_mid_indexes[j]] += 1 - - sparse_val = np.zeros((n_p_mid + 2 * n_mid_t), dtype=np.float64) - sparse_col_idx = np.ones((sparse_val.size), dtype=np.int32) * -1 - sparse_row_ptr = np.zeros((n_p_mid), dtype=np.int32) - sparse_col_idx[0] = 0 - for i in range(1, n_p_mid): - sparse_row_ptr[i] = sparse_row_ptr[i - 1] + sparse_row_counter[i - 1] + 1 - sparse_col_idx[sparse_row_ptr[i]] = i - - b = np.zeros(n_p_mid, dtype=np.float64) - - for t in range(n_t): - conn_0 = conn[t, 0] - conn_1 = conn[t, 1] - conductance = cond[t] - - for i, j in ((conn_0, conn_1), (conn_1, conn_0)): - if border[i] == 1: - continue - i_mid = total_to_mid_indexes[i] - row_ptr_start = sparse_row_ptr[i_mid] - sparse_val[row_ptr_start] -= conductance - # print(i, j) - - if inlets[j] == 1: - b[i_mid] -= conductance - elif border[j] == 0: - j_mid = total_to_mid_indexes[j] - # print("mid: ", i_mid, j_mid) - # target column is j_mid - # first check if column is already occupied - if (i_mid + 1) < sparse_row_ptr.size: - row_ptr_end = sparse_row_ptr[i_mid + 1] - else: - row_ptr_end = sparse_val.size - - found = False - for linear_index in range(row_ptr_start, row_ptr_end): - if sparse_col_idx[linear_index] == j_mid: - sparse_val[linear_index] += conductance - found = True - break - if not found: - local_index = sparse_row_counter[i_mid] - linear_index = row_ptr_start + local_index - if (local_index <= 0) or (sparse_val[linear_index] != 0): - raise Exception - sparse_col_idx[linear_index] = j_mid - sparse_val[linear_index] = conductance - sparse_row_counter[i_mid] -= 1 - found = True - # print(found, linear_index, sparse_val[linear_index]) - if not found: - raise Exception - - # print(sparse_val.sum()) - # sparse cleanup - - sparse_val_dirty = sparse_val.copy() - sparse_col_idx_dirty = sparse_col_idx.copy() - sparse_row_ptr_dirty = sparse_row_ptr.copy() - - nulls_counter = 0 - for i in range(sparse_row_ptr.size): - row_ptr_start = sparse_row_ptr[i] - if (i + 1) < sparse_row_ptr.size: - row_ptr_stop = sparse_row_ptr[i + 1] - else: - row_ptr_stop = sparse_val.size - nulls = (sparse_col_idx[row_ptr_start:row_ptr_stop] == -1).sum() - filled = row_ptr_stop - row_ptr_start - nulls - sort_index = np.argsort(sparse_col_idx[row_ptr_start:row_ptr_stop]) - sorted_vals = sparse_val[row_ptr_start:row_ptr_stop][sort_index] - sorted_col_idx = sparse_col_idx[row_ptr_start:row_ptr_stop][sort_index] - compacted_start = row_ptr_start - nulls_counter - compacted_end = compacted_start + filled - sparse_val[compacted_start:compacted_end] = sorted_vals[nulls:] - sparse_col_idx[compacted_start:compacted_end] = sorted_col_idx[nulls:] - nulls_counter += nulls - sparse_row_ptr[i] -= nulls_counter - # print() - # print(sorted_vals) - # print(sparse_col_idx) - # print(sparse_row_ptr) - if nulls > 0: - sparse_val = sparse_val[:-nulls_counter] - sparse_col_idx = sparse_col_idx[:-nulls_counter] - - # return sparse_val, sparse_col_idx, sparse_row_ptr, b, mid_to_total_indexes, sparse_val_dirty, sparse_col_idx_dirty, sparse_row_ptr_dirty - return sparse_val, sparse_col_idx, sparse_row_ptr, b, mid_to_total_indexes - - def get_flow_rate(pn_pores, pn_throats): inlet_flow_total = np.float64(0.0) outlet_flow_total = np.float64(0.0) @@ -591,323 +447,3 @@ def get_flow_rate(pn_pores, pn_throats): flow_rate = (outlet_flow_total + inlet_flow_total) / 2 return flow_rate - - -##### Temporary, delete later and import module - - -@njit -def _solve_cg( - A_val, - A_col_idx, - A_row_ptr, - b, - max_iterations, # sqrt(n) for n x n system - target_error, # 1.0e-6 - X0, - threads, -): - # Reference: https://repository.lsu.edu/cgi/viewcontent.cgi?article=1254&context=honors_etd - - x = X0.copy() - r = b.copy() - m = np.empty(1, dtype=np.float64) - m[0] = _square_sum_vector(r, threads) # f(x:vector) = x'*x - m_last = np.empty(1, dtype=np.float64) - p = r.copy() - alpha = np.empty(1, dtype=np.float64) - beta = np.empty(1, dtype=np.float64) - iteration = 0 - for _ in range(max_iterations): - iteration += 1 - alpha[0] = m[0] / _scalar_product( - p, - A_val, - A_col_idx, - A_row_ptr, - threads, - ) # scalar_product = p'*A*p - _add_product(x, alpha[0], p, threads) # f(x: vector, y: scalar, z:vector): x += y * z - # _subtract_product_of_product( - # r, - # alpha[0], - # A_val, - # A_col_idx, - # A_row_ptr, - # p, - # threads, - # ) # f(x:vector, y:scalar, z:array, k:vector): x -= y * z * k - r[:] = _recalc_residuals_jit(A_val, A_col_idx, A_row_ptr, b, x) - m_last[0] = m[0] - m[0] = _square_sum_vector(r, threads) - beta[0] = m[0] / m_last[0] - _multiply_and_add( - p, - r, - beta[0], - threads, - ) # f(x:vector, y:vector, z:scalar): x = y + z * x - error = np.sqrt(_square_sum_vector(r, threads) / _square_sum_vector(b, threads)) - if error <= target_error: - return x, error, iteration - - return x, error, iteration - - -@njit(parallel=True) -def _square_sum_vector(v, threads): - # f(v:vector) = v'*v - partial_sum = np.zeros(threads, dtype=np.float64) - n = v.size - - for w in prange(threads): - thread_start = w * n // threads - thread_end = (w + 1) * n // threads - for i in range(thread_start, thread_end): - partial_sum[w] += v[i] ** 2 - return partial_sum.sum() - - -@njit(parallel=True) -def _scalar_product( - v, - A_val, - A_col_idx, - A_row_ptr, - threads, -): - # f(v: vector[n], A:array[n, n]) = x'*A*x - partial_sum = np.zeros(threads, dtype=np.float64) - n = v.size - - for w in prange(threads): - thread_start = w * n // threads - thread_end = (w + 1) * n // threads - for i in range(thread_start, thread_end): - A_start = A_row_ptr[i] - if (i + 1) < n: - A_stop = A_row_ptr[i + 1] - else: - A_stop = A_val.size - for A_linear_index in range(A_start, A_stop): - j = A_col_idx[A_linear_index] - partial_sum[w] += v[i] * A_val[A_linear_index] * v[j] - return partial_sum.sum() - - -@njit(parallel=True) -def _add_product(v, x, u, threads): - # f(v: vector, x: scalar, u:vector): v += x * u - n = v.size - - for w in prange(threads): - thread_start = w * n // threads - thread_end = (w + 1) * n // threads - for i in range(thread_start, thread_end): - v[i] += x * u[i] - - -@njit(parallel=True) -def _recalc_residuals_jit(r, val, col_idx, row_ptr, condensed_b, X): - # residuals = np.zeros(condensed_b.size, dtype=np.float64) - r[:] = condensed_b - - for row in range(row_ptr.size): - start = row_ptr[row] - if row < (row_ptr.size - 1): - stop = row_ptr[row + 1] - else: - stop = val.size - - for index in range(start, stop): - v = val[index] - column = col_idx[index] - r[row] -= v * X[column] - - -@njit(parallel=True) -def _subtract_product_of_product( - v, - x, - A_val, - A_col_idx, - A_row_ptr, - u, - threads, -): - # f(v:vector, x:scalar, A:array, u:vector): v -= x * A * u - n = v.size - - for w in prange(threads): - thread_start = w * n // threads - thread_end = (w + 1) * n // threads - for i in range(thread_start, thread_end): - A_start = A_row_ptr[i] - if (i + 1) < n: - A_stop = A_row_ptr[i + 1] - else: - A_stop = A_val.size - for A_linear_index in range(A_start, A_stop): - j = A_col_idx[A_linear_index] - v[i] -= x * A_val[A_linear_index] * u[j] - - -@njit(parallel=True) -def _multiply_and_add( - v, - u, - x, - threads, -): - # f(v:vector, u:vector, x:scalar): v = u + x * v - n = v.size - - for w in prange(threads): - thread_start = w * n // threads - thread_end = (w + 1) * n // threads - for i in range(thread_start, thread_end): - v[i] = u[i] + x * v[i] - - -@njit -def _get_diagonal_preconditioner( - A_val, - A_col_idx, - A_row_ptr, - threads, -): # f(v, A): v = A*v - diagonal_n = A_row_ptr.size - P_val = np.empty(diagonal_n, dtype=np.float64) - P_col_idx = np.arange(diagonal_n, dtype=np.int32) - P_row_ptr = np.arange(diagonal_n, dtype=np.int32) - for row in range(diagonal_n): - start = A_row_ptr[row] - if row < (A_row_ptr.size - 1): - stop = A_row_ptr[row + 1] - else: - stop = A_val.size - - for linear_index in range(start, stop): - column = A_col_idx[linear_index] - if column == row: - v = A_val[linear_index] - P_val[row] = 1 / v - return P_val, P_col_idx, P_row_ptr - - -@njit -def _solve_pcg( - A_val, - A_col_idx, - A_row_ptr, - P_val, - P_col_idx, - P_row_ptr, - b, - max_iterations, # sqrt(n) for n x n system - target_error, # 1.0e-6 - X0, - threads, -): - # Reference: https://repository.lsu.edu/cgi/viewcontent.cgi?article=1254&context=honors_etd - - x = X0.copy() - r = b.copy() - m = np.empty(1, dtype=np.float64) - m[0] = _scalar_product( - r, - P_val, - P_col_idx, - P_row_ptr, - threads, - ) # scalar_product = p'*A*p - m_last = np.empty(1, dtype=np.float64) - p = r.copy() - p[:] = _vector_array_multiply( - p, - P_val, - P_col_idx, - P_row_ptr, - threads, - ) # f(v, A): v = A*v - alpha = np.empty(1, dtype=np.float64) - beta = np.empty(1, dtype=np.float64) - iteration = 0 - for _ in range(max_iterations): - iteration += 1 - alpha[0] = m[0] / _scalar_product( - p, - A_val, - A_col_idx, - A_row_ptr, - threads, - ) # scalar_product = p'*A*p - _add_product(x, alpha[0], p, threads) # f(x: vector, y: scalar, z:vector): x += y * z - _recalc_residuals_jit(r, A_val, A_col_idx, A_row_ptr, b, x) - m_last[0] = m[0] - m[0] = _scalar_product( - r, - P_val, - P_col_idx, - P_row_ptr, - threads, - ) # scalar_product = p'*A*p - beta[0] = m[0] / m_last[0] - p[:] = _multiply_array_and_add( - p, - r, - beta[0], - P_val, - P_col_idx, - P_row_ptr, - threads, - ) # f(x:vector, y:vector, z:scalar, A:array): x = A * y + z * x - error = np.sqrt(_square_sum_vector(r, threads) / _square_sum_vector(b, threads)) - if error <= target_error: - return x, error, iteration - - return x, error, iteration - - -@njit # -def _vector_array_multiply( - p, - val, - col_idx, - row_ptr, - threads, -): # f(v, A): v = A*v - new_p = np.zeros_like(p) - for row in range(row_ptr.size): - start = row_ptr[row] - if row < (row_ptr.size - 1): - stop = row_ptr[row + 1] - else: - stop = val.size - - for index in range(start, stop): - v = val[index] - column = col_idx[index] - new_p[row] += v * p[column] - return new_p - - -@njit(parallel=True) -def _multiply_array_and_add( - u, - v, - x, - val, - col_idx, - row_ptr, - threads, -): # f(u:vector, v:vector, x:scalar, A:array): x = A * v + x * u - new_p = _vector_array_multiply( - v, - val, - col_idx, - row_ptr, - threads, - ) - new_p += x * u - return new_p diff --git a/src/ltrace/ltrace/pore_networks/pnflow_parameter_defs.py b/src/ltrace/ltrace/pore_networks/pnflow_parameter_defs.py index bb25069..e3a6766 100644 --- a/src/ltrace/ltrace/pore_networks/pnflow_parameter_defs.py +++ b/src/ltrace/ltrace/pore_networks/pnflow_parameter_defs.py @@ -71,6 +71,12 @@ "false_value": "F", "default_values": {"left": False, "right": True}, }, + "seed": { + "display_name": "Simulation seed", + "layout": "options", + "dtype": "singleint", + "default_value": 0, + }, "calc_box_lower_boundary": { "display_name": "Lower box boundary", "layout": "options", @@ -173,6 +179,14 @@ "false_value": "F", "default_value": False, }, + "create_ca_distributions": { + "display_name": "Create CA distribution nodes", + "layout": "options", + "dtype": "singlecheckbox", + "true_value": "T", + "false_value": "F", + "default_value": False, + }, "keep_temporary": { "display_name": "Keep temporary files", "layout": "options", diff --git a/src/ltrace/ltrace/pore_networks/visualization_model.py b/src/ltrace/ltrace/pore_networks/visualization_model.py index 57c2526..e6d7175 100644 --- a/src/ltrace/ltrace/pore_networks/visualization_model.py +++ b/src/ltrace/ltrace/pore_networks/visualization_model.py @@ -13,7 +13,7 @@ def visualize_vtu( - unstructured_grid, + filepath, cycle, scale_factor=10**3, pore_scale=2000, @@ -30,6 +30,10 @@ def visualize_vtu( Must be 'w' for wetting phase (usually waater) injection cycles and 'o' for non-wetting phase (usually oil) injection cycles """ + reader = vtk.vtkXMLUnstructuredGridReader() + reader.SetFileName(filepath) + reader.Update() + unstructured_grid = reader.GetOutput() sphere_theta_resolution = 8 sphere_phi_resolution = 8 @@ -169,18 +173,13 @@ def visualize_vtu( return pressure, merger -def generate_model_variable_scalar(temp_folder, min_saturation_delta=0.005, is_multiscale=False): +def generate_model_variable_scalar(temp_folder, is_multiscale=False): file_names = sorted([i for i in os.listdir(temp_folder) if i[-4:] == ".vtu"]) pressures = [] - reader = vtk.vtkXMLUnstructuredGridReader() - base_filepath = os.path.join(temp_folder, file_names[0]) - reader.SetFileName(base_filepath) - reader.Update() - mesh = reader.GetOutput() pressure, pore_mesh = visualize_vtu( - mesh, create_model=False, cycle=file_names[0][2].lower(), normalize_radius=is_multiscale + base_filepath, create_model=False, cycle=file_names[0][2].lower(), normalize_radius=is_multiscale ) point_data = pore_mesh.GetOutput().GetPointData() pressures.append(pressure) @@ -193,16 +192,13 @@ def generate_model_variable_scalar(temp_folder, min_saturation_delta=0.005, is_m for data_point, file_name in enumerate(file_names[1:], start=1): filepath = os.path.join(temp_folder, file_name) - reader.SetFileName(filepath) - reader.Update() - mesh = reader.GetOutput() pressure, poly_data = visualize_vtu( - mesh, cycle=file_name[2].lower(), create_model=False, normalize_radius=is_multiscale + filepath, cycle=file_name[2].lower(), create_model=False, normalize_radius=is_multiscale ) saturation = poly_data.GetOutput().GetPointData().GetArray("saturation") new_array = vtk.util.numpy_support.vtk_to_numpy(saturation) - if np.mean(np.abs(new_array - previous_array)) >= min_saturation_delta: + if data_point == 1 or np.mean(np.abs(new_array - previous_array)) != 0.0: saturation.SetName(f"saturation_{(i:=i+1)}") point_data.AddArray(saturation) pressures.append(pressure) @@ -235,7 +231,7 @@ def generate_model_variable_scalar(temp_folder, min_saturation_delta=0.005, is_m return extract.GetOutputDataObject(0), saturation_steps -def _model_elements_from_grid( +def _unstructured_grid_to_dict( unstructured_grid, cycle, scale_factor=10**3, @@ -289,7 +285,6 @@ def _model_elements_from_grid( # y_min -= y_length * linear_size_reduction # y_max += y_length * linear_size_reduction - throat_id_list = np.empty(n_cells, dtype=np.int64) throat_index_list = np.empty(n_cells, dtype=np.int64) neighbors_id_list = np.empty((n_cells, 2), dtype=np.int64) throat_radius_list = np.empty(n_cells, dtype=np.float64) @@ -321,10 +316,8 @@ def _model_elements_from_grid( ): continue - throat_id = unstructured_grid.GetCell(i).GetPointIds().GetId(2) throat_radius = unstructured_grid.GetCellData().GetArray("RRR").GetComponent(i, 0) - throat_id_list[throat_count] = throat_id throat_index_list[throat_count] = i neighbors_id_list[throat_count] = (left_pore_id, right_pore_id) throat_radius_list[throat_count] = throat_radius @@ -418,16 +411,47 @@ def _model_elements_from_grid( throats = {} for i in range(throat_count): throat_index = throat_index_list[i] - throat_id = throat_id_list[i] left_pore_id, right_pore_id = neighbors_id_list[i] throats[throat_index] = { "first_conn": pore_mapper[left_pore_id], "second_conn": pore_mapper[right_pore_id], "radius": throat_radius_list[i], - "Sw": unstructured_grid.GetPointData().GetArray("Sw").GetComponent(throat_id, 0), "Sw_cell": unstructured_grid.GetCellData().GetArray("Sw").GetComponent(throat_index, 0), } + return pores, throats, arrows, volume_side + + +def _model_elements_from_grid( + unstructured_grid, + cycle, + scale_factor=10**3, + pore_scale=2000, + throat_scale=20, + arrow_scale=0.2, + axis="x", + normalize_radius=False, + **kwargs, +): + """Model elements from unstructured grid + + Args: + unstructured_grid (vtkUnstructuredGrid): unstructured_grid + cycle (char): "w" for water injection, "o" for oil injection + scale_factor (float): Scales entire network + pore_scale (float): Scales pore sizes + throat_scale (float): Scale throat sizes + arrow_scale (float): Scale arrow sizes + axis (char): axis + normalize_radius (bool): If true, ignore throats and pores scale factors and normalize their size by the grid volume + + Returns: + dict: model elements data + """ + pores, throats, arrows, volume_side = _unstructured_grid_to_dict( + unstructured_grid, cycle, scale_factor, pore_scale, throat_scale, arrow_scale, axis, normalize_radius + ) + coordinates = vtk.vtkPoints() radii = vtk.vtkFloatArray() radii.SetName("radius") diff --git a/src/ltrace/ltrace/remote/hosts/ssh/widget.py b/src/ltrace/ltrace/remote/hosts/ssh/widget.py index b0908e0..f3baf72 100644 --- a/src/ltrace/ltrace/remote/hosts/ssh/widget.py +++ b/src/ltrace/ltrace/remote/hosts/ssh/widget.py @@ -124,6 +124,8 @@ def _onAddPublicKeyClicked(self) -> None: self.rsa_key = Path(fileDialog.selectedFiles()[0]) self.keyLabel.setText(f" - {self.rsa_key}") + fileDialog.deleteLater() + def getRSAKeyPathString(self) -> str: return str(self.rsa_key) if self.rsa_key else None diff --git a/src/ltrace/ltrace/screenshot/Screenshot.py b/src/ltrace/ltrace/screenshot/Screenshot.py index 8761b10..c599065 100644 --- a/src/ltrace/ltrace/screenshot/Screenshot.py +++ b/src/ltrace/ltrace/screenshot/Screenshot.py @@ -1,7 +1,15 @@ +from pathlib import Path + import qt import slicer import vtk -from pathlib import Path +import pyqtgraph.exporters +import pyqtgraph as pg + +from ltrace.slicer_utils import getResourcePath + +GRAPHIC_VIEW_DATA = "GraphicViewData" +SLICE_VIEW_DATA = "SliceViewData" def _captureRenderWindow(renderWindow, isTransparent, fileName): @@ -65,6 +73,13 @@ def _captureSliceView(sliceName, isTransparent, fileName): renderWindow.SetAlphaBitPlanes(alphaBitPlanes) +def _captureGraphView(viewIdentifier, fileName): + plotItem = slicer.util.getModuleWidget("ImageLogData").getGraphicViewPlotItem(viewIdentifier) + if isinstance(plotItem, pg.PlotItem): + exporter = pg.exporters.ImageExporter(plotItem.scene()) + exporter.export(fileName) + + class ScreenshotWidget(qt.QDialog): VIEW_OPTION = ["Red", "Green", "Yellow"] THREED_VIEW_OPTION = "3D View" @@ -75,17 +90,23 @@ class ScreenshotWidget(qt.QDialog): IS_TRANSPARENT_SETTINGS_KEY = "/".join((SETTINGS_NAME, "isTransparent")) SAVE_DIRECTORY_SETTINGS_KEY = "/".join((SETTINGS_NAME, "saveDirectory")) - def __init__(self, icon): + ICON = "ScreenShot.png" + + def __init__(self): super().__init__( 0, qt.Qt.WindowSystemMenuHint | qt.Qt.WindowTitleHint | qt.Qt.WindowCloseButtonHint, ) - self.iconPath = icon - self.setWindowTitle("Screenshot") + self.setWindowTitle("ScreenShot") self.setAttribute(qt.Qt.WA_DeleteOnClose) self.saved_lines = [] + try: + self.imagelogViews = slicer.util.getModuleWidget("ImageLogData").getVisibleViews() + except: + self.imagelogViews = None + self.setup() def setup(self): @@ -93,7 +114,16 @@ def setup(self): # View option self.viewCombobox = qt.QComboBox() - options = (self.THREED_VIEW_OPTION,) + slicer.app.layoutManager().sliceViewNames() + + imagelogOptions = [] + if self.imagelogViews: + self.imagelogValidIds = [] + for key, value in self.imagelogViews.items(): + if value["type"] in [GRAPHIC_VIEW_DATA, SLICE_VIEW_DATA]: + imagelogOptions.append(value["name"]) + self.imagelogValidIds.append(key) + + options = [self.THREED_VIEW_OPTION] + self.VIEW_OPTION + imagelogOptions for option in options: self.viewCombobox.addItem(option) self.viewCombobox.currentText = slicer.app.settings().value(self.VIEW_SETTINGS_KEY, self.THREED_VIEW_OPTION) @@ -103,12 +133,17 @@ def setup(self): layout.addRow("Transparent background:", self.transparentCheck) # Annotations options + self.annotationsOptionsWidget = qt.QWidget() + annotationsOptionsLayout = qt.QFormLayout(self.annotationsOptionsWidget) + annotationsOptionsLayout.setContentsMargins(0, 0, 0, 0) + hbox_line = qt.QHBoxLayout() + hbox_line.setSpacing(10) line_left = qt.QFrame() line_left.setFrameShape(qt.QFrame.HLine) line_left.setFrameShadow(qt.QFrame.Sunken) hbox_line.addWidget(line_left) - text_label = qt.QLabel(" Add Annotations") + text_label = qt.QLabel("Add Annotations") hbox_line.addWidget(text_label) line_right = qt.QFrame() line_right.setFrameShape(qt.QFrame.HLine) @@ -117,10 +152,10 @@ def setup(self): line.setFrameShape(qt.QFrame.HLine) line.setFrameShadow(qt.QFrame.Sunken) hbox_line.addWidget(line_right) - layout.addRow(hbox_line) + annotationsOptionsLayout.addRow(hbox_line) self.input = qt.QLineEdit("Click Add") - layout.addRow("Annotation:", self.input) + annotationsOptionsLayout.addRow("Annotation:", self.input) hbox = qt.QHBoxLayout() self.radio_left = qt.QRadioButton("Left") @@ -130,14 +165,14 @@ def setup(self): hbox.addWidget(self.radio_left) hbox.addWidget(self.radio_center) hbox.addWidget(self.radio_right) - layout.addRow("Text Position:", hbox) + annotationsOptionsLayout.addRow("Text Position:", hbox) self.fontSizeSlider = slicer.qMRMLSliderWidget() self.fontSizeSlider.maximum = 100 self.fontSizeSlider.minimum = 12 self.fontSizeSlider.value = 15 self.fontSizeSlider.singleStep = 1 - layout.addRow("Font Size:", self.fontSizeSlider) + annotationsOptionsLayout.addRow("Font Size:", self.fontSizeSlider) textButtons = qt.QHBoxLayout() addTextButton = qt.QPushButton("Add") @@ -147,8 +182,9 @@ def setup(self): textButtons.addWidget(addTextButton) textButtons.addWidget(removeTextButton) textButtons.setAlignment(qt.Qt.AlignCenter) - layout.addRow(textButtons) + annotationsOptionsLayout.addRow(textButtons) + layout.addRow(self.annotationsOptionsWidget) layout.addRow(line) layout.addRow(qt.QFrame()) @@ -160,7 +196,7 @@ def setup(self): buttonBox.rejected.connect(self.onReject) layout.addRow(buttonBox) - self.viewCombobox.currentIndexChanged.connect(lambda font: self.clearAll()) + self.viewCombobox.currentIndexChanged.connect(self.viewIndexChange) self.radio_left.clicked.connect(lambda: self.clearAll()) self.radio_center.clicked.connect(lambda: self.clearAll()) self.radio_right.clicked.connect(lambda: self.clearAll()) @@ -168,7 +204,7 @@ def setup(self): addTextButton.clicked.connect(self.addText) removeTextButton.clicked.connect(self.removeText) - self.setWindowIcon(qt.QIcon(self.iconPath)) + self.setWindowIcon(qt.QIcon(getResourcePath("Icons") / self.ICON)) def addText(self): input_text = self.input.text @@ -182,6 +218,15 @@ def removeText(self): del self.saved_lines[-1] self.renderText() + def viewIndexChange(self, index): + self.annotationsOptionsWidget.setEnabled(True) + if index > 3: + id = self.imagelogValidIds[index - 4] + if self.imagelogViews[id]["type"] == GRAPHIC_VIEW_DATA: + self.annotationsOptionsWidget.setEnabled(False) + + self.clearAll() + def clearAll(self): textColor = (1.0, 1.0, 1.0) for viewName in self.VIEW_OPTION: @@ -192,12 +237,19 @@ def clearAll(self): def renderText(self): textColor = (1.0, 1.0, 1.0) viewName = self.viewCombobox.currentText + viewIndex = self.viewCombobox.currentIndex text = self.get_saved_text() - if viewName == self.THREED_VIEW_OPTION: + if viewIndex == 0: # 3D VIEW self.renderIn3DView(text, textColor) - else: + + elif viewIndex in [1, 2, 3]: # RED, GREEN or YELLOW Camera self.renderInView(text, viewName, textColor) + else: # ImageLog Views + id = self.imagelogValidIds[viewIndex - 4] + if self.imagelogViews[id]["type"] == SLICE_VIEW_DATA: + self.renderInView(text, f"ImageLogSliceView{id}", textColor) + def renderInView(self, text, viewName, textColor): lm = slicer.app.layoutManager() view = lm.sliceWidget(viewName).sliceView() @@ -244,6 +296,7 @@ def get_saved_text(self): def onSaveAs(self): viewName = self.viewCombobox.currentText + viewIndex = self.viewCombobox.currentIndex isTransparent = self.transparentCheck.checked directory = slicer.app.settings().value( self.SAVE_DIRECTORY_SETTINGS_KEY, @@ -259,11 +312,19 @@ def onSaveAs(self): slicer.app.settings().setValue(self.IS_TRANSPARENT_SETTINGS_KEY, str(isTransparent)) slicer.app.settings().setValue(self.SAVE_DIRECTORY_SETTINGS_KEY, directory) - if viewName == self.THREED_VIEW_OPTION: + if viewIndex == 0: # 3D VIEW _capture3DView(isTransparent, fileName) - else: + + elif viewIndex in [1, 2, 3]: # RED, GREEN or YELLOW Camera _captureSliceView(viewName, isTransparent, fileName) + else: # ImageLog Views + id = self.imagelogValidIds[viewIndex - 4] + if self.imagelogViews[id]["type"] == GRAPHIC_VIEW_DATA: + _captureGraphView(id, fileName) + else: + _captureSliceView(f"ImageLogSliceView{id}", isTransparent, fileName) + self.accept() def onReject(self): diff --git a/tools/deploy/GeoSlicerManual/docs/Core CT/corect.md b/src/ltrace/ltrace/slicer/about/__init__.py similarity index 100% rename from tools/deploy/GeoSlicerManual/docs/Core CT/corect.md rename to src/ltrace/ltrace/slicer/about/__init__.py diff --git a/src/ltrace/ltrace/slicer/about/about_dialog.py b/src/ltrace/ltrace/slicer/about/about_dialog.py new file mode 100644 index 0000000..ad8a903 --- /dev/null +++ b/src/ltrace/ltrace/slicer/about/about_dialog.py @@ -0,0 +1,67 @@ +import qt +import slicer + +from ltrace.slicer.app import getApplicationVersion +from ltrace.slicer_utils import getResourcePath + + +class AboutDialog(qt.QDialog): + def __init__(self, parent, *args, **kwargs) -> None: + super().__init__(parent) + + self.setWindowTitle("About GeoSlicer") + self.setWindowFlags(self.windowFlags() & ~qt.Qt.WindowContextHelpButtonHint) + self.setWindowIcon(qt.QIcon((getResourcePath("Icons") / "GeoSlicer.ico").as_posix())) + self.setFixedSize(600, 430) + self.setupUi() + self.setObjectName("GeoSlicer About Dialog") + + def setupUi(self) -> None: + layout = qt.QVBoxLayout(self) + + pixMap = qt.QPixmap((getResourcePath("Icons") / "GeoSlicerLogo.png").as_posix()) + pixMap = pixMap.scaled( + pixMap.width() // 10, pixMap.height() // 10, qt.Qt.KeepAspectRatio, qt.Qt.SmoothTransformation + ) + logoLabel = qt.QLabel() + logoLabel.setPixmap(pixMap) + + textBrowser = qt.QTextBrowser() + textBrowser.setFixedSize(340, 370) + textBrowser.setOpenExternalLinks(True) + + textBrowser.setFontPointSize(25) + textBrowser.append("GeoSlicer") + textBrowser.setFontPointSize(11) + textBrowser.append("") + textBrowser.append(getApplicationVersion()) + textBrowser.append("") + textBrowser.insertHtml(slicer.modules.AppContextInstance.getAboutGeoSlicer()) + + aboutQtButton = qt.QPushButton("About Qt") + aboutQtButton.setAutoDefault(False) + closeButton = qt.QPushButton("Close") + closeButton.setAutoDefault(False) + + horizontalLayout = qt.QHBoxLayout() + horizontalLayout.addWidget(logoLabel, 1, qt.Qt.AlignCenter) + horizontalLayout.addWidget(textBrowser, 1, qt.Qt.AlignRight) + + buttonsLayout = qt.QHBoxLayout() + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(aboutQtButton) + buttonsLayout.addWidget(closeButton) + + layout.addLayout(horizontalLayout) + layout.addLayout(buttonsLayout) + self.setLayout(layout) + + # connections + aboutQtButton.clicked.connect(self.__onAboutQtButtonClicked) + closeButton.clicked.connect(self.__onCloseButtonClicked) + + def __onAboutQtButtonClicked(self): + slicer.app.aboutQt() + + def __onCloseButtonClicked(self): + self.close() diff --git a/src/ltrace/ltrace/slicer/app/__init__.py b/src/ltrace/ltrace/slicer/app/__init__.py new file mode 100644 index 0000000..05a741c --- /dev/null +++ b/src/ltrace/ltrace/slicer/app/__init__.py @@ -0,0 +1,86 @@ +import json +from pathlib import Path +from typing import Dict + +import slicer +import vtk +import os + +from datetime import datetime + + +def parseApplicationVersion(data: Dict) -> str: + geoslicerVersion = data["GEOSLICER_VERSION"] + geoslicerHash = data["GEOSLICER_HASH"] + geoslicerHashDirty = data["GEOSLICER_HASH_DIRTY"] + geoslicerBuildTime = datetime.strptime(data["GEOSLICER_BUILD_TIME"], "%Y-%m-%d %H:%M:%S.%f") + + versionString = geoslicerVersion + if not geoslicerVersion: + hash_ = geoslicerHash[:8] + "*" if geoslicerHashDirty else "" + date = geoslicerBuildTime.strftime("%Y-%m-%d") + versionString = "{} {}".format(hash_, date) + + return versionString + + +def getApplicationVersion(): + return slicer.modules.AppContextInstance.appVersionString + + +def getApplicationInfo(key): + return slicer.modules.AppContextInstance.appData.get(key, None) + + +def updateWindowTitle(versionString): + """Updates main window's title according to the current project""" + + projectString = "Untitled project" + projectURL = slicer.mrmlScene.GetURL() + if projectURL != "": + projectString = os.path.dirname(projectURL) + + windowTitle = "GeoSlicer {} - {} [*]".format(versionString, projectString) + slicer.modules.AppContextInstance.mainWindow.setWindowTitle(windowTitle) + + +def tryDetectProjectDataType(): + + nodes = slicer.util.getNodesByClass("vtkMRMLVolumeNode") + if len(nodes) == 0: + return False + + sceneTree = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) + rootItemID = sceneTree.GetSceneItemID() + + children = vtk.vtkIdList() + sceneTree.GetItemChildren(rootItemID, children) + + for i in reversed(range(children.GetNumberOfIds())): + child = children.GetId(i) + name = sceneTree.GetItemName(child) + print("name", name) + if "Thin Section" in name: + return "Thin Section" + elif "Micro CT" in name: + return "Volumes" + elif "Multicore" in name: + return "Core" + elif "Well Logs" in name: + return "Well Logs" + elif "Multiscale" in name: + return "Multiscale" + + return None + + +def getJsonData() -> Dict: + appName = slicer.app.applicationName + appHome = Path(slicer.app.slicerHome) + appSlicerVersion = f"{slicer.app.majorVersion}.{slicer.app.minorVersion}" + scriptedModulesDir = appHome / "lib" / f"{appName}-{appSlicerVersion}" / "qt-scripted-modules" + appJson = scriptedModulesDir / "Resources" / "json" / "GeoSlicer.json" + with open(appJson, "r") as file: + jsonData = json.load(file) + + return jsonData diff --git a/src/ltrace/ltrace/slicer/app/custom_3dview.py b/src/ltrace/ltrace/slicer/app/custom_3dview.py new file mode 100644 index 0000000..caad18e --- /dev/null +++ b/src/ltrace/ltrace/slicer/app/custom_3dview.py @@ -0,0 +1,27 @@ +import slicer +import qt + +from ltrace.slicer.helpers import themeIsDark + + +def customize_3d_view(): + """ " + modified by: Gabriel Muller + commit 4dee811d6ef94128a37e85d5747a50f0bf5f5acb + * PL-1170 3D view settings + """ + viewWidget = slicer.app.layoutManager().threeDWidget(0) + viewNode = viewWidget.mrmlViewNode() + if themeIsDark(): + viewNode.SetBackgroundColor(0, 0, 0) + viewNode.SetBackgroundColor2(0, 0, 0) + # Hiding the purple 3D boundary box + viewNode.SetBoxVisible(False) + + viewNode.SetAxisLabelsVisible(False) + viewNode.SetOrientationMarkerType(slicer.vtkMRMLViewNode.OrientationMarkerTypeAxes) + + orientationMenu = viewWidget.findChild(qt.QMenu, "orientationMarkerMenu") + for action in orientationMenu.actions(): + if action.text in ["Cube", "Human"]: + orientationMenu.removeAction(action) diff --git a/src/ltrace/ltrace/slicer/color_map_customizer.py b/src/ltrace/ltrace/slicer/app/custom_colormaps.py similarity index 100% rename from src/ltrace/ltrace/slicer/color_map_customizer.py rename to src/ltrace/ltrace/slicer/app/custom_colormaps.py diff --git a/src/ltrace/ltrace/slicer/app/drawer.py b/src/ltrace/ltrace/slicer/app/drawer.py new file mode 100644 index 0000000..c22a935 --- /dev/null +++ b/src/ltrace/ltrace/slicer/app/drawer.py @@ -0,0 +1,44 @@ +import slicer +import qt + +from ltrace.slicer.helpers import svgToQIcon +from ltrace.slicer_utils import getResourcePath + + +class ExpandDataDrawer: + + APP_NAME = slicer.app.applicationName + + def __init__(self, drawer: qt.QWidget): + + self.closeIcon = svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "PanelRightClose.svg") + self.openIcon = svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "PanelRightOpen.svg") + self.__actionButton = None + self.__drawer = drawer + + self.__drawer.setFeatures(qt.QDockWidget.DockWidgetFloatable | qt.QDockWidget.DockWidgetMovable) + + def widget(self): + return self.__drawer + + def setAction(self, action): + self.__actionButton = action + + def show(self, index=0): + self.__actionButton.setIcon(self.closeIcon) + self.__actionButton.setToolTip("Collapse Data") + self.__drawer.setCurrentWidget(index) + self.__drawer.visible = True + slicer.app.userSettings().setValue(f"{ExpandDataDrawer.APP_NAME}/RighDrawerVisible", True) + + def hide(self): + self.__actionButton.setIcon(self.openIcon) + self.__actionButton.setToolTip("Expand Data") + self.__drawer.visible = False + slicer.app.userSettings().setValue(f"{ExpandDataDrawer.APP_NAME}/RighDrawerVisible", False) + + def __call__(self, *args, **kargs): + if self.__drawer.visible: + self.hide() + else: + self.show() diff --git a/src/ltrace/ltrace/slicer/app/layouts.py b/src/ltrace/ltrace/slicer/app/layouts.py new file mode 100644 index 0000000..876abda --- /dev/null +++ b/src/ltrace/ltrace/slicer/app/layouts.py @@ -0,0 +1,19 @@ +import slicer +import qt + + +def customLayout(layoutID, layoutXML, name, iconPath): + layoutManager = slicer.app.layoutManager() + layoutManager.layoutLogic().GetLayoutNode().AddLayoutDescription(layoutID, layoutXML) + + # Add button to layout selector toolbar for this custom layout + viewToolBar = slicer.modules.AppContextInstance.mainWindow.findChild("QToolBar", "ViewToolBar") + layoutMenu = viewToolBar.widgetForAction(viewToolBar.actions()[0]).menu() + layoutSwitchActionParent = layoutMenu + layoutSwitchAction = layoutSwitchActionParent.addAction(name) # add inside layout list + layoutSwitchAction.setData(layoutID) + layoutSwitchAction.setIcon(qt.QIcon(str(iconPath))) + layoutSwitchAction.connect( + "triggered()", + lambda layoutId=layoutID: slicer.app.layoutManager().setLayout(layoutId), + ) diff --git a/src/ltrace/ltrace/slicer/app/onboard.py b/src/ltrace/ltrace/slicer/app/onboard.py new file mode 100644 index 0000000..c1dfaf7 --- /dev/null +++ b/src/ltrace/ltrace/slicer/app/onboard.py @@ -0,0 +1,278 @@ +import logging +from dataclasses import dataclass +from pathlib import Path + +import qt +import slicer + +from ltrace.slicer.module_utils import loadModules +from ltrace.slicer_utils import getResourcePath +from ltrace.utils.ProgressBarProc import ProgressBarProc +from ltrace.slicer.app import tryDetectProjectDataType + + +class ui_IntroToolButton(qt.QToolButton): + def __init__(self, text: str, moduleName: str, icon: str, parent=None) -> None: + super().__init__(parent) + + self.__updateStyleSheet() + self.__moduleName = moduleName + self.objectName = f"{moduleName} Tool Button" + iconWidget = qt.QIcon(icon.as_posix()) + self.setToolButtonStyle(qt.Qt.ToolButtonTextUnderIcon) + self.setIcon(iconWidget) + self.setText(text) + self.setIconSize(qt.QSize(60, 60)) + self.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding) + # + # self.clicked.connect(self._action) + + def __updateStyleSheet(self) -> None: + self.setStyleSheet( + "QToolButton {\ + background-color: transparent;\ + border: none;\ + padding-top: 8px;\ + padding-bottom: 8px;\ + }\ + QToolButton:hover {\ + background-color: gray;\ + border-radius: 3px;\ + }\ + QToolButton:pressed {\ + background-color: #6B6B6B;\ + }" + ) + + def _action(self): + try: + if self.__moduleName is not None: + slicer.util.selectModule(self.__moduleName) + + except Exception as error: + logging.debug(f"Error in {self.__moduleName} shortcut: {error}.") + + +# TODO move to widgets +def ui_LineSeparator(): + line = qt.QFrame() + line.setFrameShape(qt.QFrame.HLine) + line.setFrameShadow(qt.QFrame.Sunken) + return line + + +def ui_LineSeparatorWithText(text): + linetagged = qt.QFrame() + layout = qt.QHBoxLayout(linetagged) + + lineBefore = ui_LineSeparator() + lineBefore.setSizePolicy(qt.QSizePolicy.MinimumExpanding, qt.QSizePolicy.Fixed) + lineAfter = ui_LineSeparator() + lineAfter.setSizePolicy(qt.QSizePolicy.MinimumExpanding, qt.QSizePolicy.Fixed) + + label = qt.QLabel(text) + label.setStyleSheet("color: gray; margin: 8px;") + label.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Preferred) + + layout.addWidget(lineBefore) + layout.addWidget(label) + layout.addWidget(lineAfter) + + return linetagged + + +class ui_DataLoaderSelectorDialog(qt.QDialog): + signalEnvironmentClicked = qt.Signal(object) + + def __init__(self, modules, parent=None) -> None: + super().__init__(parent) + + self.projectOptionVisible = True + + self.setWindowIcon(qt.QIcon((getResourcePath("Icons") / "GeoSlicer.ico").as_posix())) + + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + + self.setMinimumWidth(400) + self.setWindowTitle("Open Image") + + self.projectOptionFrame = qt.QFrame() + layoutProjectOption = qt.QVBoxLayout(self.projectOptionFrame) + + openProjectButton = qt.QPushButton("Open .mrml Project") + openProjectButton.clicked.connect(self.openProject) + openProjectButton.setSizePolicy(qt.QSizePolicy.MinimumExpanding, qt.QSizePolicy.Fixed) + openProjectButton.setProperty("class", "actionButtonBackground") + + OrLine = ui_LineSeparatorWithText("Or") + + layoutProjectOption.addWidget(openProjectButton) + layoutProjectOption.addWidget(OrLine) + layoutProjectOption.setContentsMargins(0, 0, 0, 0) + + introMsgLabel = qt.QLabel("Choose a data type to start") + lineFrame = ui_LineSeparator() + buttonsFrame = qt.QFrame() + buttonsGridLayout = qt.QGridLayout(buttonsFrame) + + self.helpBoxTextEdit = qt.QTextEdit() + self.helpBoxTextEdit.setReadOnly(True) + self.helpBoxTextEdit.setFixedHeight(64) + + for i, module in enumerate(modules): + button = ui_IntroToolButton(text=module.displayName, moduleName=module.moduleName, icon=module.icon) + button.installEventFilter(self) + button.setToolTip(f"Load {module.displayName} data") + buttonsGridLayout.addWidget(button, i // 3, i % 3) + button.clicked.connect(lambda _, m=module: self.handleSignalEmit(m)) + + layout.addWidget(self.projectOptionFrame) + layout.addWidget(introMsgLabel) + layout.addWidget(lineFrame) + layout.addWidget(buttonsFrame) + layout.addWidget(self.helpBoxTextEdit) + + self.projectOptionFrame.visible = self.projectOptionVisible + + def handleSignalEmit(self, module): + self.signalEnvironmentClicked.emit(module) + + def eventFilter(self, obj, event): + if event.type() == qt.QEvent.Enter: + self.helpBoxTextEdit.setText(obj.toolTip) + elif event.type() == qt.QEvent.Leave: + self.helpBoxTextEdit.clear() + return False + + def openProject(self): + selected = slicer.modules.AppContextInstance.projectEventsLogic.loadScene() + if not selected: + return + + category = tryDetectProjectDataType() + if category: + self.close() + loaderInfo: LoaderInfo = LOADERS[category] + self.handleSignalEmit(loaderInfo) + else: + self.showOnlyDataTypes() + + def showOnlyDataTypes(self): + self.projectOptionFrame.visible = False + + +@dataclass +class LoaderInfo: + displayName: str + moduleName: str + icon: Path + category: str = None + environment: str = None + + +LOADERS = { + li.displayName: li + for li in [ + LoaderInfo( + displayName="Volumes", + moduleName="MicroCTLoader", + icon=getResourcePath("Icons") / "MicroCT3D.png", + category="MicroCT", + environment="MicroCTEnv", + ), + LoaderInfo( + displayName="Thin Section", + moduleName="ThinSectionLoader", + icon=getResourcePath("Icons") / "ThinSection.png", + category="Thin Section", + environment="ThinSectionEnv", + ), + LoaderInfo( + displayName="Well Logs", + moduleName="ImageLogData", + icon=getResourcePath("Icons") / "ImageLog.png", + category="ImageLog", + environment="ImageLogEnv", + ), + LoaderInfo( + displayName="Core", + moduleName="Multicore", + icon=getResourcePath("Icons") / "CoreEnv.png", + category="Core", + environment="CoreEnv", + ), + LoaderInfo( + displayName="Multiscale", + moduleName="CustomizedData", + icon=getResourcePath("Icons") / "MultiscaleIcon.png", + category="Multiscale", + environment="MultiscaleEnv", + ), + LoaderInfo( + displayName="NetCDF", + moduleName="NetCDFLoader", + icon=getResourcePath("Icons") / "NetCDF.png", + category="MicroCT", + environment="MicroCTEnv", + ), + ] +} + + +def loadEnvironment(toolbar, environmentInfo): + groups = slicer.modules.AppContextInstance.modules.groups + with ProgressBarProc() as pb: + related = groups[environmentInfo.category] + loadModules(related, permanent=False, favorite=False) + + try: + toolbar.clear() # clear before add + module = getattr(slicer.modules, f"{environmentInfo.environment}Instance") + module.environment.modulesToolbar = toolbar # APP_TOOLBARS["ModuleToolBar"] + module.environment.setCategory(environmentInfo.category) + module.environment.setupEnvironment() + module.environment.enter() + + # TODO this is ugly, move to class/module + sval = slicer.app.userSettings().value(f"{slicer.app.applicationName}/LeftDrawerVisible", True) + if slicer.util.toBool(sval): + widget = toolbar.widgetForAction(toolbar.actions()[0]) + if widget.toolButtonStyle != qt.Qt.ToolButtonTextBesideIcon: + mainToolBar = slicer.util.mainWindow().findChild(qt.QToolBar, "MainToolBar") + mainToolBar.actions()[0].trigger() + + except AttributeError as e: + import traceback + + traceback.print_exc() + logging.error(f"Error setting up environment {environmentInfo.environment}: {e}") + + slicer.util.selectModule(environmentInfo.moduleName) + + moduleSelectorToolbar = slicer.util.mainWindow().moduleSelector() + envSelectorButton = moduleSelectorToolbar.findChild(qt.QToolButton, "environment Selector Menu") + envSelectorButton.setText(environmentInfo.displayName) + envSelectorButton.setIcon(qt.QIcon(environmentInfo.icon)) + envSelectorButton.setToolButtonStyle(qt.Qt.ToolButtonTextBesideIcon) + + +def loadEnvironmentByName(toolbar, displayName): + module = LOADERS[displayName] + loadEnvironment(toolbar, module) + + +def showDataLoaders(toolbar): + def threadWrapper(*args): + loadEnvironment(*args) + welcomeDialog.signalEnvironmentClicked.disconnect() + welcomeDialog.close() + + welcomeDialog = ui_DataLoaderSelectorDialog( + [LOADERS[category] for category in LOADERS], + parent=slicer.util.mainWindow(), + ) + + welcomeDialog.signalEnvironmentClicked.connect(lambda envInfo: threadWrapper(toolbar, envInfo)) + + welcomeDialog.exec_() diff --git a/src/ltrace/ltrace/slicer/application_observables.py b/src/ltrace/ltrace/slicer/application_observables.py index c85d573..f2757e4 100644 --- a/src/ltrace/ltrace/slicer/application_observables.py +++ b/src/ltrace/ltrace/slicer/application_observables.py @@ -8,3 +8,4 @@ class ApplicationObservables(qt.QObject): applicationLoadFinished = qt.Signal() aboutToQuit = qt.Signal() moduleWidgetEnter = qt.Signal(object) + environmentChanged = qt.Signal() diff --git a/src/ltrace/ltrace/slicer/biaep/thumbnail_downloader.py b/src/ltrace/ltrace/slicer/biaep/thumbnail_downloader.py index 2fb2b27..ede6d0d 100644 --- a/src/ltrace/ltrace/slicer/biaep/thumbnail_downloader.py +++ b/src/ltrace/ltrace/slicer/biaep/thumbnail_downloader.py @@ -151,7 +151,7 @@ def _download_thumbnails( end = idx + self._batch_size batch_df = input_df[start:end].reset_index(drop=True) batch_df_with_cache = self.get_thumbnails_from_cache(df=batch_df) - batch_df_with_cache.dropna(inplace=True, subset=[THUMBNAIL_FILE_PATH_LABEL]) + batch_df_with_cache = batch_df_with_cache.dropna(subset=[THUMBNAIL_FILE_PATH_LABEL]) batch_df_without_cache = ( pd.merge(batch_df, batch_df_with_cache, on=list(batch_df.columns), how="outer", indicator=True) .query("_merge != 'both'") @@ -270,7 +270,6 @@ def get_thumbnails_from_cache(self, df: pd.DataFrame) -> pd.DataFrame: data_df = pd.DataFrame.from_dict(data) # join data to related columns from the input DataFrame output_df = pd.merge(df, data_df, on="name", how="left") - # output_df.dropna(inplace=True, subset=[THUMBNAIL_FILE_PATH_LABEL]) return output_df def _get_thumbnail_file_name_pattern(self, name: str, extension: str = None) -> str: diff --git a/src/ltrace/ltrace/slicer/cli_queue.py b/src/ltrace/ltrace/slicer/cli_queue.py index 5c4ec33..7ffcf96 100644 --- a/src/ltrace/ltrace/slicer/cli_queue.py +++ b/src/ltrace/ltrace/slicer/cli_queue.py @@ -8,21 +8,39 @@ from ltrace.slicer.widget.global_progress_bar import LocalProgressBar -CliNodeInformation = namedtuple("CliNodeInformation", ["node", "module", "parameters", "modified_callback"]) +CliNodeInformation = namedtuple( + "CliNodeInformation", ["node", "module", "parameters", "modified_callback", "progress_text"] +) class CliQueue(qt.QObject): + signal_queue_successful = qt.Signal() + signal_queue_failed = qt.Signal() + signal_queue_cancelled = qt.Signal() signal_queue_finished = qt.Signal() - def __init__(self, cli_node=None, quick_start=False, progress_bar: LocalProgressBar = None): + def __init__( + self, + cli_node=None, + quick_start=False, + update_display=True, + progress_bar: LocalProgressBar = None, + progress_label: qt.QLabel = None, + ): super(qt.QObject, self).__init__() self.__nodes = [] self.__queue = [] self.__running = False - self.__update_display = True + self.__update_display = update_display self.__progress_bar = progress_bar + self.__progress_label = progress_label self.__current_observer_handlers = list() + self.__current_node_idx = 0 + self.__total_nodes = 0 + self.__current_node_info = None + self.__error_message = None + if cli_node is not None: if isinstance(cli_node, list): for node in cli_node: @@ -30,6 +48,9 @@ def __init__(self, cli_node=None, quick_start=False, progress_bar: LocalProgress else: self.add_cli_node(cli_node) + if self.__progress_label is not None: + self.__progress_label.setVisible(False) + if quick_start: self.run() @@ -48,7 +69,7 @@ def run(self): logging.debug("Starting running CLI nodes process. Total in queue: {}".format(len(self.__queue))) self.__run_next_node_in_queue() - def stop(self): + def stop(self, cancelled=False): if not self.__running: return @@ -62,8 +83,20 @@ def stop(self): self.__queue.clear() self.__nodes.clear() self.__running = False + if self.__progress_label is not None: + self.__progress_label.setVisible(False) + + if self.__error_message is None: + if not cancelled: + self.signal_queue_successful.emit() + else: + self.signal_queue_cancelled.emit() + else: + self.signal_queue_failed.emit() self.signal_queue_finished.emit() + self.__current_node_info = None + def add_cli_node(self, cli_node: CliNodeInformation): if not isinstance(cli_node, CliNodeInformation): raise RuntimeError( @@ -71,8 +104,11 @@ def add_cli_node(self, cli_node: CliNodeInformation): type(cli_node) ) ) + + self.__total_nodes += 1 if self.__running: self.__queue.append(cli_node) + self.update_progress_label(self.__queue[0]) logging.debug( "Creating cli node {} and adding it to queue. Total in queue: {}".format( cli_node.node.GetID(), len(self.__queue) @@ -82,53 +118,73 @@ def add_cli_node(self, cli_node: CliNodeInformation): self.__nodes.append(cli_node) logging.debug("Creating cli node {}".format(cli_node.node.GetID())) - def create_cli_node(self, module, parameters=None, modified_callback=None): + def create_cli_node(self, module, parameters=None, modified_callback=None, progress_text="Running"): node = slicer.cli.createNode(module, parameters) self.add_cli_node( - CliNodeInformation(node=node, module=module, parameters=parameters, modified_callback=modified_callback) + CliNodeInformation( + node=node, + module=module, + parameters=parameters, + modified_callback=modified_callback, + progress_text=progress_text, + ) ) def __clear_observers(self): for obj, handler in self.__current_observer_handlers: logging.debug("Removing observer {} from cli node {}".format(handler, obj.GetID())) - qt.QTimer.singleShot(10, lambda x=handler: obj.RemoveObserver(x)) + obj.RemoveObserver(handler) self.__current_observer_handlers.clear() def __on_modified_event(self, caller, event): - if caller is None and not self.__running and caller.IsBusy(): + if caller is None or self.__current_node_info is None: + return + + if self.__current_node_info.modified_callback: + self.__current_node_info.modified_callback(caller, event, self.__current_node_info.parameters) + + if not self.__running or caller.IsBusy(): return - if ( - caller.GetStatus() == slicer.vtkMRMLCommandLineModuleNode.Completed - or caller.GetStatus() == slicer.vtkMRMLCommandLineModuleNode.CompletedWithErrors - ): - for queued_node_info in self.__queue[:]: - if caller is queued_node_info.node: - self.__queue.remove(queued_node_info) - logging.debug( - "cli node {} run finished! Removing it from queue!".format(queued_node_info.node.GetID()) - ) + if caller.GetStatus() == slicer.vtkMRMLCommandLineModuleNode.Completed: + logging.debug( + "cli node {} run finished! Removing it from queue!".format(self.__current_node_info.node.GetID()) + ) + self.__queue.remove(self.__current_node_info) if len(self.__queue) > 0: self.__run_next_node_in_queue() else: self.stop() + elif caller.GetStatus() == slicer.vtkMRMLCommandLineModuleNode.CompletedWithErrors: + logging.error("error running cli node {}!".format(self.__current_node_info.node.GetID())) + self.__error_message = ( + f"{self.__current_node_info.progress_text}:\n{caller.GetErrorText().strip().splitlines()[-1]}" + ) + self.stop() + + elif caller.GetStatus() == slicer.vtkMRMLCommandLineModuleNode.Cancelled: + self.stop(cancelled=True) + def __run_next_node_in_queue(self): if len(self.__queue) <= 0 or not self.__running: self.stop() return - node_info = self.__queue[0] - node = node_info.node - module = node_info.module + self.__clear_observers() + self.__current_node_info = self.__queue[0] + node = self.__current_node_info.node + module = self.__current_node_info.module if node.IsBusy(): logging.debug("Node {} is busy! Skipping it!".format(node.GetID())) - self.__queue.remove(node_info) + self.__queue.remove(self.__current_node_info) + self.__current_node_info = None self.__run_next_node_in_queue() return + self.__current_node_idx += 1 logging.debug("Running next node: {}! Total node in queue: {}".format(node.GetID(), len(self.__queue))) logic = module.logic() logic.SetDeleteTemporaryFiles(True) @@ -137,15 +193,29 @@ def __run_next_node_in_queue(self): modified_handler = node.AddObserver("ModifiedEvent", self.__on_modified_event) self.__current_observer_handlers.append((node, modified_handler)) logging.debug("Creating default observer {} for node {}".format(modified_handler, node.GetID())) - if node_info.modified_callback is not None: - modified_callback_handler = node.AddObserver( - "ModifiedEvent", - lambda caller, event, config=node_info.parameters: node_info.modified_callback(caller, event, config), - ) - logging.debug("Creating custom observer {} for node {}".format(modified_callback_handler, node.GetID())) - self.__current_observer_handlers.append((node, modified_callback_handler)) if self.__progress_bar is not None: self.__progress_bar.setCommandLineModuleNode(node) + if self.__progress_label is not None and self.__total_nodes > 1: + self.update_progress_label(self.__current_node_info) def is_running(self): return self.__running + + def get_error_message(self): + return self.__error_message + + def get_current_node(self): + return self.__current_node_info.node + + def update_progress_label(self, node_info): + if self.__progress_label is None: + return + self.__progress_label.setText(f"Step {self.__current_node_idx}/{self.__total_nodes}: {node_info.progress_text}") + self.__progress_label.setVisible(True) + + # Compatibility with vtkMRMLCommandLineModuleNode: + def IsBusy(self): + return self.is_running() + + def Cancel(self): + return self.stop(cancelled=True) diff --git a/src/ltrace/ltrace/slicer/cli_utils.py b/src/ltrace/ltrace/slicer/cli_utils.py index 2729221..af522e0 100644 --- a/src/ltrace/ltrace/slicer/cli_utils.py +++ b/src/ltrace/ltrace/slicer/cli_utils.py @@ -38,6 +38,8 @@ def _readVectorFrom(volumeFile): vtkArray = numpy_support.numpy_to_vtk(imageArray.ravel(), deep=True) vtkArray.SetNumberOfComponents(3) + if imageArray.ndim == 3: + imageArray = imageArray.reshape(1, *imageArray.shape) vtkImage.SetDimensions(imageArray.shape[-2::-1]) vtkImage.GetPointData().SetScalars(vtkArray) diff --git a/src/ltrace/ltrace/slicer/custom_export_to_file.py b/src/ltrace/ltrace/slicer/custom_export_to_file.py index 6707949..37f4118 100644 --- a/src/ltrace/ltrace/slicer/custom_export_to_file.py +++ b/src/ltrace/ltrace/slicer/custom_export_to_file.py @@ -8,14 +8,7 @@ Env = NodeEnvironment -def _select_tab(tab_widget, label): - for i in range(tab_widget.count): - if tab_widget.tabText(i) == label: - tab_widget.setCurrentIndex(i) - return tab_widget.widget(i) - - -def _detect_node_env(node, current_env): +def _detectNodeEnv(node, current_env): if node is None: return None if node.IsA("vtkMRMLTableNode") or node.IsA("vtkMRMLSegmentationNode"): @@ -33,45 +26,31 @@ def _detect_node_env(node, current_env): return None -def _export_node_as(selected_item_id, env): +def _exportNodeAs(selectedItemId, env): if env is None: slicer.util.warningDisplay( "Can't export selection. Make sure you have selected a single image, or try using the 'Data > Export' tab of your environment." ) return - select_module = slicer.util.mainWindow().moduleSelector().selectModule + selectModule = slicer.modules.AppContextInstance.mainWindow.moduleSelector().selectModule if env == Env.THIN_SECTION: - select_module("ThinSectionEnv") - widget = slicer.modules.ThinSectionEnvWidget - - data_widget = _select_tab(widget.mainTab, "Data") - export_widget = _select_tab(data_widget, "Export").self() - - export_widget.subjectHierarchyTreeView.setCurrentItem(selected_item_id) + selectModule("ThinSectionExport") + widget = slicer.modules.ThinSectionExportWidget + widget.subjectHierarchyTreeView.setCurrentItem(selectedItemId) return if env == Env.IMAGE_LOG: - select_module("ImageLogEnv") - widget = slicer.modules.ImageLogEnvWidget - - data_widget = _select_tab(widget.mainTab, "Data") - export_widget = _select_tab(data_widget, "Export").self() - export_widget.subjectHierarchyTreeView.setCurrentItem(selected_item_id) + selectModule("ImageLogExport") + widget = slicer.modules.ImageLogExportWidget + widget.subjectHierarchyTreeView.setCurrentItem(selectedItemId) return if env == Env.CORE: - select_module("CoreEnv") - widget = slicer.modules.CoreEnvWidget + selectModule("MulticoreExport") + widget = slicer.modules.MulticoreExportWidget - data_widget = _select_tab(widget.mainTab, "Data") - export_widget = _select_tab(data_widget, "Export").self() - - export_widget.subjectHierarchyTreeView.setCurrentItem(selected_item_id) + widget.subjectHierarchyTreeView.setCurrentItem(selectedItemId) return if env == Env.MICRO_CT: - select_module("MicroCTEnv") - widget = slicer.modules.MicroCTEnvWidget - - data_widget = _select_tab(widget.mainTab, "Data") - export_widget = _select_tab(data_widget, "Export").self() + selectModule("MicroCTExport") return @@ -85,29 +64,30 @@ def _export_folder_as_netcdf(folder_id): def _save_folder_as_netcdf(folder_id): widget = SaveNetcdfWidget() - widget.setFolder(folder_id) widget.show() + widget.setFolder(folder_id) -def _export_selected_node(): +def _exportSelectedNode(): sh = slicer.mrmlScene.GetSubjectHierarchyNode() - plugin_handler = slicer.qSlicerSubjectHierarchyPluginHandler().instance() - selected_item_id = plugin_handler.currentItem() + pluginHandler = slicer.qSlicerSubjectHierarchyPluginHandler().instance() + selectedItemId = pluginHandler.currentItem() - if sh.GetItemOwnerPluginName(selected_item_id) == "Folder": - if sh.GetItemAttribute(selected_item_id, "netcdf_path"): - _save_folder_as_netcdf(selected_item_id) + if sh.GetItemOwnerPluginName(selectedItemId) == "Folder": + if sh.GetItemAttribute(selectedItemId, "netcdf_path"): + _save_folder_as_netcdf(selectedItemId) else: - _export_folder_as_netcdf(selected_item_id) + _export_folder_as_netcdf(selectedItemId) return - node = sh.GetItemDataNode(selected_item_id) - detected_env = _detect_node_env(node, getCurrentEnvironment()) - _export_node_as(selected_item_id, detected_env) + + node = sh.GetItemDataNode(selectedItemId) + detectedEnv = _detectNodeEnv(node, getCurrentEnvironment()) + _exportNodeAs(selectedItemId, detectedEnv) -def customize_export_to_file(): - plugin_handler = slicer.qSlicerSubjectHierarchyPluginHandler().instance() - export_plugin = plugin_handler.pluginByName("Export") - export_action = export_plugin.findChild(qt.QAction) - export_action.triggered.disconnect() - export_action.triggered.connect(_export_selected_node) +def customizeExportToFile(): + pluginHandler = slicer.qSlicerSubjectHierarchyPluginHandler().instance() + exportPlugin = pluginHandler.pluginByName("Export") + exportAction = exportPlugin.findChild(qt.QAction) + exportAction.triggered.disconnect() + exportAction.triggered.connect(_exportSelectedNode) diff --git a/src/ltrace/ltrace/slicer/custom_main_window_event_filter.py b/src/ltrace/ltrace/slicer/custom_main_window_event_filter.py index 5b5f166..d72e95f 100644 --- a/src/ltrace/ltrace/slicer/custom_main_window_event_filter.py +++ b/src/ltrace/ltrace/slicer/custom_main_window_event_filter.py @@ -18,10 +18,12 @@ def eventFilter(self, object, event): """Qt eventFilter method overload. Please read reference for more information: https://doc.qt.io/archives/qt-4.8/eventsandfilters.html """ - appObservables = ApplicationObservables() - mainWindow = slicer.util.mainWindow() + if event.type() == qt.QEvent.Close: + appObservables = ApplicationObservables() + mainWindow = slicer.modules.AppContextInstance.mainWindow isModified = mainWindow.isWindowModified() + if isModified is False: event.accept() appObservables.aboutToQuit.emit() @@ -49,9 +51,6 @@ def eventFilter(self, object, event): event.ignore() return True else: # SaveStatus.FAILED options and SaveStatus.IN_PROGRESS - slicer.util.errorDisplay( - "Failed to save the project. Please, check the application's logs and try again." - ) event.ignore() return True elif messageBox.clickedButton() == exitButton: diff --git a/src/ltrace/ltrace/slicer/debounce_caller.py b/src/ltrace/ltrace/slicer/debounce_caller.py new file mode 100644 index 0000000..90ca3b0 --- /dev/null +++ b/src/ltrace/ltrace/slicer/debounce_caller.py @@ -0,0 +1,74 @@ +import logging +import qt + +from typing import Union + + +class DebounceCaller: + """ + Wrapper for qt.Signal emits and method calls with debouncing effect, + emiting the signal/calling the method only one time after a given interval. + """ + + def __init__( + self, + parent: Union[qt.QWidget, qt.QObject], + signal: qt.Signal = None, + callback=None, + intervalMs: int = 500, + qtTimer=qt.QTimer, + ) -> None: + assert signal is not None or callback is not None, "Use either signal or callback." + assert parent is not None, "Invalid parent reference." + + self.__signal = signal + self.__callback = callback + self.__useSignal = signal is not None + self.timer = qtTimer(parent) + self.timer.setSingleShot(True) + self.timer.setInterval(intervalMs) + self.timer.timeout.connect(self.__onTimeout) + self.timer.stop() + self.__args = None + self.__kwargs = None + + def emit(self, *args, **kwargs) -> None: + self.__args = args + self.__kwargs = kwargs + + try: + if self.timer.isActive(): + self.timer.stop() + + self.timer.start() + except ValueError: # timer or parent has been deleted + pass + + def __call__(self, *args, **kwargs): + self.emit(*args, **kwargs) + + def __onTimeout(self) -> None: + try: + if self.__useSignal: + self.__signal.emit(*self.__args, **self.__kwargs) + else: + if self.__args is None and self.__kwargs is None: + self.__callback() + elif self.__kwargs is None: + self.__callback(*self.__args) + elif self.__args is None: + self.__callback(**self.__kwargs) + else: + self.__callback(*self.__args, **self.__kwargs) + except Exception as error: + logging.info(f"Failed to execute the call: {error}") + + self.__args = None + self.__kwargs = None + + def stop(self): + try: + if self.timer.isActive(): + self.timer.stop() + except ValueError: # timer or parent has been deleted + pass diff --git a/src/ltrace/ltrace/slicer/directorylistwidget.py b/src/ltrace/ltrace/slicer/directorylistwidget.py index dfb7a70..8e5f6c9 100644 --- a/src/ltrace/ltrace/slicer/directorylistwidget.py +++ b/src/ltrace/ltrace/slicer/directorylistwidget.py @@ -63,3 +63,5 @@ def addDirectories(self): for path in natsorted(paths): if len(path): self.ui.pathList.addDirectory(path) + + file_dialog.delete() diff --git a/src/ltrace/ltrace/slicer/export.py b/src/ltrace/ltrace/slicer/export.py new file mode 100644 index 0000000..cd1d337 --- /dev/null +++ b/src/ltrace/ltrace/slicer/export.py @@ -0,0 +1,312 @@ +import csv +import os +from pathlib import Path +import re + +import cv2 +import numpy as np +import slicer.util +import vtk + +from ltrace.slicer.helpers import ( + extent2size, + getSourceVolume, + export_las_from_histogram_in_depth_data, + createTemporaryNode, + removeTemporaryNodes, + safe_convert_array, +) +from ltrace.slicer.node_attributes import TableDataOrientation +from ltrace.units import global_unit_registry as ureg, SLICER_LENGTH_UNIT + +SCALAR_VOLUME_FORMAT_RAW = 0 +SCALAR_VOLUME_FORMAT_TIF = 1 + +IMAGE_FORMAT_TIF = 0 +IMAGE_FORMAT_PNG = 1 + +LABEL_MAP_FORMAT_RAW = 0 +LABEL_MAP_FORMAT_TIF = 1 +LABEL_MAP_FORMAT_PNG = 2 + +SEGMENTATION_FORMAT_RAW = 0 +SEGMENTATION_FORMAT_TIF = 1 +SEGMENTATION_FORMAT_PNG = 2 + +TABLE_FORMAT_CSV = 0 +TABLE_FORMAT_LAS = 1 + + +def _rawPath(node, name=None, imageType=None): + """Creates path for node according to standard nomenclature. + See https://ltrace.atlassian.net/browse/PL-532 + """ + inferredName = node.GetName() + if isinstance(node, slicer.vtkMRMLSegmentationNode): + # Use the master volume to find out the extent + master = getSourceVolume(node) + if master: + inferredName = master.GetName() + imageData = master.GetImageData() + spacing = master.GetMinSpacing() + else: + # Segmentation has no master volume, so we merge the segments + imageData = slicer.vtkOrientedImageData() + node.GenerateMergedLabelmapForAllSegments(imageData, slicer.vtkSegmentation.EXTENT_UNION_OF_SEGMENTS) + spacing = min(imageData.GetSpacing()) + inferredImageType = "LABELS" + elif isinstance(node, slicer.vtkMRMLLabelMapVolumeNode): + imageData = node.GetImageData() + inferredImageType = "LABELS" + spacing = node.GetMinSpacing() + elif isinstance(node, slicer.vtkMRMLScalarVolumeNode): + imageData = node.GetImageData() + size = imageData.GetScalarSize() + if size == 1: + inferredImageType = "LABELS" + elif size == 2: + inferredImageType = "CT" + elif size >= 4: + inferredImageType = "FLOAT" + spacing = node.GetMinSpacing() + + name = name or inferredName + imageType = imageType or inferredImageType + parts = [name, imageType] + + dimensions = extent2size(imageData.GetExtent()) + parts += [str(dim).rjust(4, "0") for dim in dimensions] + + mmToNm = 10**6 + spacingNm = int(spacing * mmToNm) + parts.append(str(spacingNm).rjust(5, "0") + "nm.raw") + + return Path("_".join(parts)) + + +def _getItemsSubitemsIds(items): + subjectHierarchyNode = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) + nodesIds = [] + numberOfIds = items.GetNumberOfIds() + if numberOfIds == 0: + return [] + for i in range(numberOfIds): + itemId = items.GetId(i) + if itemId == 3: # when not selecting any item, it supposes entire scene, which we don't want + return [] + nodesIds.append(itemId) + itemChildren = vtk.vtkIdList() + subjectHierarchyNode.GetItemChildren(itemId, itemChildren, True) # recursive + for j in range(itemChildren.GetNumberOfIds()): + childrenItemId = itemChildren.GetId(j) + nodesIds.append(childrenItemId) + return list(set(nodesIds)) # removing duplicate items + + +def _createImageArrayForLabelMapAndSegmentation(labelMapNode): + array = slicer.util.arrayFromVolume(labelMapNode).copy() + + if 1 not in array.shape: # if the label map is not 2D + print("Export 3D images to TIFF or PNG format is not supported yet.") + return None + + arrayShape = np.array(array.shape) + imageDimensions = arrayShape[arrayShape > 1] + array = array.reshape(imageDimensions).astype(np.uint8) + + # Converting to RGB + imageArray = cv2.cvtColor(array, cv2.COLOR_GRAY2RGB) + + colorNode = labelMapNode.GetDisplayNode().GetColorNode() + colorCSV = [] + for i in range(1, colorNode.GetNumberOfColors()): + color = np.zeros(4) + colorNode.GetColor(i, color) + rgbColor = (color * 255).round().astype(int)[:-1] + colorLocations = np.where( + np.logical_and(imageArray[:, :, 0] == i, imageArray[:, :, 1] == i, imageArray[:, :, 2] == i) + ) + imageArray[colorLocations] = rgbColor[::-1] + if len(colorLocations[0]) > 0: + colorCSV.append(colorNode.GetColorName(i) + "," + ",".join(str(e) for e in rgbColor)) + + return imageArray, colorCSV + + +def exportNodeAsImage(nodeName, dataArray, imageFormat, rootPath, nodePath, colorTable=None): + path = rootPath / nodePath + path.mkdir(parents=True, exist_ok=True) + cv2.imwrite(str(path / Path(nodeName + imageFormat)), dataArray) + if colorTable is not None: + with open(str(path / Path(nodeName + ".csv")), mode="w", newline="") as csvFile: + writer = csv.writer(csvFile, delimiter="\n") + writer.writerow(colorTable) + + +def _exportTableAsCsv(node, rootPath, nodePath): + csvRows = [] + + # Column names + csvRow = [] + for i in range(node.GetNumberOfColumns()): + csvRow.append(node.GetColumnName(i)) + csvRows.append(",".join(str(s) for s in csvRow)) + + # Values + for i in range(node.GetNumberOfRows()): + csvRow = [] + for j in range(node.GetNumberOfColumns()): + value = node.GetCellText(i, j) + if j == 0 and "DEPTH" in node.GetColumnName(j): + value = (float(value) * SLICER_LENGTH_UNIT).m_as(ureg.meter) + if isinstance(value, float): + value = np.format_float_positional(value, trim="0", precision=6) + csvRow.append(value) + csvRows.append(",".join(str(s) for s in csvRow)) + + path = rootPath / nodePath + adequatedNodeName = re.sub(r"[\\/*.<>ç?:]", "_", node.GetName()) # avoiding characters not suitable for file name + path.mkdir(parents=True, exist_ok=True) + with open(str(path / Path(adequatedNodeName + ".csv")), mode="w", newline="") as csvFile: + writer = csv.writer(csvFile, delimiter="\n") + writer.writerow(csvRows) + + +def _exportTableAsLas(self, node, rootPath, nodePath): + table_data_orientation_attribute = node.GetAttribute(TableDataOrientation.name()) + if table_data_orientation_attribute is None or table_data_orientation_attribute != str( + TableDataOrientation.ROW.value + ): + raise RuntimeError("The selected table doesn't match the pattern necessary for this export type.") + + path = rootPath / nodePath + path.mkdir(parents=True, exist_ok=True) + file_path = os.path.join(path, node.GetName() + ".las") + + df = slicer.util.dataframeFromTable(node) + status = export_las_from_histogram_in_depth_data(df=df, file_path=file_path) + if not status: + raise RuntimeError("Unable to export the LAS file. Please check the logs for more information.") + + +def getLabelMapLabelsCSV(labelMapNode, withColor=False): + colorNode = labelMapNode.GetDisplayNode().GetColorNode() + labelsCSV = [] + for i in range(1, colorNode.GetNumberOfColors()): + label = f"{colorNode.GetColorName(i)},{i}" + if withColor: + color = [0] * 4 + colorNode.GetColor(i, color) + label += ",#%02x%02x%02x" % tuple(int(ch * 255) for ch in color[:3]) + labelsCSV.append(label) + return labelsCSV + + +def getDataNodes(itemsIds, exportableTypes): + subjectHierarchyNode = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) + itemsIds = _getItemsSubitemsIds(itemsIds) + dataNodes = [] + for itemId in itemsIds: + dataNode = subjectHierarchyNode.GetItemDataNode(itemId) + if dataNode is not None and type(dataNode) in exportableTypes: + dataNodes.append(dataNode) + return dataNodes + + +def exportScalarVolume(node, rootPath, nodePath, format, name=None, imageType=None, imageDtype=None): + name = name or node.GetName() + array = slicer.util.arrayFromVolume(node) + if format == SCALAR_VOLUME_FORMAT_RAW: + path = rootPath / nodePath + path.mkdir(parents=True, exist_ok=True) + if imageDtype: + array = safe_convert_array(array, imageDtype) + array.tofile(str(path / _rawPath(node, name, imageType))) + elif format == SCALAR_VOLUME_FORMAT_TIF: + path = rootPath / nodePath / Path(f"{name}_{imageType}.tif") + + dtype = imageDtype or array.dtype + if dtype not in [np.uint8, np.uint16, np.int8, np.int16]: + # Slicer supports float 32 TIFF, but not integer 32 types, or 64 bit types + dtype = np.float32 + + array = safe_convert_array(array, dtype) + node = createTemporaryNode(slicer.vtkMRMLScalarVolumeNode, "converted") + slicer.util.updateVolumeFromArray(node, array) + + success = slicer.util.saveNode(node, str(path)) + removeTemporaryNodes() + + if not success: + slicer.util.errorDisplay(f"Failed to save node {name} to {path}") + return + + +def exportImage(node, rootPath, nodePath, format): + array = slicer.util.arrayFromVolume(node) + imageArray = cv2.cvtColor(array[0, :, :, :], cv2.COLOR_BGR2RGB) + if format == IMAGE_FORMAT_TIF: + exportNodeAsImage(node.GetName(), imageArray, ".tif", rootPath, nodePath) + elif format == IMAGE_FORMAT_PNG: + exportNodeAsImage(node.GetName(), imageArray, ".png", rootPath, nodePath) + + +def exportLabelMap(node, rootPath, nodePath, format, name=None, imageType=None, imageDtype=np.uint8): + name = name or node.GetName() + if format == LABEL_MAP_FORMAT_RAW: + array = slicer.util.arrayFromVolume(node) + path = rootPath / nodePath + path.mkdir(parents=True, exist_ok=True) + rawPath = path / _rawPath(node, name, imageType) + array.astype(imageDtype).tofile(str(rawPath)) + colorCSV = getLabelMapLabelsCSV(node) + csvPath = rawPath.with_suffix(".csv") + with open(str(csvPath), mode="w", newline="") as csvFile: + writer = csv.writer(csvFile, delimiter="\n") + writer.writerow(colorCSV) + else: + imageArrayAndColorCSV = _createImageArrayForLabelMapAndSegmentation(node) + if imageArrayAndColorCSV is not None: + imageArray, colorCSV = imageArrayAndColorCSV + imageArray = safe_convert_array(imageArray, imageDtype) + if format == LABEL_MAP_FORMAT_TIF: + exportNodeAsImage(name, imageArray, ".tif", rootPath, nodePath, colorTable=colorCSV) + elif format == LABEL_MAP_FORMAT_PNG: + exportNodeAsImage(name, imageArray, ".png", rootPath, nodePath, colorTable=colorCSV) + + +def exportSegmentation(node, rootPath, nodePath, format, name=None, imageType=None): + name = name or node.GetName() + labelMapVolumeNode = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode") + slicer.modules.segmentations.logic().ExportAllSegmentsToLabelmapNode( + node, labelMapVolumeNode, slicer.vtkSegmentation.EXTENT_REFERENCE_GEOMETRY + ) + if format == SEGMENTATION_FORMAT_RAW: + array = slicer.util.arrayFromVolume(labelMapVolumeNode) + path = rootPath / nodePath + path.mkdir(parents=True, exist_ok=True) + rawPath = path / _rawPath(node, name, imageType) + array.astype(np.uint8).tofile(str(rawPath)) + colorCSV = getLabelMapLabelsCSV(labelMapVolumeNode) + csvPath = rawPath.with_suffix(".csv") + with open(str(csvPath), mode="w", newline="") as csvFile: + writer = csv.writer(csvFile, delimiter="\n") + writer.writerow(colorCSV) + else: + imageArrayAndColorCSV = _createImageArrayForLabelMapAndSegmentation(labelMapVolumeNode) + if imageArrayAndColorCSV is not None: + imageArray, colorCSV = imageArrayAndColorCSV + if format == SEGMENTATION_FORMAT_TIF: + exportNodeAsImage(name, imageArray, ".tif", rootPath, nodePath, colorTable=colorCSV) + elif format == SEGMENTATION_FORMAT_PNG: + exportNodeAsImage(name, imageArray, ".png", rootPath, nodePath, colorTable=colorCSV) + slicer.mrmlScene.RemoveNode(labelMapVolumeNode) + + +def exportTable(node, rootPath, nodePath, format): + if format == TABLE_FORMAT_CSV: + _exportTableAsCsv(node, rootPath, nodePath) + elif format == TABLE_FORMAT_LAS: + _exportTableAsLas(node, rootPath, nodePath) + else: + raise RuntimeError(f"{format} export table format not implemented.") diff --git a/src/ltrace/ltrace/slicer/graph_data.py b/src/ltrace/ltrace/slicer/graph_data.py index 18358d5..9c55793 100644 --- a/src/ltrace/ltrace/slicer/graph_data.py +++ b/src/ltrace/ltrace/slicer/graph_data.py @@ -13,7 +13,7 @@ from ltrace.slicer.helpers import tryGetNode from pyqtgraph import QtCore from ltrace.slicer import data_utils as dutils -from ltrace.slicer_utils import DebounceSignal +from ltrace.slicer.debounce_caller import DebounceCaller TEXT_SYMBOLS = { "● Circle": "o", @@ -236,7 +236,7 @@ def __init__( # destroyed signal only works with a lambda self.destroyed.connect(lambda: self._cleanUp()) - self.signalModifiedDebouncer = DebounceSignal(parent=self, signal=self.signalModified, qtTimer=QtCore.QTimer) + self.signalModifiedDebouncer = DebounceCaller(parent=self, signal=self.signalModified, qtTimer=QtCore.QTimer) def __eq__(self, other): if not isinstance(other, GraphData): @@ -318,11 +318,7 @@ def handleLegacyPoreSizeClassVersions(columns, columnData): return columns[idx], None except ValueError: labelColumnName = f"pore_size_class[label]" - newLabelColumnData = columnData.replace( - PORE_SIZE_CATEGORIES, - np.arange(0, len(PORE_SIZE_CATEGORIES), 1), - inplace=False, - ) + newLabelColumnData = columnData.replace(PORE_SIZE_CATEGORIES, np.arange(0, len(PORE_SIZE_CATEGORIES), 1)) return labelColumnName, newLabelColumnData @@ -376,7 +372,7 @@ def __del__(self): @property def node(self): - return tryGetNode(self.__node) + return tryGetNode(self.__nodeId) def __parseData(self, dataNode: slicer.vtkMRMLNode): """Internal parser function. Currently only supporting Table's nodes. diff --git a/src/ltrace/ltrace/slicer/helpers.py b/src/ltrace/ltrace/slicer/helpers.py index ca95101..6fd492f 100644 --- a/src/ltrace/ltrace/slicer/helpers.py +++ b/src/ltrace/ltrace/slicer/helpers.py @@ -1,10 +1,9 @@ ########################################################### # WARNING: DO NOT IMPORT UI MODULES LIKE QT, CTK GLOBALLY # ########################################################### -import sys -import lasio import cv2 import enum +import importlib import lasio import logging import numpy as np @@ -14,7 +13,9 @@ import psutil import re import slicer +import sys import stat +import tempfile import time import vtk @@ -24,7 +25,6 @@ NodeTemporarity, LosslessAttribute, ) -from ltrace.wrappers import timeit from pathlib import Path from skimage.segmentation import relabel_sequential @@ -268,10 +268,15 @@ def bounds2size(extent): def getCurrentEnvironment(): - envName = slicer.util.selectedModule() + try: + envName = slicer.modules.AppContextInstance.modules.currentWorkingDataType[1] + except Exception as error: + return None + for env in NodeEnvironment: if env.value == envName: return env + return None @@ -349,7 +354,8 @@ def createTemporaryVolumeNode( """ valid_name = slicer.mrmlScene.GenerateUniqueName(name) if uniqueName else name tempNode = slicer.mrmlScene.AddNewNodeByClass(cls.__name__, valid_name) - tempNode.CreateDefaultDisplayNodes() + if hasattr(tempNode, "CreateDefaultDisplayNodes"): + tempNode.CreateDefaultDisplayNodes() if hidden: tempNode.SetHideFromEditors(True) @@ -1445,7 +1451,7 @@ def openModuleHelp(module): else: return - mainWindow = slicer.util.mainWindow() + mainWindow = slicer.modules.AppContextInstance.mainWindow mainWindow.moduleSelector().selectModule(module.parent.name) modulePanel = mainWindow.findChild(slicer.qSlicerModulePanel, "ModulePanel") helpCollapsibleButton = modulePanel.findChild(ctk.ctkCollapsibleButton, "HelpCollapsibleButton") @@ -1534,87 +1540,32 @@ def arrayPartsFromNode(node: slicer.vtkMRMLNode) -> tuple[np.ndarray, np.ndarray return depthColumn, values -def redactAnonymize( - node: slicer.vtkMRMLNode, - messDepths: bool, - messData: bool, - newName="DummyName", - newWellName="DummyWell", -): - """Anonymizes data (and optionally edits numerical values) to meet NDA criteria". - - Well name is replaced, node name is replaced, depths can be offset by a random value. And (not working yet) numerical data can be shuffled. - No noise is added to the data so to keep the standard null entries and to preserve the numerical range. - """ +def themeIsDark(): + import qt - raise NotImplementedError("redactAnonymize not fully implemented.") + palette = slicer.app.palette() + bg_color = palette.color(qt.QPalette.Background) + fg_color = palette.color(qt.QPalette.WindowText) + return fg_color.value() > bg_color.value() - # messData not working yet - if messData: - messData = False - node.SetAttribute("WellName", newWellName) +def svgToQIcon(iconPath): + import qt - if newWellName: - node.SetName(f"{newWellName}_{newName}") - else: - node.SetName(f"{newName}") - - if isinstance(node, slicer.vtkMRMLTableNode): - df = None # dutils.tableNodeToDataFrame(node) - df_inner = df.values - depths = df_inner[0:, 0] - dfTo = None # dutils.tableNodeToDataFrame(node) - if messDepths: - offset = 100000.0 + (np.random.random_sample() * 200000.0) - depths += offset - dfTo.iloc[0:, 0] = pd.Series(depths) - dutils.dataFrameToTableNode(dfTo, node) - if messData: # not working yet - values = df_inner[0:, 1:] - values = np.squeeze(values) - np.random.shuffle(values) - values = values.transpose() - np.random.shuffle(values) - values = values.transpose() - dfTo.iloc[0:, 1] = pd.Series(values) # doesn't work for multidimensional arrays - dutils.dataFrameToTableNode(dfTo, node) - else: - values = [] - spacing = [] - origin = [] - if isinstance(node, slicer.vtkMRMLSegmentationNode): - values, spacing, origin = arrayFromVisibleSegmentsBinaryLabelmap(node) - else: - values = slicer.util.arrayFromVolume(node) - origin = node.GetOrigin() - - if messDepths: - new_origin = [ - origin[0], - origin[1], - origin[2] - 100000.0 - (np.random.random_sample() * 200000.0), - ] - else: - new_origin = origin - node.SetOrigin(new_origin) + with open(iconPath, "r", encoding="utf-8") as src: + svg_content = src.read() - if messData: - values = np.squeeze(values) - np.random.shuffle(values) - values = values.transpose() - np.random.shuffle(values) - values = values.transpose() - slicer.util.updateVolumeFromArray(node, values) + hex = "#e1e1e1" if themeIsDark() else "#333333" + updated_svg_content = re.sub(r'stroke="[^"]+"', f'stroke="{hex}"', svg_content) + with tempfile.NamedTemporaryFile(suffix=".svg", delete=False, mode="w", encoding="utf-8") as src: + src.write(updated_svg_content) + tmpIconPath = Path(src.name) -def themeIsDark(): - import qt + icon = qt.QIcon(tmpIconPath.as_posix()) + tmpIconPath.unlink() - palette = slicer.app.palette() - bg_color = palette.color(qt.QPalette.Background) - fg_color = palette.color(qt.QPalette.WindowText) - return fg_color.value() > bg_color.value() + return icon def numberArrayToLabelArray(array: np.ndarray) -> np.ndarray: @@ -2022,36 +1973,32 @@ class GitImportError(Exception): pass -def import_git(): - try: - import git +def install_git_module(remote, collection=False): + from ltrace.slicer.app import getApplicationInfo - return git - except Exception as e: - raise GitImportError() from e + modules_parent_dir = get_scripted_modules_path().parent + third_party_dir = modules_parent_dir / "qt-scripted-external-modules" + repo = clone_or_update_repo(remote, third_party_dir, branch="master", collection=collection) -def install_git_module(remote): - from ltrace.slicer_utils import base_version + return repo - geoslicer_version = base_version() - - modules_folders = ( - *(os.path.dirname(slicer.app.launcherExecutableFilePath).split("/")), - *(("lib\\" + geoslicer_version + "\\qt-scripted-modules").split("\\")), - ) - modules_path = os.path.join(modules_folders[0], os.sep, *modules_folders[1:]) - - json_folders = ( - *(os.path.dirname(slicer.app.launcherExecutableFilePath).split("/")), - *(("lib\\" + geoslicer_version + "\\qt-scripted-modules\\Resources\\json\\WelcomeGeoSlicer.json").split("\\")), - ) - json_path = os.path.join(json_folders[0], os.sep, *json_folders[1:]) - - new_module_name = remote.split("/")[-1].split(".")[0] - new_module_path = os.path.join(modules_path, new_module_name) - _ = import_git().Repo.clone_from(remote, new_module_path, env={"GIT_SSL_NO_VERIFY": "1"}) - config_module_paths(new_module_name, new_module_path, json_path) + # modules_folders = ( + # *(os.path.dirname(slicer.app.launcherExecutableFilePath).split("/")), + # *(("lib\\" + geoslicer_version + "\\qt-scripted-modules").split("\\")), + # ) + # modules_path = os.path.join(modules_folders[0], os.sep, *modules_folders[1:]) + # + # json_folders = ( + # *(os.path.dirname(slicer.app.launcherExecutableFilePath).split("/")), + # *(("lib\\" + geoslicer_version + "\\qt-scripted-modules\\Resources\\json\\WelcomeGeoSlicer.json").split("\\")), + # ) + # json_path = os.path.join(json_folders[0], os.sep, *json_folders[1:]) + # + # new_module_name = remote.split("/")[-1].split(".")[0] + # new_module_path = os.path.join(modules_path, new_module_name) + # _ = import_git().Repo.clone_from(remote, new_module_path, env={"GIT_SSL_NO_VERIFY": "1"}) + # config_module_paths(new_module_name, new_module_path, json_path) def config_module_paths(new_module_name, new_module_path, json_path): @@ -2224,7 +2171,6 @@ def safe_convert_array(array, dtype): array = array.astype(dtype) return array - return typeStr def isImageFile(filePath: Union[Path, str]) -> bool: @@ -2254,7 +2200,7 @@ class WatchSignal: Example: >>> with WatchSignal(signal=slicer.mrmlScene.EndImportEvent, timeout_ms=10000): >>> doSomething() - >>> slicer.mrmlScene.Clear(0) # Close project + >>> ProjectManager().close() # Close project """ def __init__(self, signal: slicer.vtkMRMLScene.SceneEventType, timeout_ms: int = 2000) -> None: @@ -2366,3 +2312,62 @@ def hex2Rgb(hex: str, normalize=True) -> Tuple: lv = len(hex) rgb = tuple(int(hex[i : i + lv // 3], 16) / normalizeValue for i in range(0, lv, lv // 3)) return rgb + + +class LazyLoad: + def __init__(self, moduleName): + self.moduleName = moduleName + self.module = None + + def __getattr__(self, name): + if not self.module: + moduleInfo = slicer.modules.AppContextInstance.modules.availableModules[self.moduleName] + sys.path.append(moduleInfo.searchPath) + self.module = importlib.import_module(self.moduleName) + return getattr(self.module, name) + + +def checkUniqueNames(nodes): + nodeNames = set() + for node in nodes: + if node.GetName() in nodeNames: + node.SetName(slicer.mrmlScene.GenerateUniqueName(node.GetName())) + nodeNames.add(node.GetName()) + + +def arrayPartsFromNode(node: slicer.vtkMRMLNode) -> tuple[np.ndarray, np.ndarray]: + mmToM = 0.001 + if isinstance(node, slicer.vtkMRMLScalarVolumeNode): + values = slicer.util.arrayFromVolume(node).copy().squeeze() + if values.ndim != 2: + raise ValueError(f"Node has dimension {values.ndim}, expected 2.") + + bounds = [0] * 6 + node.GetBounds(bounds) + ymax = -bounds[4] * mmToM + ymin = -bounds[5] * mmToM + spacing = node.GetSpacing()[2] * mmToM + depthColumn = np.arange(ymin, ymax - spacing / 2, spacing) + + ijkToRas = np.zeros([3, 3]) + node.GetIJKToRASDirections(ijkToRas) + if ijkToRas[0][0] > 0: + values = np.flip(values, axis=0) + if ijkToRas[1][1] > 0: + values = np.flip(values, axis=1) + if ijkToRas[2][2] > 0: + values = np.flip(values, axis=2) + elif isinstance(node, slicer.vtkMRMLTableNode): + if node.GetAttribute("table_type") == "histogram_in_depth": + df = slicer.util.dataframeFromTable(node) + df_columns = df.columns + depthColumn = df[df_columns[0]].to_numpy() * mmToM + values = df[df_columns[1:]].to_numpy() + else: + values = slicer.util.arrayFromTableColumn(node, node.GetColumnName(1)) + depthColumn = slicer.util.arrayFromTableColumn(node, node.GetColumnName(0)) * mmToM + if depthColumn[0] > depthColumn[-1]: + depthColumn = np.flipud(depthColumn) + values = np.flipud(values) + + return depthColumn, values diff --git a/tools/deploy/GeoSlicerManual/docs/Thin Section/thinsection.md b/src/ltrace/ltrace/slicer/image_log/__init__.py similarity index 100% rename from tools/deploy/GeoSlicerManual/docs/Thin Section/thinsection.md rename to src/ltrace/ltrace/slicer/image_log/__init__.py diff --git a/src/modules/DLISImport/DLISImportLib/DLISImportLogic.py b/src/ltrace/ltrace/slicer/image_log/import_logic.py similarity index 95% rename from src/modules/DLISImport/DLISImportLib/DLISImportLogic.py rename to src/ltrace/ltrace/slicer/image_log/import_logic.py index 8fae532..b5fdd30 100644 --- a/src/modules/DLISImport/DLISImportLib/DLISImportLogic.py +++ b/src/ltrace/ltrace/slicer/image_log/import_logic.py @@ -1,17 +1,18 @@ import logging import re -from collections import namedtuple -from pathlib import Path import vtk - import lasio import numpy as np import pandas as pd import slicer + + +from collections import namedtuple +from pathlib import Path from dlisio import dlis as dlisio from pandas.errors import ParserError - -from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUE, handle_null_values +from ltrace.constants import DLISImportConst +from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUES, handle_null_values, ANP_880_2022_DEFAULT_NULL_VALUE from ltrace.lmath.filtering import DistributionFilter from ltrace.ocr import parse_pdf from ltrace.slicer import helpers @@ -26,14 +27,15 @@ from ltrace.units import global_unit_registry as ureg from ltrace.file_utils import read_csv -SCALAR_VOLUME_TYPE = "ScalarVolumeType" -WELL_PROFILE_TAG = "WellProfile" -NULL_VALUE_TAG = "NullValue" -LOGICAL_FILE_TAG = "LogicalFile" -FRAME_TAG = "Frame" -WELL_NAME_TAG = "WellName" -UNITS_TAG = "Units" -DEPTH_LABEL = "DEPTH" +SCALAR_VOLUME_TYPE = DLISImportConst.SCALAR_VOLUME_TYPE +WELL_PROFILE_TAG = DLISImportConst.WELL_PROFILE_TAG +NULL_VALUE_TAG = DLISImportConst.NULL_VALUE_TAG +LOGICAL_FILE_TAG = DLISImportConst.LOGICAL_FILE_TAG +FRAME_TAG = DLISImportConst.FRAME_TAG +WELL_NAME_TAG = DLISImportConst.WELL_NAME_TAG +UNITS_TAG = DLISImportConst.UNITS_TAG +DEPTH_LABEL = DLISImportConst.DEPTH_LABEL + CURVES_NAME = ["T2_DIST", "T2DIST", "T1DIST"] ChannelMetadata = namedtuple( "ChannelMetadata", @@ -53,10 +55,10 @@ class DLISLoader(object): - def __init__(self, filepath): + def __init__(self, filepath, nulls=set): self.filepath = filepath self.logical_files = dlisio.load(str(self.filepath)) - self.null_value = DEFAULT_NULL_VALUE + self.null_value = nulls def load_volumes(self, curves, stepCallback, appFolder, nullValue, well_diameter_mm, well_name): return load_volumes(curves, stepCallback, appFolder, nullValue, well_diameter_mm, well_name) @@ -156,10 +158,21 @@ def load_data(self, file_path, mnemonic_and_files): class LASLoader(object): - def __init__(self, filepath): + def __init__(self, filepath, nulls=set): self.filepath = filepath - self.logical_files = lasio.read(str(self.filepath)) - self.null_value = set([self.logical_files.well.NULL.value]) + + # Some invalid data will be susbtituted by nan (also other malformed data) by lasio + invalid_values_handling_options = ["(null)", "NA", "INF", "IO", "IND"] + + self.logical_files = lasio.read(str(self.filepath), null_policy=invalid_values_handling_options) + + # Finally, the null values substitutions will be left to GeoSlicer's handle_null_values function + self.null_value = nulls + if self.logical_files.well.NULL.value: + self.null_value.add( + self.logical_files.well.NULL.value + ) # Ensuring that self.logical_files.well.NULL.value is in the substitution list + self.stacked_curves_pattern = re.compile(r"(.+)[\[|(|{]([0-9]+)[\]|)|}]") def load_volumes(self, curves, stepCallback, appFolder, nullValue, well_diameter_mm=None, well_name=None): @@ -314,13 +327,13 @@ def load_data(self, file_path, mnemonic_and_files): class CSVLoader(object): - def __init__(self, filepath): + def __init__(self, filepath, nulls=set): self.filepath = filepath self.curve_depth = None self.curve_name = None self.filename = None self.db = {} - self.null_value = DEFAULT_NULL_VALUE + self.null_value = nulls self.loaded_as_image = False @staticmethod @@ -386,7 +399,7 @@ def load_metadata(self): if secondRowHasUnits: units = df.iloc()[0] df = df.drop(0) - df.reset_index(drop=True, inplace=True) + df = df.reset_index(drop=True) depthUnit = units[0] else: pattern = re.compile(r"\((.+)\)") @@ -758,9 +771,6 @@ def add_volume_from_data( if is_labelmap: name = f"{name}_LabelMap" - if verify_repeated_file(name, root_id, folder, frame): - return None, None # IF a TDEP exists into this frame, ignore a new one - if is_labelmap: volume_node = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode") data = helpers.numberArrayToLabelArray(data) @@ -774,7 +784,7 @@ def add_volume_from_data( name = name.strip() # remove trailing spaces volume_node.SetName(slicer.mrmlScene.GenerateUniqueName(name)) volume_node.SetAttribute(SCALAR_VOLUME_TYPE, WELL_PROFILE_TAG) - volume_node.SetAttribute(NULL_VALUE_TAG, str(nullValue)) + volume_node.SetAttribute(NULL_VALUE_TAG, str(nullValue)) # default set at handle_null_values volume_node.SetAttribute(WELL_NAME_TAG, well_name) volume_units = units if units != "" else None @@ -806,14 +816,14 @@ def add_volume_from_data( return volume_node, volume_item_id -def get_loader(file_path): +def get_loader(file_path, null_values=""): ext = Path(file_path).suffix.lower() if ext == ".las": - return LASLoader(file_path) + return LASLoader(file_path, null_values) if ext == ".dlis": - return DLISLoader(file_path) + return DLISLoader(file_path, null_values) if ext == ".csv" or ext == ".pdf": - return CSVLoader(file_path) + return CSVLoader(file_path, null_values) raise NotImplementedError(f'Handler for "{ext}" not implemented.') @@ -882,7 +892,7 @@ def create_depth_curves_table( df.columns = list(curves.keys()) if null_value is not None: - df.replace(null_value, np.nan, inplace=True) + df = df.replace(null_value, np.nan) # Assert data frame has DEPTH as first column depth_column_index = df.columns.get_loc(DEPTH_LABEL) @@ -959,7 +969,7 @@ def create_depth_curves_table_from_image( df = pd.DataFrame(curves[name].astype(float)) if null_value is not None: - df.replace(null_value, np.nan, inplace=True) + df = df.replace(null_value, np.nan) # Assert data frame has DEPTH as first column if curves[DEPTH_LABEL].size > 0: diff --git a/src/modules/DLISImport/DLISImportLib/DLISImportWidget.py b/src/ltrace/ltrace/slicer/image_log/import_widget.py similarity index 82% rename from src/modules/DLISImport/DLISImportLib/DLISImportWidget.py rename to src/ltrace/ltrace/slicer/image_log/import_widget.py index 893e3ac..8795a63 100644 --- a/src/modules/DLISImport/DLISImportLib/DLISImportWidget.py +++ b/src/ltrace/ltrace/slicer/image_log/import_widget.py @@ -1,9 +1,17 @@ import time import qt, ctk, slicer -from .DLISTableViewer import DLISTableViewer -from .DLISImportLogic import DLISLoader, LASLoader, CSVLoader, blank_fn, get_loader, LoaderError, ImageLogImportError -from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUE +from .table_viewer_widget import ImageLogTableViewer +from .import_logic import ( + DLISLoader, + LASLoader, + CSVLoader, + blank_fn, + get_loader, + LoaderError, + ImageLogImportError, +) +from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUES from ltrace.slicer import ui, widgets from ltrace.utils.ProgressBarProc import ProgressBarProc from ltrace.slicer import helpers @@ -18,7 +26,7 @@ def __init__(self, parent=None): self.loadClicked = blank_fn - self.tableView = DLISTableViewer() + self.tableView = ImageLogTableViewer() self.tableView.setMinimumHeight(500) self.tableView.loadClicked = self.onLoadClicked @@ -56,9 +64,19 @@ def buildFileInputWidget(self): self.ioFileInputLineEdit.setCurrentPath("") self.ioFileInputLineEdit.findChild("QLineEdit").enabled = False + nullsLayout = qt.QHBoxLayout() self.nullValuesListText = qt.QLineEdit() - self.nullValuesListText.text = str(DEFAULT_NULL_VALUE)[1:-1] + self.nullValuesListText.setObjectName("Null Values List Text") + self.nullValuesListText.text = str(DEFAULT_NULL_VALUES)[1:-1] self.nullValuesListText.textChanged.connect(lambda: self.setNullValuesFieldState(widgets.InputState.OK)) + nullsLayout.addWidget(self.nullValuesListText) + + self.resetNullValuesListButton = qt.QPushButton("Reset list") + self.resetNullValuesListButton.setObjectName("Reset Null Values List Button") + self.resetNullValuesListButton.clicked.connect( + lambda: self.nullValuesListText.setText(str(DEFAULT_NULL_VALUES)[1:-1]) + ) + nullsLayout.addWidget(self.resetNullValuesListButton) self.wellDiameter = ui.floatParam("") self.wellDiameter.setObjectName("Well Diameter Input") @@ -67,18 +85,29 @@ def buildFileInputWidget(self): wellDiameterLabel = qt.QLabel("Well diameter (inches):") formLayout.addRow("Well log file:", self.ioFileInputLineEdit) - formLayout.addRow("Null values list:", self.nullValuesListText) + formLayout.addRow("Null values list:", nullsLayout) formLayout.addRow(wellDiameterLabel, self.wellDiameter) def onPathChanged(filepath): with ProgressBarProc() as progressBar: - progressBar.nextStep(5, "Starting to load metadata...") - self.dataLoader = get_loader(filepath) + progressBar.nextStep(5, "Loading metadata...") + progressBar.nextStep(80, "Setting context variables...") nullvalues = set(self.nullValuesListText.text.split(",")) - nullvalues = set(map(float, nullvalues)) + + if self.nullValuesListText.text.split(",")[0]: + nullvalues = set(map(float, nullvalues)) + else: # an empty nullValuesListText will give us a 1-element set ([""]). Reinitializing the set here + nullvalues = set() + + self.dataLoader = get_loader(filepath, nullvalues) + nullvalues.union(self.dataLoader.null_value) - self.nullValuesListText.text = str(nullvalues)[1:-1] + + if len(nullvalues): + self.nullValuesListText.text = str(nullvalues)[1:-1] + else: + self.nullValuesListText.text = "" try: progressBar.nextStep(90, "Showing metadata...") diff --git a/src/modules/DLISImport/DLISImportLib/DLISTableViewer.py b/src/ltrace/ltrace/slicer/image_log/table_viewer_widget.py similarity index 99% rename from src/modules/DLISImport/DLISImportLib/DLISTableViewer.py rename to src/ltrace/ltrace/slicer/image_log/table_viewer_widget.py index 2b93b01..d5851c2 100644 --- a/src/modules/DLISImport/DLISImportLib/DLISTableViewer.py +++ b/src/ltrace/ltrace/slicer/image_log/table_viewer_widget.py @@ -1,7 +1,7 @@ import qt -from .DLISImportLogic import ChannelMetadata -from .DLISImportLogic import checkMnemonicChanged +from .import_logic import ChannelMetadata +from .import_logic import checkMnemonicChanged from ltrace.slicer import ui @@ -17,7 +17,7 @@ def buildCheckBoxCell(checked): return checkBoxWidget -class DLISTableViewer(qt.QWidget): +class ImageLogTableViewer(qt.QWidget): def __init__(self, parent=None): super().__init__(parent) diff --git a/src/ltrace/ltrace/slicer/module_info.py b/src/ltrace/ltrace/slicer/module_info.py new file mode 100644 index 0000000..e30d3f6 --- /dev/null +++ b/src/ltrace/ltrace/slicer/module_info.py @@ -0,0 +1,136 @@ +import ast +import os + + +class StopTraversal(Exception): + pass + + +class CategoryVisitor(ast.NodeVisitor): + def __init__(self, expectedClassName): + self.categories = None + self.hidden = False + self.name = expectedClassName + + def visit_ClassDef(self, node): + if node.name == self.name: + if "LTracePlugin" in [base.id for base in node.bases]: + self.generic_visit(node) # Visit the children of the class + + def visit_Assign(self, node): + # Check if the assignment is to `self.parent.categories` + if isinstance(node.targets[0], ast.Attribute): + attr = node.targets[0] + if ( + isinstance(attr.value, ast.Attribute) + and isinstance(attr.value.value, ast.Name) + and attr.value.value.id == "self" + and attr.value.attr == "parent" + ): + if attr.attr == "categories" and isinstance(node.value, ast.List): + # Extract the value (should be a list in this case) + self.categories = [elt.s for elt in node.value.elts] + elif attr.attr == "hidden" and isinstance(node.value, ast.Constant): + # Extract the value (should be a boolean in this case) + self.hidden = node.value.value + + # Halt the traversal only when both categories and hidden are found + if self.categories is not None and self.hidden is not False: + raise StopTraversal + + self.generic_visit(node) + + +# ============================================================================= +# +# _ui_CreateComponentDialog +# +# ============================================================================= +# ============================================================================= +# +# ModuleInfo +# +# ============================================================================= +class ModuleInfo: + # --------------------------------------------------------------------------- + def __init__(self, path, key=None, categories=None, hidden=False): + self.path = path + self.searchPath = os.path.dirname(path) + self.categories = categories or [] + self.hidden = hidden + + if key is None: + self.key = os.path.splitext(os.path.basename(path))[0] + else: + self.key = key + + # --------------------------------------------------------------------------- + def __repr__(self): + return "ModuleInfo(key=%(key)r, path=%(path)r, hidden=%(hidden)r" % self.__dict__ + + # --------------------------------------------------------------------------- + def __str__(self): + return self.path + + # --------------------------------------------------------------------------- + @staticmethod + def findModules(path, depth): + result = [] + if os.path.isfile(path): + entries = [path] + elif os.path.isdir(path): + entries = [os.path.join(path, entry) for entry in os.listdir(path)] + # If the folder contains __init__.py, it means that this folder + # is not a Slicer module but an embedded Python library that a module will load. + if any(entry.endswith("__init__.py") for entry in entries): + entries = [] + else: + # not a file or folder + return result + + if depth > 0: + for entry in filter(os.path.isdir, entries): + result += ModuleInfo.findModules(entry, depth - 1) + + for entry in filter(os.path.isfile, entries): + if not entry.endswith(".py"): + continue + + if os.path.basename(entry) == "__init__.py": + continue + + # Criteria for a Slicer module to have a module class + # that has the same name as the filename and its base class is LTracePlugin. + + if entry.endswith("CLI.py") and os.path.dirname(entry).endswith("CLI"): + # Dirty but works / TODO move it to a function specialized for CLI + result.append(ModuleInfo(entry, categories=["CLI"], hidden=False)) + continue + + try: + # Find all class definitions + with open(entry) as entry_file: + tree = ast.parse(entry_file.read()) + + filename = os.path.basename(entry) + expectedClassName = os.path.splitext(filename)[0] + visitor = CategoryVisitor(expectedClassName) + try: + visitor.visit(tree) + except StopTraversal: + pass + + if visitor.categories: + minfo = ModuleInfo(entry, categories=visitor.categories, hidden=visitor.hidden) + result.append(minfo) + + except: + # Error while processing the file (e.g., syntax error), + # it cannot be a Slicer module. + pass + + # We have the option to identify scripted CLI modules, such as by examining the existence of a + # compatible module descriptor XML file. However, this type of module is relatively uncommon, so + # the decision was made not to invest in implementing this feature. + + return result diff --git a/src/ltrace/ltrace/slicer/module_utils.py b/src/ltrace/ltrace/slicer/module_utils.py new file mode 100644 index 0000000..716a6e9 --- /dev/null +++ b/src/ltrace/ltrace/slicer/module_utils.py @@ -0,0 +1,179 @@ +import logging + +from pathlib import Path + +import slicer, qt + +from ltrace.slicer.module_info import ModuleInfo + + +def fetchAsList(settings, key) -> list: + # Return a settings value as a list (even if empty or a single value) + + value = settings.value(key) + + if isinstance(value, str): + return [value] + + return [] if value is None else value + + +def loadModule(module: ModuleInfo): + factory = slicer.app.moduleManager().factoryManager() + + if factory.isLoaded(module.key): + return + + factory.registerModule(qt.QFileInfo(str(module.path))) + if not factory.isRegistered(module.key): + logging.warning(f"Failed to register module {module.key}") + return False + + if not factory.loadModules([module.key]): + logging.error(f"Failed to load module {module.key}") + + return True + + +def loadModules(modules, permanent=False, favorite=False): + """ + Loads a module in the Slicer factory while Slicer is running + """ + # Determine which modules in above are not already loaded + factory = slicer.app.moduleManager().factoryManager() + + # Add module(s) to permanent search paths, if requested + settings = slicer.app.revisionUserSettings() + searchPaths = [Path(fp) for fp in fetchAsList(settings, "Modules/AdditionalPaths")] + npaths = len(searchPaths) + + modulesToLoad = [] + + for myModule in modules: + if factory.isLoaded(myModule.key): + logging.info(f"Module {myModule.key} already loaded") + continue + + if permanent: + rawPath = Path(myModule.searchPath) + + if rawPath not in searchPaths: + searchPaths.append(rawPath) + + # Register requested module(s) + factory.registerModule(qt.QFileInfo(str(myModule.path))) + + if not factory.isRegistered(myModule.key): + logging.warning(f"Failed to register module {myModule.key}") + continue + + modulesToLoad.append(myModule.key) + + if not factory.loadModules(modulesToLoad): + logging.error(f"Failed to load some module(s)") + return + + if len(searchPaths) > npaths: + settings.setValue("Modules/AdditionalPaths", [str(p) for p in searchPaths]) + + for myModule in modules: + myModule.loaded = factory.isLoaded(myModule.key) + logging.info(f"Module {myModule.key} loaded") + + # Instantiate and load requested module(s) + # if len(modules) != len(modulesToLoad): + # slicer.util.errorDisplay("The module factory manager reported an error. \ + # One or more of the requested module(s) and/or \ + # dependencies thereof may not have been loaded.") + + if favorite and len(modulesToLoad) > 0: + favorites = [*slicer.app.userSettings().value("Modules/FavoriteModules"), *modulesToLoad] + slicer.app.userSettings().setValue("Modules/FavoriteModules", favorites) + + +def fetchModulesFrom(path, depth=1): + if path is None: + return {} + + try: + if path.suffix == ".git": + # Clone or update the repository + from ltrace.slicer_utils import base_version + + geoslicer_version = base_version() + dest = Path(slicer.app.slicerHome) / "lib" / f"GeoSlicer-{geoslicer_version}" / "qt-scripted-extern-modules" + dest.mkdir(parents=True, exist_ok=True) + path = clone_or_update_repo(path, dest, branch="master") + + # Get list of modules in specified path + modules = ModuleInfo.findModules(path, depth) + + candidates = {m.key: m for m in modules} + return candidates + except RuntimeError as re: + logging.warning(repr(re)) + except Exception as e: + logging.warning(f"Failed to load modules: {e}") + + return {} + + +def mapByCategory(modules): + groupedModulesByCategories = {} + for module in modules: + if module.key == "CustomizedGradientAnisotropicDiffusion": + pass + for category in module.categories: + if category not in groupedModulesByCategories: + groupedModulesByCategories[category] = [] + groupedModulesByCategories[category].append(module) + + return groupedModulesByCategories + + +def clone_or_update_repo(remote_url: Path, destination_dir: Path, branch: str = "master") -> None: + """ + Clone the repository from `remote_url` into `destination_dir`. + If the repository already exists, update it by pulling the latest changes. + + Args: + remote_url (str): URL of the remote repository. + destination_dir (str | Path): Path where the repository should be cloned or updated. + + Raises: + ValueError: If the existing repository in `destination_dir` has a different remote URL. + + Returns: + str: A message indicating the action taken. + """ + import os + + os.environ["GIT_PYTHON_REFRESH"] = "quiet" + import git + + try: + remote_repo = remote_url.split("/")[-1].split(".")[0] + + destination_dir = destination_dir / remote_repo + + if destination_dir.exists(): + # If the directory exists, open the repo and check the remote URL + repo = git.Repo(destination_dir) + if repo.remotes.origin.url != remote_url: + raise ValueError(f"Directory exists but points to a different repository: {repo.remotes.origin.url}") + + # Pull the latest changes if the remote URL matches + try: + repo.remotes.origin.pull(branch).raise_if_error() + except git.GitCommandError as e: + repo.remotes.origin.pull("main").raise_if_error() + + logging.info(f"Updated '{branch}' branch in repository at '{destination_dir}'.") + else: + # Clone the repository if the directory does not exist + git.Repo.clone_from(remote_url, destination_dir, env={"GIT_SSL_NO_VERIFY": "1"}) + logging.info(f"Cloned repository '{remote_repo}' into '{destination_dir}'.") + except git.GitCommandError as e: + raise RuntimeError(f"Failed to fetch {remote_url}") + + return destination_dir diff --git a/src/ltrace/ltrace/slicer/modules_help_menu.py b/src/ltrace/ltrace/slicer/modules_help_menu.py index eefaec3..b23dd4b 100644 --- a/src/ltrace/ltrace/slicer/modules_help_menu.py +++ b/src/ltrace/ltrace/slicer/modules_help_menu.py @@ -95,8 +95,15 @@ def __getEnvironments(self): clsObject=None, modules=[], ) + multiScale = EnvModule( + cls=slicer.modules.multiscaleenv, + name=slicer.modules.multiscaleenv.title, + tag="Multiscale", + clsObject=None, + modules=[], + ) - return [core, imageLog, microCt, thinSection] + return [core, imageLog, microCt, thinSection, multiScale] except AttributeError: return [] diff --git a/src/ltrace/ltrace/slicer/netcdf.py b/src/ltrace/ltrace/slicer/netcdf.py index 8a9a916..b96c3fc 100644 --- a/src/ltrace/ltrace/slicer/netcdf.py +++ b/src/ltrace/ltrace/slicer/netcdf.py @@ -3,9 +3,13 @@ import logging import xarray as xr import logging +import numpy as np -from PIL import ImageColor +from dataclasses import dataclass from pathlib import Path +from PIL import ImageColor +from ltrace.readers import microtom +from ltrace.slicer import export from ltrace.slicer.helpers import ( autoDetectColumnType, create_color_table, @@ -13,10 +17,24 @@ makeTemporaryNodePermanent, removeTemporaryNodes, updateSegmentationFromLabelMap, + getSourceVolume, + safe_convert_array, + checkUniqueNames, ) +from ltrace.utils.callback import Callback +from scipy import ndimage from typing import List, Tuple -from ltrace.readers import microtom +MIN_CHUNKING_SIZE_BYTES = 2**33 # 8 GiB +CHUNK_SIZE_BYTES = 2**21 # 2 MiB + + +@dataclass +class DataArrayTransform: + ijk_to_ras: np.ndarray + transform: np.ndarray + ras_min: np.ndarray + ras_max: np.ndarray def _crop_value(array: xr.DataArray, value: int): @@ -179,3 +197,310 @@ def import_dataset(dataset, images="all"): makeTemporaryNodePermanent(node, show=True) autoDetectColumnType(node) yield node + + +def _segmentation_to_label_map(segmentation: slicer.vtkMRMLSegmentationNode) -> slicer.vtkMRMLLabelMapVolumeNode: + labelMapVolumeNode = createTemporaryVolumeNode(slicer.vtkMRMLLabelMapVolumeNode, segmentation.GetName()) + slicer.modules.segmentations.logic().ExportAllSegmentsToLabelmapNode( + segmentation, labelMapVolumeNode, slicer.vtkSegmentation.EXTENT_REFERENCE_GEOMETRY + ) + return labelMapVolumeNode + + +def _node_to_data_array( + node: slicer.vtkMRMLScalarVolumeNode, dim_names: List[str], dtype=None +) -> Tuple[slicer.vtkMRMLScalarVolumeNode, xr.DataArray]: + attrs = {} + if isinstance(node, slicer.vtkMRMLSegmentationNode): + node_name = node.GetName() + node = _segmentation_to_label_map(node) + + if node.GetImageData().GetPointData().GetScalars() is None: + return f"Could not export {node_name}: segmentation is empty" + + attrs["type"] = "segmentation" + elif isinstance(node, slicer.vtkMRMLLabelMapVolumeNode): + attrs["type"] = "labelmap" + elif not isinstance(node, slicer.vtkMRMLScalarVolumeNode): + raise ValueError(f"Unsupported node type: {type(node)}") + + if isinstance(node, slicer.vtkMRMLLabelMapVolumeNode): + attrs["labels"] = ["Name,Index,Color"] + export.getLabelMapLabelsCSV(node, withColor=True) + + array = slicer.util.arrayFromVolume(node) + if dtype: + array = safe_convert_array(array, dtype) + dims = dim_names[: array.ndim] + return node, xr.DataArray(array, dims=dims, attrs=attrs) + + +def _recommended_chunksizes(img): + chunk_size = round((CHUNK_SIZE_BYTES // img.dtype.itemsize) ** (1 / 3)) + if ( + img.nbytes >= MIN_CHUNKING_SIZE_BYTES + and img.ndim >= 3 + and all(size >= chunk_size * 4 for size in img.shape[:3]) + ): + return (chunk_size,) * 3 + return None + + +def _add_color(transform: xr.DataArray) -> xr.DataArray: + assert transform.shape == (4, 4) + ret = np.insert(transform, 3, 0, axis=0) + ret = np.insert(ret, 3, 0, axis=1) + ret[3, 3] = 1 + return ret + + +def _get_transform(node: slicer.vtkMRMLScalarVolumeNode, array_shape: np.ndarray) -> DataArrayTransform: + default_transform = np.array( + [ + [-1, 0, 0, 0], + [0, -1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ] + ) + + transform = vtk.vtkMatrix4x4() + node.GetIJKToRASMatrix(transform) + transform = _vtk_4x4_to_numpy(transform) + ijk_to_ras = transform.copy() + + transform = transform @ default_transform + transform[:2, 3] *= -1 + + # Convert XYZ to ZYX + transform[:3, :3] = np.flip(transform[:3, :3], axis=(0, 1)) + transform[:3, 3] = np.flip(transform[:3, 3], axis=0) + + pre_shape = np.array(array_shape[:3]) - 1 + + # Corners of a unit cube + unit_corners = [ + [0, 0, 0], + [0, 0, 1], + [0, 1, 0], + [0, 1, 1], + [1, 0, 0], + [1, 0, 1], + [1, 1, 0], + [1, 1, 1], + ] + ijk_corners = unit_corners * pre_shape + ras_corners = np.array([(transform @ np.concatenate([ijk, [1]]))[:3] for ijk in ijk_corners]) + + ras_min, ras_max = ras_corners.min(axis=0), ras_corners.max(axis=0) + + if len(array_shape) == 4: + transform = _add_color(transform) + + return DataArrayTransform(ijk_to_ras, transform, ras_min, ras_max) + + +def _vtk_4x4_to_numpy(matrix_vtk): + matrix_np = np.empty(16) + matrix_vtk.DeepCopy(matrix_np, matrix_vtk) + return matrix_np.reshape(4, 4) + + +def _get_dataset_main_dims(dataset): + # Uses a heuristic to find likely zyxc dimensions of a dataset + for var in dataset: + array = dataset[var] + dims = array.dims + if len(dims) >= 3: + return dims[:4] + + +def exportNetcdf( + exportPath, + dataNodes, + referenceItem=None, + single_coords=False, + use_compression=False, + callback=None, + nodeNames=None, + nodeDtypes=None, + save_in_place=False, +): + if not dataNodes: + raise ValueError("No images selected.") + + if callback is None: + callback = Callback(on_update=lambda *args, **kwargs: None) + + if single_coords and not save_in_place: + if not referenceItem: + raise ValueError("No reference node selected.") + if referenceItem not in dataNodes: + raise ValueError("Reference image must be in the list of images to export.") + + warnings = [] + + callback.on_update("Starting…", 0) + if not nodeNames: + checkUniqueNames(dataNodes) + + arrays = {} + coords = {} + nodeNames = nodeNames or [node.GetName() for node in dataNodes] + nodeDtypes = nodeDtypes or [None] * len(dataNodes) + + id_to_name_map = {node.GetID(): name for node, name in zip(dataNodes, nodeNames)} + + if save_in_place: + existing_dataset = xr.load_dataset(exportPath) + existing_dims = _get_dataset_main_dims(existing_dataset) + + for node, dtype in zip(dataNodes, nodeDtypes): + name = id_to_name_map[node.GetID()] + if save_in_place and name in existing_dataset: + continue + is_ref = node == referenceItem + + if save_in_place: + dims = existing_dims + elif single_coords or name == "microtom": + dims = list("zyxc") + else: + dims = [f"{d}_{name}" for d in "zyx"] + ["c"] + + source_node = getSourceVolume(node) + + result = _node_to_data_array(node, dims, dtype) + if isinstance(result, str): + warnings.append(result) + continue + node, data_array = result + + if source_node: + try: + source_name = id_to_name_map[source_node.GetID()] + data_array.attrs["reference"] = source_name + except KeyError: + pass + + if is_ref: + referenceItem = node + if data_array.ndim == 4: + coords["c"] = ["r", "g", "b"] + arrays[name] = (data_array, _get_transform(node, data_array.shape), node) + + if single_coords: + if save_in_place: + ref_spacing = [] + ras_min = [] + output_shape = [] + for dim in existing_dims[:3]: + dim_coords = existing_dataset.coords[dim] + if len(dim_coords) > 1: + ref_spacing.append((dim_coords[1] - dim_coords[0]).item()) + else: + ref_spacing.append(1) + ras_min.append(dim_coords[0].item()) + output_shape.append(len(dim_coords)) + else: + ref_spacing = np.array(referenceItem.GetSpacing())[::-1] + ras_min = np.array([tr.ras_min for _, tr, _ in arrays.values()]).min(axis=0) + ras_max = np.array([tr.ras_max for _, tr, _ in arrays.values()]).max(axis=0) + output_shape = np.ceil((ras_max - ras_min) / ref_spacing).astype(int) + 1 + + output_transform_no_color = np.array( + [ + [ref_spacing[0], 0, 0, ras_min[0]], + [0, ref_spacing[1], 0, ras_min[1]], + [0, 0, ref_spacing[2], ras_min[2]], + [0, 0, 0, 1], + ] + ) + + output_transform_with_color = _add_color(output_transform_no_color) + + if not arrays: + raise ValueError("No images to export.\n" + "\n".join(warnings)) + + progress_range = np.arange(5, 90, 85 / len(arrays)) + data_arrays = {} + for (name, (data_array, transform, _)), progress in zip(arrays.items(), progress_range): + callback.on_update(f'Processing "{name}"…', round(progress)) + + array = data_array.data + attrs = data_array.attrs + + if single_coords: + input_transform = transform.transform + output_transform = output_transform_no_color if data_array.ndim == 3 else output_transform_with_color + output_to_input = np.linalg.inv(input_transform) @ output_transform + + fill_value = 0 if "labels" in data_array.attrs else 255 + shape = output_shape.copy() + if data_array.ndim == 4: + shape = np.append(shape, 3) + + # Transform interpolation does not work on dimensions with size 1 + for i in range(3): + if data_array.shape[i] == 1: + output_to_input[i, :] = 0 + output_to_input[:, i] = 0 + output_to_input[i, i] = 1 + + identity = np.eye(output_to_input.shape[0]) + if np.allclose(output_to_input, identity): + if data_array.shape != tuple(shape): + pads = [] + for small, large in zip(data_array.shape, shape): + diff = large - small + pads.append((0, diff)) + array = np.pad(array, pads, mode="constant", constant_values=fill_value) + + else: + array = ndimage.affine_transform( + data_array, output_to_input, output_shape=shape, order=0, cval=fill_value, mode="grid-constant" + ) + else: + attrs["transform"] = transform.ijk_to_ras.flatten().tolist() + + if "reference" in attrs and attrs["reference"] not in arrays: + del attrs["reference"] + + new_data_array = xr.DataArray(array, dims=data_array.dims, attrs=attrs) + data_arrays[name] = new_data_array + + removeTemporaryNodes() + callback.on_update("Exporting to NetCDF…", 90) + + if save_in_place: + for dim in existing_dims[:3]: + coords[dim] = existing_dataset.coords[dim] + elif single_coords: + for min_, spacing, size, dim in zip(ras_min, ref_spacing, output_shape, "zyx"): + max_ = min_ + spacing * (size - 1) + coords[dim] = np.linspace(min_, max_, size) + else: + for name, data_array in data_arrays.items(): + node = arrays[name][2] + origin_zyx = list(node.GetOrigin()[::-1]) + origin_zyx[1] *= -1 + origin_zyx[2] *= -1 + spacing_zyx = node.GetSpacing()[::-1] + for origin, spacing, size, dim in zip(origin_zyx, spacing_zyx, data_array.shape, "zyx"): + coord_name = f"{dim}_{name}" if name != "microtom" else dim + coords[coord_name] = np.linspace(origin, origin + spacing * (size - 1), size) + dataset = xr.Dataset(data_arrays, coords=coords) + + if save_in_place: + for var in dataset: + existing_dataset[var] = dataset[var] + dataset = existing_dataset + + encoding = {} + for var in dataset: + img = dataset[var] + encoding[var] = {"zlib": use_compression, "chunksizes": _recommended_chunksizes(img)} + + dataset.attrs["geoslicer_version"] = slicer.app.applicationVersion + dataset.to_netcdf(exportPath, encoding=encoding, format="NETCDF4") + + return warnings diff --git a/src/ltrace/ltrace/slicer/node_attributes.py b/src/ltrace/ltrace/slicer/node_attributes.py index 87d72cc..9163120 100644 --- a/src/ltrace/ltrace/slicer/node_attributes.py +++ b/src/ltrace/ltrace/slicer/node_attributes.py @@ -54,6 +54,7 @@ class NodeEnvironment(NodeAttributeValue): THIN_SECTION = "ThinSectionEnv" CHARTS = "Charts" LABEL_MAP_EDITOR = "LabelMapEditor" + MULTISCALE = "MultiscaleEnv" class DataOrigin(NodeAttributeValue): diff --git a/src/ltrace/ltrace/slicer/nodes/custom_behavior_image_node.py b/src/ltrace/ltrace/slicer/nodes/custom_behavior_image_node.py index aa4b12e..a323c36 100644 --- a/src/ltrace/ltrace/slicer/nodes/custom_behavior_image_node.py +++ b/src/ltrace/ltrace/slicer/nodes/custom_behavior_image_node.py @@ -2,6 +2,7 @@ from .defs import TriggerEvent from ltrace.slicer.node_attributes import LosslessAttribute from ltrace.slicer.helpers import isImageFile +from ltrace.slicer import export from pathvalidate import sanitize_filepath from pathlib import Path from typing import Union @@ -126,8 +127,7 @@ def _exportImageWrapper(self, node: slicer.vtkMRMLNode, destinationImageFile: Un array = slicer.util.arrayFromVolume(node) imageArray = cv2.cvtColor(array[0, :, :, :], cv2.COLOR_BGR2RGB) - exportLogic = slicer.modules.export.widgetRepresentation().self().logic - exportLogic.exportNodeAsImage( + export.exportNodeAsImage( nodeName=destinationImageFile.stem, dataArray=imageArray, imageFormat=destinationImageFile.suffix, diff --git a/src/ltrace/ltrace/slicer/project_manager.py b/src/ltrace/ltrace/slicer/project_manager.py index 82d552b..ded65a3 100644 --- a/src/ltrace/ltrace/slicer/project_manager.py +++ b/src/ltrace/ltrace/slicer/project_manager.py @@ -6,17 +6,31 @@ import re import shutil import traceback +import psutil +import pandas as pd +from dataclasses import dataclass from ltrace.constants import SaveStatus -from ltrace.slicer.helpers import bounds2size, singleton +from ltrace.slicer.helpers import bounds2size, singleton, getSourceVolume from ltrace.slicer.nodes.custom_behavior_node_manager import CustomBehaviorNodeManager from ltrace.slicer.nodes.defs import TriggerEvent from ltrace.slicer.node_observer import NodeObserver from pathlib import Path from pathvalidate import sanitize_filepath, is_valid_filename -from typing import List +from typing import List, Union +from humanize import naturalsize DEFAULT_PROPERTIES = {"useCompression": 0} +FILE_SIZE_EXTRA_MARGIN = 1.1 + + +@dataclass +class SliceViewConfiguration: + background: str = None + foreground: str = None + label: str = None + foregroundOpacity: float = None + labelOpacity: float = None class HierarchyVisibilityManager: @@ -61,10 +75,9 @@ def __on_node_modified(self, caller, event): self.__last_visibility = caller.GetVisibility() -def BUGFIX_handle_copy_suffix_on_cloned_nodes(storage_node) -> str: +def handleCopySuffixOnClonedNodes(storageNode: slicer.vtkMRMLStorageNode) -> str: """Handle the extra copy at the end of the file name. This is a bug fix for the method AddDefaultStorageNode, that probably generate the wrong name internally - (TODO check this). Args: filename (str): the file name to be fixed. @@ -72,7 +85,7 @@ def BUGFIX_handle_copy_suffix_on_cloned_nodes(storage_node) -> str: Returns: str: the fixed file name. """ - filename = storage_node.GetFileName() + filename = storageNode.GetFileName() if not filename: return "" @@ -84,22 +97,11 @@ def BUGFIX_handle_copy_suffix_on_cloned_nodes(storage_node) -> str: if found > 0: filepath = Path(filename[:-found]) filename = str(filepath.with_name(f"{filepath.stem} Copy{filepath.suffix}")) - storage_node.SetFileName(filename) + storageNode.SetFileName(filename) return filename -def generate_unique_node_name(name: str, dirpath: Path): - count = 0 - for file in dirpath.iterdir(): - if file.is_file() and file.name.startswith(name): - count += 1 - - unique_name = f"{name} ({count})" if count > 0 else name - - return unique_name - - @singleton class ProjectManager(qt.QObject): """Class to handle the 'project concept' from Geoslicer. @@ -108,128 +110,315 @@ class ProjectManager(qt.QObject): projectChangedSignal = qt.Signal(None) - def __init__(self, folder_icon_path, *args, **kwargs): + def __init__(self, folderIconPath, *args, **kwargs): super().__init__(*args, **kwargs) - self.__node_observers = list() - self.__folder_icon_path = folder_icon_path - self.__slices_shown = False - self.custom_behavior_node_manager = CustomBehaviorNodeManager() - self.__config_compression_mode() - - def save(self, project_url, internal_call=False, *args, **kwargs): + self.__nodeObservers = list() + self.__folderIconPath = folderIconPath + self.__slicesShown = False + self.__customBehaviorNodeManager = CustomBehaviorNodeManager() + self.__configCompressionMode() + self.__endCloseSceneObserverHandler = None + self.__modifiedEventObserverHandler = None + self.__endLoadEventObserverHandler = None + self.__startLoadEventObserverHandler = None + + def save(self, projectUrl, internalCall=False, *args, **kwargs): """Handles project' saving process. It saves the nodes that has modifications and are related to the current scene, and then, saves the scene. Args: - project_url (str): the scene filepath (.mrml). + projectUrl (str): the scene filepath (.mrml). Returns: bool: True if save process was successful, otherwise False. """ - if not internal_call: - self.custom_behavior_node_manager.triggerEvent = TriggerEvent.SAVE + if not internalCall: + self.__customBehaviorNodeManager.triggerEvent = TriggerEvent.SAVE slicer.mrmlScene.StartState(slicer.mrmlScene.SaveState) status = SaveStatus.IN_PROGRESS - root_dir_before_save = slicer.mrmlScene.GetRootDirectory() - self.__config_compression_mode(*args, **kwargs) - if os.path.isdir(project_url): - slicer.mrmlScene.SetRootDirectory(project_url) - - # Save Scene - if not self.__save_scene(project_url, *args, **kwargs): - slicer.mrmlScene.SetRootDirectory(root_dir_before_save) - status = SaveStatus.FAILED - else: - slicer.mrmlScene.SetRootDirectory(os.path.dirname(project_url)) - # Save nodes - nodeSavingStatus = self.__save_nodes(*args, **kwargs) + if not self.__validateProjectIsWritable(projectUrl): + slicer.mrmlScene.EndState(slicer.mrmlScene.SaveState) + return SaveStatus.FAILED - # Save Scene - if not nodeSavingStatus or not self.__save_scene(project_url, *args, **kwargs): - slicer.mrmlScene.SetRootDirectory(root_dir_before_save) - status = SaveStatus.FAILED + rootDirBeforeSave = slicer.mrmlScene.GetRootDirectory() + self.__configCompressionMode(*args, **kwargs) + projectUrl = Path(projectUrl).resolve() + projectRootUrl = projectUrl.parent if projectUrl.is_file() else projectUrl + slicer.mrmlScene.SetRootDirectory(projectRootUrl.as_posix()) + firstSave = not projectUrl.exists() - if status == SaveStatus.IN_PROGRESS: - status = SaveStatus.SUCCEED + # Save Scene + if not self.__saveNodes(firstSave=firstSave, *args, **kwargs) or not self.__saveScene( + projectUrl, *args, **kwargs + ): + slicer.mrmlScene.SetRootDirectory(rootDirBeforeSave) + status = SaveStatus.FAILED + + projectFile = self.__findProjectFile(projectRootUrl.as_posix()) + if not projectFile: + slicer.mrmlScene.SetRootDirectory(rootDirBeforeSave) + status = SaveStatus.FAILED - if not internal_call: + if not internalCall: slicer.mrmlScene.EndState(slicer.mrmlScene.SaveState) - self.__set_project_modified(status != SaveStatus.SUCCEED) + if status != SaveStatus.IN_PROGRESS: # status is FAILED or FAILED_FILE_ALREADY_EXISTS + if not internalCall: + self.__setProjectModified(True) + return status - return status + fileProjectPath = (projectRootUrl / projectFile).resolve() + + parameters = { + "fileType": "SceneFile", + "fileName": fileProjectPath.as_posix(), + } + slicer.app.coreIOManager().emitFileSaved(parameters) + + if not internalCall: + self.__setProjectModified(False) + + return SaveStatus.SUCCEED + + def __getSliceViewConfiguration(self) -> SliceViewConfiguration: + config = SliceViewConfiguration() + num = slicer.mrmlScene.GetNumberOfNodesByClass("vtkMRMLSliceCompositeNode") + for i in range(num): + sliceViewer = slicer.mrmlScene.GetNthNodeByClass(i, "vtkMRMLSliceCompositeNode") + config.background = config.background or sliceViewer.GetBackgroundVolumeID() + config.foreground = config.foreground or sliceViewer.GetForegroundVolumeID() + config.label = config.label or sliceViewer.GetLabelVolumeID() + config.foregroundOpacity = config.foregroundOpacity or sliceViewer.GetForegroundOpacity() + config.labelOpacity = config.labelOpacity or sliceViewer.GetLabelOpacity() + + return config + + def __setSliceViewConfiguration(self, config: SliceViewConfiguration) -> None: + slicer.util.setSliceViewerLayers( + background=config.background, + foreground=config.foreground, + label=config.label, + foregroundOpacity=config.foregroundOpacity, + labelOpacity=config.labelOpacity, + ) - def save_as(self, scene_path, *args, **kwargs): + def saveAs(self, scenePath, *args, **kwargs): """Handle custom save scene as operation.""" - self.custom_behavior_node_manager.triggerEvent = TriggerEvent.SAVE_AS + self.__customBehaviorNodeManager.triggerEvent = TriggerEvent.SAVE_AS + sliceViewConfig = self.__getSliceViewConfiguration() slicer.mrmlScene.StartState(slicer.mrmlScene.SaveState) status = SaveStatus.IN_PROGRESS - scene_path = Path(scene_path) + scenePath = Path(scenePath) - if scene_path.is_file(): - slicer.util.errorDisplay(f'Cannot create project directory at "{scene_path}" because it is a file.') - self.__set_project_modified(True) + if scenePath.is_file(): + slicer.util.errorDisplay(f'Cannot create project directory at "{scenePath}" because it is a file.') + self.__setProjectModified(True) slicer.mrmlScene.EndState(slicer.mrmlScene.SaveState) + self.__setSliceViewConfiguration(sliceViewConfig) return SaveStatus.FAILED_FILE_ALREADY_EXISTS - try: - scene_path.mkdir(parents=True, exist_ok=True) - except Exception as error: - logging.error("Failed during attempt to create new project directory.") - self.__set_project_modified(True) - slicer.mrmlScene.EndState(slicer.mrmlScene.SaveState) - return SaveStatus.FAILED - - status = self.save(str(scene_path), internal_call=True, *args, **kwargs) + status = self.save(str(scenePath), internalCall=True, *args, **kwargs) if status != SaveStatus.SUCCEED and status != SaveStatus.IN_PROGRESS: # CANCELLED or FAILED options - self.__set_project_modified(True) + self.__setProjectModified(True) slicer.mrmlScene.EndState(slicer.mrmlScene.SaveState) + self.__setSliceViewConfiguration(sliceViewConfig) return status - self.__configure_project_folder(str(scene_path)) - project_file = self.__find_project_file(str(scene_path)) + self.__configureProjectFolder(str(scenePath)) + projectFile = self.__findProjectFile(str(scenePath)) - if project_file is None: - self.__set_project_modified(True) + if projectFile is None: + self.__setProjectModified(True) slicer.mrmlScene.EndState(slicer.mrmlScene.SaveState) + self.__setSliceViewConfiguration(sliceViewConfig) return SaveStatus.FAILED slicer.mrmlScene.EndState(slicer.mrmlScene.SaveState) - file_project_path = os.path.join(str(scene_path), project_file) + # Maintain event behavior from previous version + slicer.mrmlScene.StartState(slicer.mrmlScene.ImportState) + + fileProjectPath = (scenePath / projectFile).resolve() + slicer.mrmlScene.SetURL(fileProjectPath.as_posix()) + status = SaveStatus.SUCCEED + + slicer.mrmlScene.EndState(slicer.mrmlScene.ImportState) + + self.projectChangedSignal.emit() + self.__setProjectModified(False) + self.__setSliceViewConfiguration(sliceViewConfig) + + return status + + def __getFileSize(self, filePath: str) -> int: + """Returns the size of a file in bytes if it exists. + If the file does not exist, checks if the path is a directory and returns the total size of all files in that directory. + """ + filePath = Path(filePath) + if filePath.is_file(): + return filePath.stat().st_size + elif filePath.is_dir(): + totalSize = 0 + for item in list(filePath.iterdir()): + if item.is_file(): + totalSize += item.stat().st_size + return totalSize + else: + if filePath.parent.is_dir(): + totalSize = 0 + for item in list(filePath.parent.iterdir()): + if item.is_file(): + totalSize += item.stat().st_size + + return totalSize + + logging.error(f"File or directory not found: {filePath}") + return 0 + + def __estimateImageDataSize(self, imageData: vtk.vtkImageData) -> int: + """returns expected file size of a volume node Image Data""" + dimensions = imageData.GetDimensions() + dataTypeSize = imageData.GetScalarSize() + return dimensions[0] * dimensions[1] * dimensions[2] * dataTypeSize + + def estimateNodeSize(self, node: slicer.vtkMRMLNode) -> int: + """returns the expected file size in bytes of a node""" + if isinstance(node, slicer.vtkMRMLScalarVolumeNode): + estimate = self.__estimateImageDataSize(node.GetImageData()) + + elif isinstance(node, slicer.vtkMRMLSegmentationNode): + try: + binaryLabelMap = node.GetSegmentation().GetNthSegment(0).GetRepresentation("Binary labelmap") + except AttributeError: # Invalid segment or representation + logging.error("Attempt to estimate size of empty segmentation node failed.") + estimate = 0 + else: + estimate = self.__estimateImageDataSize(binaryLabelMap) + + elif isinstance(node, slicer.vtkMRMLTableNode): + tableDF = slicer.util.dataframeFromTable(node) + tableDF: pd.DataFrame = tableDF.round(6) + headerCounts = pd.Series(tableDF.columns.astype(str).str.len()) + numRows, numColumns = tableDF.shape + characterCounts = tableDF.astype(str).apply(lambda x: x.astype(str).str.len()) + estimate = characterCounts.sum().sum() + numRows * numColumns + headerCounts.sum() + + elif isinstance(node, slicer.vtkMRMLModelNode): + try: + estimate = node.GetPolyData().GetActualMemorySize() * 1024 + except AttributeError: + estimate = 0 + + elif isinstance(node, slicer.vtkMRMLSequenceNode): + estimate = 0 + for item in range(node.GetNumberOfDataNodes()): + estimate += self.estimateNodeSize(node.GetNthDataNode(item)) + + else: + estimate = 0 + + return estimate * FILE_SIZE_EXTRA_MARGIN + + def getNodeSize(self, node: slicer.vtkMRMLNode, scenePath: str) -> int: + """Returns the size of a node's associated file if it exists and is in scene path, + the expected estimate size or 0 if it doesn't exist or type has considerable size.""" + storageNode = node.GetStorageNode() + filePath = None + if storageNode: + filePath = storageNode.GetFullNameFromFileName() + if not Path(filePath).is_relative_to(Path(scenePath).parent): + filePath = None + + if filePath: + return self.__getFileSize(filePath) + else: + estimate = self.estimateNodeSize(node) + return estimate + + def __listAllStorableNodes(self, scenePath: str) -> int: + """Lists all storable nodes and their sizes.""" + totalSize = 0 + for node in self.__getNodesToSave(): + nodeSize = self.getNodeSize(node, scenePath) + totalSize += nodeSize + + return totalSize + + def __hasWriteAccess(self, path: Path) -> bool: + """Check if the user has write access to the given path.""" + try: - self.load(file_project_path) - except Exception as error: - status = SaveStatus.FAILED - logging.error(f"A problem occured during the 'Save As Scene' process: {error}\n{traceback.format_exc()}") - self.__set_project_modified(True) + testFile = path / "test_write.tmp" + with open(testFile, "w") as f: + f.write("test") + testFile.unlink(missing_ok=True) + return True + except (PermissionError, IOError): + return False + + def getWritableStorageInfo(self, userHome: Path) -> int: + """Get writable storage information for the user home directory.""" + + if self.__hasWriteAccess(userHome): + usage = psutil.disk_usage(userHome.as_posix()) + return usage.free else: - status = SaveStatus.SUCCEED - self.__set_project_modified(False) - self.projectChangedSignal.emit() + logging.error(f"Warning: No write access to {userHome}!") + return 0 - return status + def __validateProjectIsWritable(self, scenePath: str) -> bool: + """Checks if project can be written and if has enough space for expected files sizes""" + try: + userHomeDirectory = Path.home() + hdSize = self.getWritableStorageInfo(userHomeDirectory) + sceneSize = self.__listAllStorableNodes(scenePath) + + if hdSize <= sceneSize: + logging.error( + f"Not enough space in drive ({naturalsize(hdSize)}) for scene ({naturalsize(sceneSize)})." + ) + return False + + except Exception as e: + logging.error(repr(e)) + return False + + return True - def load(self, project_file_path, internal_call=False) -> None: + def load(self, projectFilePath: Union[str, Path], internalCall: bool = False) -> bool: """Handle custom load scene operation.""" - if isinstance(project_file_path, Path): - project_file_path = project_file_path.as_posix() + if isinstance(projectFilePath, str): + projectFilePath = Path(projectFilePath) - if project_file_path == slicer.mrmlScene.GetURL(): - return + projectFilePath = projectFilePath.resolve() - if not internal_call: - self.custom_behavior_node_manager.triggerEvent = TriggerEvent.LOAD + if projectFilePath.as_posix() == slicer.mrmlScene.GetURL(): + return True - self.__clear_node_observers() + if not projectFilePath.exists(): + logging.error(f"Cannot load project from '{projectFilePath.as_posix()}'. File does not exist.") + return False + + if not internalCall: + self.__customBehaviorNodeManager.triggerEvent = TriggerEvent.LOAD + + self.__clearNodeObservers() # Close scene before load a new one self.close() - slicer.util.loadScene(project_file_path) - self.__set_project_modified(False) + status = True + try: + slicer.util.loadScene(projectFilePath.as_posix()) + except Exception as error: + logging.error(f"A problem occured during the 'Load Scene' process: {error}\n{traceback.format_exc()}") + status = False + + self.__setProjectModified(False) + + return status def close(self) -> None: """Wrapper method to close the project.""" @@ -238,42 +427,42 @@ def close(self) -> None: def setup(self): """Initialize project's event handlers""" - self.end_close_scene_observer_handler = slicer.mrmlScene.AddObserver( - slicer.mrmlScene.EndCloseEvent, self.__on_end_close_scene + self.__endCloseSceneObserverHandler = slicer.mrmlScene.AddObserver( + slicer.mrmlScene.EndCloseEvent, self.__onEndCloseScene ) - self.modified_event_observer_handler = slicer.mrmlScene.AddObserver("ModifiedEvent", self.__on_scene_modified) + self.__modifiedEventObserverHandler = slicer.mrmlScene.AddObserver("ModifiedEvent", self.__onSceneModified) self.node_added_observer_handler = slicer.mrmlScene.AddObserver( - slicer.mrmlScene.NodeAddedEvent, self.__on_node_added + slicer.mrmlScene.NodeAddedEvent, self.__onNodeAdded ) - self.end_load_event_observer_handler = slicer.mrmlScene.AddObserver( - slicer.mrmlScene.EndImportEvent, self.__on_end_load_scene + self.__endLoadEventObserverHandler = slicer.mrmlScene.AddObserver( + slicer.mrmlScene.EndImportEvent, self.__onEndLoadScene ) - self.start_load_event_observer_handler = slicer.mrmlScene.AddObserver( - slicer.mrmlScene.StartImportEvent, self.__on_start_load_scene + self.__startLoadEventObserverHandler = slicer.mrmlScene.AddObserver( + slicer.mrmlScene.StartImportEvent, self.__onStartLoadScene ) - def __on_start_load_scene(self, *args, **kwargs): + def __onStartLoadScene(self, *args, **kwargs): """Handle slicer' start load scene event.""" - slicer.mrmlScene.RemoveObserver(self.modified_event_observer_handler) + slicer.mrmlScene.RemoveObserver(self.__modifiedEventObserverHandler) - def __on_end_close_scene(self, *args): + def __onEndCloseScene(self, *args): """Handle slicer' end close scene event.""" - self.__slices_shown = False + self.__slicesShown = False def process(): - self.__set_project_modified(False) + self.__setProjectModified(False) self.projectChangedSignal.emit() # Add 'process' method to the end of the Qt's events queue. qt.QTimer.singleShot(0, process) - def __on_end_load_scene(self, *args, **kwargs): + def __onEndLoadScene(self, *args, **kwargs): """Handle slicer' end load scene event.""" - slicer.util.mainWindow().setWindowModified(False) + slicer.modules.AppContextInstance.mainWindow.setWindowModified(False) self.projectChangedSignal.emit() - self.modified_event_observer_handler = slicer.mrmlScene.AddObserver("ModifiedEvent", self.__on_scene_modified) + self.__modifiedEventObserverHandler = slicer.mrmlScene.AddObserver("ModifiedEvent", self.__onSceneModified) # Hide axis labels for legacy projects which may have them. # This can probably be removed in the future, as it is also done @@ -281,28 +470,14 @@ def __on_end_load_scene(self, *args, **kwargs): viewNode = slicer.app.layoutManager().threeDWidget(0).mrmlViewNode() viewNode.SetAxisLabelsVisible(False) - # Without this the Image Log segmenter doesn't correctly restore the selected segmentation node - try: - image_log_data_logic = slicer.modules.imagelogdata.widgetRepresentation().self().logic - image_log_data_logic.imageLogSegmenterWidget.self().initializeSavedNodes() + self.__clearMaskSettingsOnAllSegmentEditors() - # Image Log number of views restoration - if slicer.app.layoutManager().layout >= 15000: - image_log_data_logic.configurationsNode = None - image_log_data_logic.loadConfiguration() - - except ValueError: - # Widget has been deleted after test - pass - - self.__clear_mask_settings_on_all_segment_editors() - - def __on_scene_modified(self, *args, **kwargs): + def __onSceneModified(self, *args, **kwargs): """Handle slicer' scene modified event.""" - self.__set_project_modified(True) + self.__setProjectModified(True) @vtk.calldata_type(vtk.VTK_OBJECT) - def __on_node_added(self, caller, eventId, callData): + def __onNodeAdded(self, caller, eventId, callData): """Handle slicer' node added to scene event.""" if issubclass( type(callData), @@ -312,15 +487,20 @@ def __on_node_added(self, caller, eventId, callData): slicer.vtkMRMLSubjectHierarchyNode, slicer.vtkMRMLTransformNode, slicer.vtkMRMLSliceDisplayNode, + slicer.vtkMRMLTableViewNode, + slicer.vtkMRMLSegmentEditorNode, ), ): return + if isinstance(callData, slicer.vtkMRMLTableNode) and callData.GetName() == "Default mineral colors": + return + observer = NodeObserver(node=callData, parent=self) - observer.modifiedSignal.connect(self.__on_scene_modified) - observer.removedSignal.connect(self.__on_observed_node_removed) + observer.modifiedSignal.connect(self.__onSceneModified) + observer.removedSignal.connect(self.__onObservedNodeRemoved) - self.__node_observers.append(observer) + self.__nodeObservers.append(observer) if isinstance(callData, slicer.vtkMRMLVolumeNode): @@ -331,7 +511,7 @@ def onVolumeModified(node_observer: NodeObserver, node: slicer.vtkMRMLNode) -> N timer = qt.QTimer() def onTimeout(): - self.__on_volume_modified(node) + self.__onVolumeModified(node) timer.timeout.disconnect(onTimeout) timer.setSingleShot(True) @@ -347,49 +527,49 @@ def onTimeout(): segmentation_display = callData HierarchyVisibilityManager(segmentation_display, lambda node: node.GetDisplayableNode()) - def __on_observed_node_removed(self, node_observer: NodeObserver, node: slicer.vtkMRMLNode) -> None: + def __onObservedNodeRemoved(self, node_observer: NodeObserver, node: slicer.vtkMRMLNode) -> None: """Handle when a node being observed is removed from the scene.""" - if node_observer not in self.__node_observers: + if node_observer not in self.__nodeObservers: return - self.__node_observers.remove(node_observer) + self.__nodeObservers.remove(node_observer) del node_observer - def __clear_node_observers(self): + def __clearNodeObservers(self) -> None: """Clear the node observer's list and remove the observer handlers from each one.""" - for node_observer in self.__node_observers[:]: + for node_observer in self.__nodeObservers[:]: node_observer.clear() del node_observer - self.__node_observers.clear() + self.__nodeObservers.clear() - def __configure_project_folder(self, project_path): + def __configureProjectFolder(self, projectPath: str) -> None: """Applies folder's custom configuration Args: - project_path (str): the folder path + projectPath (str): the folder path """ platform = os.name if platform == "nt": # Windows: - self.__create_project_folder_configuration_file( - project_path=project_path, + self.__createProjectFolderConfigurationFile( + projectPath=projectPath, ConfirmFileOp=1, NoSharing=0, - IconFile=self.__folder_icon_path, + IconFile=self.__folderIconPath, IconIndex=0, InfoTip="This is a Geoslicer project folder", ) elif platform == "posix": # Linux: - self.__create_project_folder_configuration_file(project_path=project_path, Icon=self.__folder_icon_path) + self.__createProjectFolderConfigurationFile(projectPath=projectPath, Icon=self.__folderIconPath) else: pass - def __create_project_folder_configuration_file(self, project_path, *args, **kwargs): + def __createProjectFolderConfigurationFile(self, projectPath: str, *args, **kwargs) -> None: """Wrapper for creating the file that configures the folder attributes. Works with the following OS: Windows, Linux Args: - project_path (str): the folder path to be configured + projectPath (str): the folder path to be configured Raises: Exception: Not supported OS. @@ -398,106 +578,106 @@ def __create_project_folder_configuration_file(self, project_path, *args, **kwar platform = os.name if platform == "nt": # Windows - icon_parameter_label = "IconFile" - config_file_name = "desktop.ini" + iconParameterLabel = "IconFile" + configFileName = "desktop.ini" header = "[.ShellClassInfo]" - def post_setup(config_file): + def post_setup(configFile): # Add attributes to the config file (Mandatory) - os.system('attrib +h +s "{}"'.format(config_file)) + os.system('attrib +h +s "{}"'.format(configFile)) # Add attributes to the directory (Mandatory) - os.system('attrib +r "{}"'.format(os.path.dirname(config_file))) + os.system('attrib +r "{}"'.format(os.path.dirname(configFile))) elif platform == "posix": # Linux - icon_parameter_label = "Icon" - config_file_name = ".directory" + iconParameterLabel = "Icon" + configFileName = ".directory" header = "[Desktop Entry]" - def post_setup(config_file): + def post_setup(configFile): # TODO: test if this works consistently in different Linux systems - command = f'gio set -t string "{project_path}"' - command += f' metadata::custom-icon "file://{project_path}/ProjectIcon.ico"' - command += f' && touch "{project_path}"' + command = f'gio set -t string "{projectPath}"' + command += f' metadata::custom-icon "file://{projectPath}/ProjectIcon.ico"' + command += f' && touch "{projectPath}"' os.system(command) else: raise Exception("Current OS is not supported by this function.") - if icon_parameter_label in kwargs.keys(): - icon_file = kwargs[icon_parameter_label] - if os.path.isfile(icon_file): - shutil.copy2(icon_file, project_path) - kwargs[icon_parameter_label] = os.path.basename(icon_file) + if iconParameterLabel in kwargs.keys(): + iconFile = kwargs[iconParameterLabel] + if os.path.isfile(iconFile): + shutil.copy2(iconFile, projectPath) + kwargs[iconParameterLabel] = os.path.basename(iconFile) data = header + "\n" for k, v in kwargs.items(): data += "{}={}\n".format(k, v) - config_file = os.path.join(project_path, config_file_name) + configFile = os.path.join(projectPath, configFileName) # Create folder configuration file - with open(config_file, "w", encoding="utf-8") as file: + with open(configFile, "w", encoding="utf-8") as file: file.write(data) # Apply post configurations - post_setup(config_file) + post_setup(configFile) - def __find_project_file(self, project_path: str, extension=".mrml"): + def __findProjectFile(self, projectPath: str, extension=".mrml") -> str: """Search for project file inside path. Args: - project_path (str): The directory to search for the project file + projectPath (str): The directory to search for the project file extension (str, optional): The project file extension. Defaults to ".mrml". Returns: str: The project file path if it was found. Otherwise, None """ - project_file = None - for _, _, files in os.walk(project_path): + projectFile = None + for _, _, files in os.walk(projectPath): for file in files: if file.endswith(extension): - project_file = file + projectFile = file break - return project_file + return projectFile - def __set_project_modified(self, mode: bool): + def __setProjectModified(self, mode: bool) -> None: """Handle project modification's events.""" - slicer.util.mainWindow().setWindowModified(mode) + slicer.modules.AppContextInstance.mainWindow.setWindowModified(mode) - def __config_compression_mode(self, *args, **kwargs): + def __configCompressionMode(self, *args, **kwargs) -> None: properties = DEFAULT_PROPERTIES.copy() - custom_properties = kwargs.get("properties") - if custom_properties is not None and isinstance(custom_properties, dict): - properties.update(custom_properties) + customProperties = kwargs.get("properties") + if customProperties is not None and isinstance(customProperties, dict): + properties.update(customProperties) - use_compression = properties.get("useCompression", 0) + useCompression = properties.get("useCompression", 0) # Add default storage nodes for volume node types - default_volume_storage_node = slicer.vtkMRMLVolumeArchetypeStorageNode() - default_volume_storage_node.SetUseCompression(use_compression) - slicer.mrmlScene.AddDefaultNode(default_volume_storage_node) + defaultVolumeStorageNode = slicer.vtkMRMLVolumeArchetypeStorageNode() + defaultVolumeStorageNode.SetUseCompression(useCompression) + slicer.mrmlScene.AddDefaultNode(defaultVolumeStorageNode) # Add default storage nodes for segmentation node types - default_seg_storage_node = slicer.vtkMRMLSegmentationStorageNode() - default_seg_storage_node.SetUseCompression(use_compression) - slicer.mrmlScene.AddDefaultNode(default_seg_storage_node) + defaultSegStorageNode = slicer.vtkMRMLSegmentationStorageNode() + defaultSegStorageNode.SetUseCompression(useCompression) + slicer.mrmlScene.AddDefaultNode(defaultSegStorageNode) - def __get_nodes_to_save(self) -> List[slicer.vtkMRMLNode]: - nodes_collection = slicer.mrmlScene.GetNodesByClass("vtkMRMLStorableNode") - nodesCount = nodes_collection.GetNumberOfItems() + def __getNodesToSave(self) -> List[slicer.vtkMRMLNode]: + nodesCollection = slicer.mrmlScene.GetNodesByClass("vtkMRMLStorableNode") + nodesCount = nodesCollection.GetNumberOfItems() for idx in range(nodesCount): - node = nodes_collection.GetItemAsObject(idx) + node = nodesCollection.GetItemAsObject(idx) hide = bool(node.GetHideFromEditors()) - save_with_scene = bool(node.GetSaveWithScene()) + saveWithScene = bool(node.GetSaveWithScene()) - if not save_with_scene: + if not saveWithScene: continue yield node - def __save_nodes(self, *args, **kwargs): + def __saveNodes(self, firstSave: bool, *args, **kwargs) -> bool: """Handle the nodes saving process. Returns: @@ -505,65 +685,76 @@ def __save_nodes(self, *args, **kwargs): """ status = True - data_folder = Path(slicer.mrmlScene.GetRootDirectory()) / "Data" - files_to_delete = set(data_folder.iterdir()) + rootPath = Path(slicer.mrmlScene.GetRootDirectory()) + dataFolder = (rootPath / "Data").resolve() + if dataFolder.exists(): + filesToDelete = {path for path in dataFolder.iterdir()} + else: + filesToDelete = set() try: - for node in self.__get_nodes_to_save(): + for node in self.__getNodesToSave(): if not self.__handle_storable_node(node, *args, **kwargs): continue - storage_node = node.GetStorageNode() - file_path = Path(storage_node.GetFileName()).resolve() - file_paths = {file_path} + storageNode = node.GetStorageNode() + filePath = Path(storageNode.GetFileName()).resolve() + filePaths = {filePath} # File name list. Typically used to store a table schema. - for i in range(storage_node.GetNumberOfFileNames()): - path = Path(storage_node.GetNthFileName(i)).resolve() - file_paths.add(path) - files_to_delete -= file_paths + for i in range(storageNode.GetNumberOfFileNames()): + path = Path(storageNode.GetNthFileName(i)).resolve() + filePaths.add(path) + filesToDelete -= filePaths - file_already_exists = all(path.exists() for path in file_paths) - if node.GetModifiedSinceRead() is False and file_already_exists: + fileAlreadyExists = all(path.exists() for path in filePaths) + if node.GetModifiedSinceRead() is False and fileAlreadyExists: continue - file_path = str(file_path) - status = slicer.util.saveNode(node, file_path) - if not status: - logging.error( - "Failed to save {} node's file at the location: {}\n{}".format( - node.GetName(), file_path, traceback.format_exc() + if filePath.exists() or not firstSave: + relativeNodeFilePath = filePath.relative_to(rootPath).as_posix() + status = slicer.util.saveNode(node, relativeNodeFilePath) + if not status: + logging.error( + "Failed to save {} node's file at the location: {}\n{}".format( + node.GetName(), filePath, traceback.format_exc() + ) ) - ) - return False + return False + else: + logging.debug("Node {} was saved succesfully at {}".format(node.GetName(), filePath)) + + for filePath in filesToDelete: + if filePath.is_dir(): + shutil.rmtree(filePath) else: - logging.debug("Node {} was saved succesfully at {}".format(node.GetName(), file_path)) + filePath.unlink() + logging.debug(f"{filePath} was deleted as it is no longer associated to any node.") - for file_path in files_to_delete: - file_path.unlink() - logging.debug(f"File {file_path} was deleted as it is no longer associated to any node.") except Exception as error: logging.error(f"A problem has occured during the nodes' save process: {error}\n{traceback.format_exc()}") return False return status - def __get_localized_storage_node(self, node, local_storage_dir): - storage_node = node.GetStorageNode() + def __getLocalizedStorageNode( + self, node: slicer.vtkMRMLStorableNode, localStorageDir: Path + ) -> slicer.vtkMRMLStorageNode: + storageNode = node.GetStorageNode() # All files should be stored in the Data directory. # If the node's file name is in another directory, create a new default storage node. - if storage_node and storage_node.GetFileName(): - file_path = Path(storage_node.GetFileName()).resolve() - if file_path.parent != local_storage_dir: - slicer.mrmlScene.RemoveNode(storage_node) + if storageNode and storageNode.GetFileName(): + filePath = Path(storageNode.GetFileName()).resolve() + if filePath.parent != localStorageDir: + slicer.mrmlScene.RemoveNode(storageNode) node.AddDefaultStorageNode() else: node.AddDefaultStorageNode() return node.GetStorageNode() - def __handle_storable_node(self, node, *args, **kwargs): + def __handle_storable_node(self, node: slicer.vtkMRMLStorableNode, *args, **kwargs) -> bool: """Function that checks if storable node has a valid storage node or if it could be created. In case of creating a new storage node, it will define its filename. Otherwise, if it wouldn't be possible to create a new storage node, @@ -571,7 +762,7 @@ def __handle_storable_node(self, node, *args, **kwargs): (Probably the scene file's writer will handle it) Args: - node (vtk.vtkMRMLStorableNode): the storable node object. + node (slicer.vtkMRMLStorableNode): the storable node object. Returns: bool: True if node has a valid storage node, otherwise returns False. @@ -579,43 +770,50 @@ def __handle_storable_node(self, node, *args, **kwargs): if not hasattr(node, "GetStorageNode"): return False - data_folder = Path(slicer.mrmlScene.GetRootDirectory()) / "Data" + dataFolder = (Path(slicer.mrmlScene.GetRootDirectory()) / "Data").resolve() - storage_node = self.__get_localized_storage_node(node, data_folder) - if not storage_node: + storageNode = self.__getLocalizedStorageNode(node, dataFolder) + if not storageNode: return False - filepath = Path(BUGFIX_handle_copy_suffix_on_cloned_nodes(storage_node)) + filepath = Path(handleCopySuffixOnClonedNodes(storageNode)) if not (filepath and is_valid_filename(filepath.name)): - filename = generate_unique_node_name(node.GetName(), data_folder) - ext = storage_node.GetDefaultWriteFileExtension() - storage_node.SetFileName(str(data_folder / filename) + f".{ext}") + tempFileName = storageNode.GetTempFileName() + if tempFileName and Path(tempFileName).is_relative_to(dataFolder): + storageNode.SetFileName(tempFileName) + else: + ext = storageNode.GetDefaultWriteFileExtension() + filePath = f"{(dataFolder / node.GetID()).as_posix()}.{ext}" + storageNode.SetFileName(filePath) # Define compression mode properties = DEFAULT_PROPERTIES.copy() - custom_properties = kwargs.get("properties") - if custom_properties is not None and isinstance(custom_properties, dict): - properties.update(custom_properties) + customProperties = kwargs.get("properties") + if customProperties is not None and isinstance(customProperties, dict): + properties.update(customProperties) - use_compression = properties.get("useCompression", 0) - storage_node.SetUseCompression(use_compression) + useCompression = properties.get("useCompression", 0) + storageNode.SetUseCompression(useCompression) return True - def __save_scene(self, project_url, *args, **kwargs): + def __saveScene(self, projectUrl: Union[str, Path], *args, **kwargs) -> bool: """Handle the nodes saving process. Args: - project_url (str): the scene URL string. + projectUrl (str): the scene URL string. Returns: bool: True if worked succesfully, otherwise returns False. """ status = True + if isinstance(projectUrl, str): + projectUrl = Path(projectUrl).resolve() + try: - status = slicer.util.saveScene(project_url) + status = slicer.util.saveScene(projectUrl.as_posix()) except Exception as error: status = False logging.error( @@ -628,21 +826,21 @@ def __save_scene(self, project_url, *args, **kwargs): return status - def __on_volume_modified(self, volume: slicer.vtkMRMLNode) -> None: + def __onVolumeModified(self, volume: slicer.vtkMRMLNode) -> None: if volume is None: return autoFrameOff = volume.GetAttribute("AutoFrameOff") autoSliceVisibleOff = volume.GetAttribute("AutoSliceVisibleOff") if volume.GetImageData(): - if not self.__slices_shown and not slicer.mrmlScene.GetURL() and autoSliceVisibleOff != "true": + if not self.__slicesShown and not slicer.mrmlScene.GetURL() and autoSliceVisibleOff != "true": # Open slice eyes once for a new project - self.__show_slices_in_3d() - self.__slices_shown = True + self.__showSlicesIn3D() + self.__slicesShown = True if autoFrameOff != "true": - self.__frame_volume(volume) + self.__frameVolume(volume) - def __show_slices_in_3d(self): + def __showSlicesIn3D(self): if slicer.mrmlScene.GetNumberOfNodesByClass("vtkMRMLScalarVolumeNode") == 0: return layoutManager = slicer.app.layoutManager() @@ -650,7 +848,7 @@ def __show_slices_in_3d(self): controller = layoutManager.sliceWidget(sliceViewName).sliceController() controller.setSliceVisible(True) - def __frame_volume(self, volume): + def __frameVolume(self, volume): """Reposition camera so it points to the center of the volume and position it so the volume is reasonably visible. """ @@ -668,7 +866,54 @@ def __frame_volume(self, volume): cam.SetPosition(pos) camNode.ResetClippingRange() - def __clear_mask_settings_on_all_segment_editors(self): + def __clearMaskSettingsOnAllSegmentEditors(self): nodes = slicer.util.getNodesByClass("vtkMRMLSegmentEditorNode") for node in nodes: node.SourceVolumeIntensityMaskOff() + + +def getAvailableFilename(name: str, dirpath: Path): + files = [file for file in dirpath.iterdir() if file.is_file()] + fileNames = [file.name for file in files] + if name not in fileNames: + return name + + def check(base_name, filename): + pattern = rf"^({re.escape(base_name)})?\s?(\(\d+\))?$" + return re.fullmatch(pattern, filename) + + name, ext = os.path.splitext(name) + count = 0 + suffixCount = 0 + currentNameHasSuffix = False + + sch = re.search(rf"^(\w+)?\s+?(\(\d+\))?", name) + if sch and len(sch.groups()) >= 2: + name = sch.group(1) + currentNameHasSuffix = True + number = int(re.sub(r"[()]", "", sch.group(2))) + suffixCount = max(suffixCount, int(number)) + + for file in files: + fileStem = os.path.splitext(file.name)[0] + match = check(name, fileStem) + if not match: + continue + + pattern = rf"^({re.escape(name)})?\s?(\(\d+\))?$" + if sch := re.search(pattern, fileStem): + if len(sch.groups()) < 2: + continue + + if sch.group(2): + number = int(re.sub(r"[()]", "", sch.group(2))) + suffixCount = max(suffixCount, int(number)) + + count += 1 + + if count == 0 and suffixCount == 0 and currentNameHasSuffix is False: + unique_name = name + ext + else: + unique_name = f"{name} ({suffixCount+1}){ext}" + + return unique_name diff --git a/src/ltrace/ltrace/slicer/tests/ltrace_plugin_test.py b/src/ltrace/ltrace/slicer/tests/ltrace_plugin_test.py index 5ba75bd..f9e2e06 100644 --- a/src/ltrace/ltrace/slicer/tests/ltrace_plugin_test.py +++ b/src/ltrace/ltrace/slicer/tests/ltrace_plugin_test.py @@ -214,6 +214,8 @@ def runTest(self): if self.__test_state == TestState.CANCELLED and idx != len(test_cases) - 1: break + test.reset() + self.setUp(test) log( @@ -445,7 +447,7 @@ def __on_timeout(self) -> None: return # Clear possible message boxes freezing the operation - message_boxes = slicer.util.mainWindow().findChildren(qt.QMessageBox) + message_boxes = slicer.modules.AppContextInstance.mainWindow.findChildren(qt.QMessageBox) message_from_boxes = [] for message_box in message_boxes: if not message_box.visible: diff --git a/src/ltrace/ltrace/slicer/tests/ltrace_tests_model.py b/src/ltrace/ltrace/slicer/tests/ltrace_tests_model.py index 4f40dee..7bea9b7 100644 --- a/src/ltrace/ltrace/slicer/tests/ltrace_tests_model.py +++ b/src/ltrace/ltrace/slicer/tests/ltrace_tests_model.py @@ -9,6 +9,7 @@ from enum import Enum from ltrace.slicer.tests.ltrace_plugin_test import LTracePluginTest from ltrace.slicer.tests.constants import TestState +from typing import List class TestsSource(Enum): @@ -232,16 +233,20 @@ def cancel(self): self.__cancelling = True def result(self): - for test_suite in self.test_suite_list: - if not test_suite.enabled: - continue + def check_suite(suite_list: List[TestSuiteData]) -> TestState: + for test_suite in suite_list: + if not test_suite.enabled: + continue + + enabled_test_cases = [case for case in test_suite.test_case_data_list if case.enabled is True] + + for case in enabled_test_cases: + if case.test_status != TestState.SUCCEED: + return TestState.FAILED - enabled_test_cases = [case for case in test_suite.test_case_data_list if case.enabled is True] - for case in enabled_test_cases: - if case.test_status != TestState.SUCCEED: - return TestState.FAILED + return TestState.SUCCEED - return TestState.SUCCEED + return check_suite(self.test_suite_list + self.generate_suite_list) def __get_all_suites(self, test_source=TestsSource.ANY): test_suite_list = [] @@ -338,7 +343,7 @@ def __get_generate_methods_data(self, test_class): def run_tests(self, **kwargs): self.__is_running = True shuffle = kwargs.get("shuffle", False) - test_suite_list = kwargs.get("suite_list") + test_suite_list: List[TestSuiteData] = kwargs.get("suite_list") if not isinstance(test_suite_list, list) or test_suite_list is None: self.__cancelling = False diff --git a/src/ltrace/ltrace/slicer/tests/ltrace_tests_widget.py b/src/ltrace/ltrace/slicer/tests/ltrace_tests_widget.py index 8e1c896..adcc8c7 100644 --- a/src/ltrace/ltrace/slicer/tests/ltrace_tests_widget.py +++ b/src/ltrace/ltrace/slicer/tests/ltrace_tests_widget.py @@ -4,429 +4,460 @@ from ltrace.slicer.tests.constants import TestState from ltrace.slicer.tests.ltrace_tests_model import LTraceTestsModel, TestSuiteData, TestCaseData, TestsSource -from ltrace.slicer.tests.utils import log +from ltrace.slicer.tests.utils import log, loadAllModules from pathlib import Path +from typing import List, Union RESOURCES_DIR = Path(__file__).parent / "resources" -class LogWidgetHandler(logging.Handler): - """Custom logging handler for showing log in text browser widget.""" +class ASortFilterProxyModel(qt.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sort(0, qt.Qt.AscendingOrder) + self.setSortCaseSensitivity(qt.Qt.CaseInsensitive) + self.setFilterCaseSensitivity(qt.Qt.CaseInsensitive) - def __init__(self, callback, *args, **kwargs): + def filterAcceptsRow(self, sourceRow, sourceParent): + if self.sourceModel is None: + return True + + model = sourceParent.model() + if model is not None: + parentItem = model.item(sourceParent.row()) + + if parentItem.isEnabled(): + return True + + return qt.QSortFilterProxyModel.filterAcceptsRow(self, sourceRow, sourceParent) + + +class ATreeViewItem(qt.QStandardItem): + def __init__(self, data: Union[TestCaseData, TestSuiteData], *args, **kwargs): super().__init__(*args, **kwargs) - formatter = logging.Formatter("%(message)s%(end)s") - self.setFormatter(formatter) - self.__callback = callback + assert data is not None + self._testData = None + self.testData = data - def emit(self, record): - message = self.format(record) + @property + def testData(self): + return self._testData - if hasattr(self, "end"): - self.terminator = "" + @testData.setter + def testData(self, data): + if self._testData is not None: + self._testData.enablement_changed.disconnect(self.onEnablementChanged) - if self.__callback is not None: - self.__callback(message) + self._testData = data + self._testData.enablement_changed.connect(self.onEnablementChanged) + self._setup() -class TestSuiteDataWidgetItem(qt.QTreeWidgetItem): - def __init__(self, data: TestSuiteData, *args, **kwargs): - qt.QTreeWidgetItem.__init__(self, *args, **kwargs) - qt.QObject.__init__(self, *args, **kwargs) - self.__test_suite_data = None - self.__test_case_data_widget_list = [] - self.test_suite_data = data - - def __setup(self): - self.setText(0, self.__test_suite_data.name) + def _setup(self): + self.setEditable(False) + self.setText(self.testData.name) + checkState = qt.Qt.Checked if self.testData.enabled else qt.Qt.Unchecked self.setFlags(self.flags() | qt.Qt.ItemIsUserCheckable | qt.Qt.ItemIsSelectable) - check_state = qt.Qt.Checked if self.__test_suite_data.enabled else qt.Qt.Unchecked - self.setCheckState(0, check_state) + self.setCheckState(checkState) - def set_check_box_enabled(self, state: bool): + def setCheckBoxEnabled(self, state: bool): if state: self.setFlags(self.flags() | qt.Qt.ItemIsEnabled) else: self.setFlags(self.flags() & ~qt.Qt.ItemIsEnabled) - for test_case_widget in self.__test_case_data_widget_list: - test_case_widget.set_check_box_enabled(state) + def onEnablementChanged(self, state: bool): + checkState = qt.Qt.Checked if state is True else qt.Qt.Unchecked + self.setCheckState(checkState) - @property - def test_case_data_widget_list(self): - return self.__test_case_data_widget_list - @property - def test_suite_data(self): - return self.__test_suite_data +class ATreeView(qt.QTreeView): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.setHeaderHidden(True) + self.standardItemModel = qt.QStandardItemModel() + self.sortModel = ASortFilterProxyModel(self) + self.sortModel.setSourceModel(self.standardItemModel) + self.setModel(self.sortModel) + self.standardItemModel.itemChanged.connect(self.__onTreeViewItemClicked) + + def clear(self) -> None: + self.standardItemModel.clear() + + def setFilterRegularExpression(self, text) -> None: + self.sortModel.setFilterRegularExpression(text) + + def appendItem(self, item: ATreeViewItem) -> None: + self.standardItemModel.invisibleRootItem().appendRow(item) + + def selectAll(self) -> None: + for item in self.__items(): + item.testData.enabled = True + + def unselectAll(self) -> None: + for item in self.__items(): + item.testData.enabled = False + + def __items(self): + totalRowsDisplayed = self.sortModel.rowCount() + + for i in range(totalRowsDisplayed): + modelIndex = self.sortModel.index(i, 0) + modelIndex2 = self.sortModel.mapToSource(modelIndex) + item = self.standardItemModel.itemFromIndex(modelIndex2) + if item is None: + continue - @test_suite_data.setter - def test_suite_data(self, data: TestSuiteData): - if self.__test_suite_data is not None: - self.__test_suite_data.enablement_changed.disconnect(self.on_enablement_changed) + yield item - for test_case_widget in self.__test_case_data_widget_list: - test_case_widget.blockSignals(True) - del test_case_widget + def __onTreeViewItemClicked(self, item): + itemCheckState = item.checkState() - for test_case_data in data.test_case_data_list: - child_item = TestCaseDataWidgetItem(test_case_data) - self.__test_case_data_widget_list.append(child_item) - self.addChild(child_item) + if item is None: + raise RuntimeError("Selected item data wasn't found in the current list. Please restart the application.") - self.__test_suite_data = data - self.__test_suite_data.enablement_changed.connect(self.on_enablement_changed) - self.__setup() + item.testData.enabled = True if itemCheckState == qt.Qt.Checked else False - def on_enablement_changed(self, state): - check_state = qt.Qt.Checked if state is True else qt.Qt.Unchecked - self.setCheckState(0, check_state) - self.setExpanded(state) + def filteredItemsCount(self): + return self.sortModel.rowCount() -class TestCaseDataWidgetItem(qt.QTreeWidgetItem): +class TestCaseTreeViewItem(ATreeViewItem): def __init__(self, data: TestCaseData, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__test_case_data = None - self.test_case_data = data + super().__init__(data, *args, **kwargs) - def __setup(self): - self.setText(0, self.__test_case_data.name) - self.setFlags(self.flags() | qt.Qt.ItemIsUserCheckable | qt.Qt.ItemIsSelectable) - check_state = qt.Qt.Checked if self.__test_case_data.enabled else qt.Qt.Unchecked - self.setCheckState(0, check_state) - self.setToolTip(0, self.__test_case_data.name) - def set_check_box_enabled(self, state: bool): - if state: - self.setFlags(self.flags() | qt.Qt.ItemIsEnabled) - else: - self.setFlags(self.flags() & ~qt.Qt.ItemIsEnabled) +class TestSuiteTreeViewItem(ATreeViewItem): + def __init__(self, data: TestSuiteData, *args, **kwargs): + super().__init__(data, *args, **kwargs) - @property - def test_case_data(self): - return self.__test_case_data + @ATreeViewItem.testData.setter + def testData(self, data: TestSuiteData): + if self._testData is not None: + self._testData.enablement_changed.disconnect(self.onEnablementChanged) + + self.removeRows(0, self.rowCount()) - @test_case_data.setter - def test_case_data(self, data: TestCaseData): - if self.__test_case_data is not None: - self.__test_case_data.enablement_changed.disconnect(self.on_enablement_changed) + for testCaseData in data.test_case_data_list: + childItem = TestCaseTreeViewItem(testCaseData) + self.appendRow(childItem) - self.__test_case_data = data - self.__test_case_data.enablement_changed.connect(self.on_enablement_changed) - self.__setup() + self._testData = data + self._testData.enablement_changed.connect(self.onEnablementChanged) + self._setup() - def on_enablement_changed(self, state): - check_state = qt.Qt.Checked if state is True else qt.Qt.Unchecked - self.setCheckState(0, check_state) + +class LogWidgetHandler(logging.Handler): + """Custom logging handler for showing log in text browser widget.""" + + def __init__(self, callback, *args, **kwargs): + super().__init__(*args, **kwargs) + formatter = logging.Formatter("%(message)s%(end)s") + self.setFormatter(formatter) + self.__callback = callback + + def emit(self, record): + message = self.format(record) + + if hasattr(self, "end"): + self.terminator = "" + + if self.__callback is not None: + self.__callback(message) class LTraceTestsWidget(qt.QDialog): - def __init__(self, parent=None, current_module=None, *args, **kwargs): + TEST_TAB = 0 + GENERATE_TAB = 1 + + def __init__(self, parent=None, currentModule=None, *args, **kwargs): super().__init__(parent, *args, **kwargs) + loadAllModules() self.__model = LTraceTestsModel(test_source=TestsSource.GEOSLICER) - self.__test_suite_widget_list = [] - self.__generate_suite_widget_list = [] - self.__test_cases_count = 0 - self.__generate_cases_count = 0 - self.setup_ui() - self.__install_logger_handler() - self.__populate_tree() - self.__select_current_module_tests(current_module) - - def __select_current_module_tests(self, current_module): - if current_module is None or not hasattr(self, "tree_widget"): + self.__testSuiteItemList: List[TestSuiteTreeViewItem] = [] + self.__generateSuiteItemList: List[TestCaseTreeViewItem] = [] + self.__testCasesCount = 0 + self.__generateCasesCount = 0 + self.__setupUi() + self.__installLoggerHandler() + self.__populateTree() + self.__selectCurrentModuleTests(currentModule) + + def __selectCurrentModuleTests(self, currentModule): + if currentModule is None or not hasattr(self, "testTreeView"): return - for test_suite in self.__test_suite_widget_list: - if current_module not in test_suite.test_suite_data.name: + for testSuiteItem in self.__testSuiteItemList: + if currentModule not in testSuiteItem.testData.name: continue - test_suite.test_suite_data.enabled = True - self.tree_widget_tests.scrollToItem(test_suite) + testSuiteItem.testData.enabled = True + self.__testTreeView.scrollTo(testSuiteItem.index()) break - def setup_ui(self): + def __setupUi(self): self.setMinimumSize(1080, 720) self.setWindowTitle("GeoSlicer Test GUI") self.setWindowFlags(self.windowFlags() & ~qt.Qt.WindowContextHelpButtonHint | qt.Qt.WindowMinMaxButtonsHint) # Options layout - ## Test sources checkbox - self.__geoslicer_test_source_check_box = qt.QCheckBox("GeoSlicer") - self.__slicer_test_source_check_box = qt.QCheckBox("Slicer") - self.__slicer_test_source_check_box.setEnabled(False) # Disable it because its not functional yet - test_source_layout = qt.QHBoxLayout() - test_source_layout.addWidget(qt.QLabel("Source:")) - test_source_layout.addSpacing(10) - test_source_layout.addWidget(self.__geoslicer_test_source_check_box) - test_source_layout.addWidget(self.__slicer_test_source_check_box) - test_source_layout.addStretch() + self.__searchLineEdit = qt.QLineEdit() + self.__searchLineEdit.setPlaceholderText("Search") ## Select/Unselect tests cases buttons - self.__select_all_items_button = qt.QPushButton("Select all") - self.__unselect_all_items_button = qt.QPushButton("Unselect all") - self.__total_tests_label = qt.QLabel(f"{self.test_cases_count}") - - select_buttons_layout = qt.QHBoxLayout() - select_buttons_layout.addWidget(qt.QLabel("Total tests:")) - select_buttons_layout.addSpacing(5) - select_buttons_layout.addWidget(self.__total_tests_label) - select_buttons_layout.addStretch() - select_buttons_layout.addWidget(self.__select_all_items_button) - select_buttons_layout.addWidget(self.__unselect_all_items_button) - - ## Checkboxes default values - self.__geoslicer_test_source_check_box.setChecked(True) - self.__slicer_test_source_check_box.setChecked(False) + self.__selectAllItemsButton = qt.QPushButton("Select all") + self.__unselectAllItemsButton = qt.QPushButton("Unselect all") + self.__totalTestsLabel = qt.QLabel(f"{self.testCasesCount}") + + selectButtonsLayout = qt.QHBoxLayout() + selectButtonsLayout.addWidget(qt.QLabel("Total tests:")) + selectButtonsLayout.addSpacing(5) + selectButtonsLayout.addWidget(self.__totalTestsLabel) + selectButtonsLayout.addStretch() + selectButtonsLayout.addWidget(self.__selectAllItemsButton) + selectButtonsLayout.addWidget(self.__unselectAllItemsButton) ## Tree widget of tests - self.tree_widget_tests = qt.QTreeWidget() - self.tree_widget_tests.setColumnCount(1) - self.tree_widget_tests.setHeaderHidden(True) + self.__testTreeView = ATreeView() + self.__generateTreeView = ATreeView() - ## Tree widget of template generation - self.tree_widget_generate = qt.QTreeWidget() - self.tree_widget_generate.setColumnCount(1) - self.tree_widget_generate.setHeaderHidden(True) - - self.tree_widget_tabs = qt.QTabWidget() - self.tree_widget_tabs.addTab(self.tree_widget_tests, "Tests") - self.tree_widget_tabs.addTab(self.tree_widget_generate, "Generate") - self.tree_widget_tabs.currentChanged.connect(self.__on_tab_changed) + self.__treeWidgetTabs = qt.QTabWidget() + self.__treeWidgetTabs.addTab(self.__testTreeView, "Tests") + self.__treeWidgetTabs.addTab(self.__generateTreeView, "Generate") + self.__treeWidgetTabs.setEnabled(True) ## Options group box - options_form_layout = qt.QFormLayout() - options_form_layout.setLabelAlignment(qt.Qt.AlignRight) + optionsFormLayout = qt.QFormLayout() + optionsFormLayout.setLabelAlignment(qt.Qt.AlignRight) - self.__shuffle_check_box = qt.QCheckBox() - self.__break_on_failure_check_box = qt.QCheckBox() - self.__clear_scene_after_tests_check_box = qt.QCheckBox() + self.__shuffleCheckBox = qt.QCheckBox() + self.__breakOnFailureCheckBox = qt.QCheckBox() + self.__clearSceneAfterTestsCheckBox = qt.QCheckBox() - self.__shuffle_check_box.setToolTip("Run tests cases in random order if activated.") - self.__break_on_failure_check_box.setToolTip("Stop test run after an failure if activated") - self.__clear_scene_after_tests_check_box.setToolTip( + self.__shuffleCheckBox.setToolTip("Run tests cases in random order if activated.") + self.__breakOnFailureCheckBox.setToolTip("Stop test run after an failure if activated") + self.__clearSceneAfterTestsCheckBox.setToolTip( "Clear scene data after the last test run if activated. " + "When inactivated, it helps to analyse scene data when a failure occurs. " + "Works best with 'break on failure' option." ) - self.__shuffle_check_box.setChecked(qt.Qt.Checked) - self.__break_on_failure_check_box.setChecked(qt.Qt.Unchecked) - self.__clear_scene_after_tests_check_box.setChecked(qt.Qt.Checked) + self.__shuffleCheckBox.setChecked(qt.Qt.Checked) + self.__breakOnFailureCheckBox.setChecked(qt.Qt.Unchecked) + self.__clearSceneAfterTestsCheckBox.setChecked(qt.Qt.Checked) - options_form_layout.addRow("Shuffle", self.__shuffle_check_box) - options_form_layout.addRow("Break on failure", self.__break_on_failure_check_box) - options_form_layout.addRow("Clear scene after test", self.__clear_scene_after_tests_check_box) + optionsFormLayout.addRow("Shuffle", self.__shuffleCheckBox) + optionsFormLayout.addRow("Break on failure", self.__breakOnFailureCheckBox) + optionsFormLayout.addRow("Clear scene after test", self.__clearSceneAfterTestsCheckBox) - options_group_box = qt.QGroupBox("Options") - options_group_box.setAlignment(qt.Qt.AlignCenter) - options_group_box.setLayout(options_form_layout) + optionsGroupBox = qt.QGroupBox("Options") + optionsGroupBox.setAlignment(qt.Qt.AlignHCenter) + optionsGroupBox.setLayout(optionsFormLayout) - options_layout = qt.QVBoxLayout() - options_layout.addLayout(test_source_layout, 1) - options_layout.addLayout(select_buttons_layout, 1) - options_layout.addWidget(self.tree_widget_tabs, 4) - options_layout.addWidget(options_group_box, 1) + optionsLayout = qt.QVBoxLayout() + optionsLayout.addLayout(selectButtonsLayout, 1) + optionsLayout.addWidget(self.__searchLineEdit, 4) + optionsLayout.addWidget(self.__treeWidgetTabs, 4) + optionsLayout.addWidget(optionsGroupBox, 1) # Logging layout - self.__log_text_browser = qt.QTextBrowser() - logging_layout = qt.QVBoxLayout() - logging_layout.addWidget(self.__log_text_browser) - logging_group_box = qt.QGroupBox("Logs") - logging_group_box.setAlignment(qt.Qt.AlignCenter) - logging_group_box.setLayout(logging_layout) + self.__logTextBrowser = qt.QTextBrowser() + loggingLayout = qt.QVBoxLayout() + loggingLayout.addWidget(self.__logTextBrowser) + loggingGroupBox = qt.QGroupBox("Logs") + loggingGroupBox.setAlignment(qt.Qt.AlignCenter) + loggingGroupBox.setLayout(loggingLayout) # Run layout ## Progress bar - self.__progress_bar = qt.QProgressBar() - self.__progress_bar.setVisible(False) + self.__progressBar = qt.QProgressBar() + self.__progressBar.setVisible(False) - progress_bar_layout = qt.QVBoxLayout() - progress_bar_layout.addWidget(self.__progress_bar) + progressBarLayout = qt.QVBoxLayout() + progressBarLayout.addWidget(self.__progressBar) ## Run & Cancel button - self.__run_button = qt.QPushButton("Run") - self.__run_button.setIcon(qt.QIcon(str(RESOURCES_DIR / "play_button.png"))) + self.__runButton = qt.QPushButton("Run") + self.__runButton.setIcon(qt.QIcon(str(RESOURCES_DIR / "play_button.png"))) - self.__cancel_button = qt.QPushButton("Cancel") - self.__cancel_button.setIcon(qt.QIcon(str(RESOURCES_DIR / "stop_button.png"))) - self.__cancel_button.setVisible(False) + self.__cancelButton = qt.QPushButton("Cancel") + self.__cancelButton.setIcon(qt.QIcon(str(RESOURCES_DIR / "stop_button.png"))) + self.__cancelButton.setVisible(False) - run_cancel_button_layout = qt.QVBoxLayout() - run_cancel_button_layout.addWidget(self.__run_button) - run_cancel_button_layout.addWidget(self.__cancel_button) + runCancelButtonLayout = qt.QVBoxLayout() + runCancelButtonLayout.addWidget(self.__runButton) + runCancelButtonLayout.addWidget(self.__cancelButton) # Main layout layout = qt.QGridLayout() - layout.addLayout(options_layout, 0, 0, 4, 2) - layout.addWidget(logging_group_box, 0, 2, 4, 4) - layout.addLayout(progress_bar_layout, 5, 0, 1, 6) - layout.addLayout(run_cancel_button_layout, 6, 2, 1, 2) + layout.addLayout(optionsLayout, 0, 0, 4, 2) + layout.addWidget(loggingGroupBox, 0, 2, 4, 4) + layout.addLayout(progressBarLayout, 5, 0, 1, 6) + layout.addLayout(runCancelButtonLayout, 6, 2, 1, 2) self.setLayout(layout) # connections - self.__run_button.clicked.connect(self.__on_run_button_clicked) - self.__cancel_button.clicked.connect(self.__on_cancel_button_clicked) - self.tree_widget_tests.itemClicked.connect(self.__on_tree_widget_item_clicked) - self.tree_widget_generate.itemClicked.connect(self.__on_tree_widget_item_clicked) - self.__geoslicer_test_source_check_box.stateChanged.connect(self.__on_test_source_checkbox_changed) - self.__slicer_test_source_check_box.stateChanged.connect(self.__on_test_source_checkbox_changed) - self.__select_all_items_button.clicked.connect(self.__on_select_all_button_clicked) - self.__unselect_all_items_button.clicked.connect(self.__on_unselect_all_button_clicked) - - def __on_tab_changed(self, state: bool): - cases_count = self.__test_cases_count if state == 0 else self.__generate_cases_count - self.__total_tests_label.setText(f"{cases_count}") - - def __set_all_tree_widget_item_enable(self, state: bool): - tab_index = self.tree_widget_tabs.currentIndex - widget_list = self.__test_suite_widget_list if tab_index == 0 else self.__generate_suite_widget_list - for test_suite_widget in widget_list: - test_suite_widget.set_check_box_enabled(state) - - def __on_select_all_button_clicked(self, state): - tab_index = self.tree_widget_tabs.currentIndex - widget_list = self.__test_suite_widget_list if tab_index == 0 else self.__generate_suite_widget_list - for test_suite_widget in widget_list: - test_suite_widget.test_suite_data.enabled = True - - def __on_unselect_all_button_clicked(self, state): - tab_index = self.tree_widget_tabs.currentIndex - widget_list = self.__test_suite_widget_list if tab_index == 0 else self.__generate_suite_widget_list - for test_suite_widget in widget_list: - test_suite_widget.test_suite_data.enabled = False + self.__runButton.clicked.connect(self.__onRunButtonClicked) + self.__cancelButton.clicked.connect(self.__onCancelButtonClicked) + self.__selectAllItemsButton.clicked.connect(self.__onSelectAllButtonClicked) + self.__unselectAllItemsButton.clicked.connect(self.__onUnselectAllButtonClicked) + self.__searchLineEdit.textChanged.connect(self.__onSearchTextChanged) + self.__treeWidgetTabs.currentChanged.connect(self.__onTabChanged) + + # Update search input based on the last stored value + lastSearchInput = slicer.app.settings().value("LTraceTestsWidget/LastSearch", "") + if lastSearchInput: + self.__searchLineEdit.setText(lastSearchInput) + + def __onSearchTextChanged(self, text): + self.__testTreeView.setFilterRegularExpression(text) + self.__generateTreeView.setFilterRegularExpression(text) + + isValid = self.__testTreeView.filteredItemsCount() > 0 or self.__generateTreeView.filteredItemsCount() > 0 + searchQuery = text if isValid else "" + slicer.app.settings().setValue("LTraceTestsWidget/LastSearch", searchQuery) + + def __onTabChanged(self, state: bool): + casesCount = self.__testCasesCount if state == self.TEST_TAB else self.__generateCasesCount + self.__totalTestsLabel.setText(f"{casesCount}") + + def __setAllTreeWidgetItemEnable(self, state: bool): + tabIndex = self.__treeWidgetTabs.currentIndex + suiteItemList = self.__testSuiteItemList if tabIndex == self.TEST_TAB else self.__generateSuiteItemList + for testSuiteItem in suiteItemList: + testSuiteItem.setCheckBoxEnabled(state) + + def __onSelectAllButtonClicked(self, state): + tree = self.__getCurrentTree() + tree.selectAll() + + def __onUnselectAllButtonClicked(self, state): + tree = self.__getCurrentTree() + tree.unselectAll() + + def __getCurrentTree(self) -> ATreeView: + return self.__testTreeView if self.__treeWidgetTabs.currentIndex == self.TEST_TAB else self.__generateTreeView @property - def test_cases_count(self): - return self.__test_cases_count + def testCasesCount(self): + return self.__testCasesCount - @test_cases_count.setter - def test_cases_count(self, value): - self.__test_cases_count = value - self.__total_tests_label.setText(f"{self.__test_cases_count}") + @testCasesCount.setter + def testCasesCount(self, value): + self.__testCasesCount = value + self.__totalTestsLabel.setText(f"{self.__testCasesCount}") @property - def generate_cases_count(self): - return self.__generate_cases_count + def generateCasesCount(self): + return self.__generateCasesCount - @generate_cases_count.setter - def generate_cases_count(self, value): - self.__generate_cases_count = value - self.__total_tests_label.setText(f"{self.__generate_cases_count}") + @generateCasesCount.setter + def generateCasesCount(self, value): + self.__generateCasesCount = value + self.__totalTestsLabel.setText(f"{self.__generateCasesCount}") def accept(self): qt.QDialog.accept(self) - self.__uninstall_logger_handler() + self.uninstallLoggerHandler() def reject(self): qt.QDialog.reject(self) - self.__uninstall_logger_handler() + self.uninstallLoggerHandler() - def __logger_callback(self, message): - if self.__log_text_browser is None: + def loggerCallback(self, message): + if self.__logTextBrowser is None: return - self.__log_text_browser.insertPlainText(message) + self.__logTextBrowser.insertPlainText(message) - def __install_logger_handler(self): - self.__logger_handler = LogWidgetHandler(callback=self.__logger_callback) - logging.getLogger("tests_logger").addHandler(self.__logger_handler) + def __installLoggerHandler(self): + self.loggerHandler = LogWidgetHandler(callback=self.loggerCallback) + logging.getLogger("tests_logger").addHandler(self.loggerHandler) - def __uninstall_logger_handler(self): - if not hasattr(self, "__logger_handler") or self.__logger_handler is None: + def uninstallLoggerHandler(self): + if not hasattr(self, "loggerHandler") or self.loggerHandler is None: return - logging.getLogger("tests_logger").removeHandler(self.__logger_handler) + logging.getLogger("tests_logger").removeHandler(self.loggerHandler) - def __populate_tree(self): - self.tree_widget_tests.clear() - self.tree_widget_generate.clear() - self.__test_suite_widget_list.clear() - self.__generate_suite_widget_list.clear() - self.test_cases_count = 0 - self.generate_cases_count = 0 + def __populateTree(self): + self.__testTreeView.clear() + self.__generateTreeView.clear() + self.__testSuiteItemList.clear() + self.__generateSuiteItemList.clear() + self.testCasesCount = 0 + self.generateCasesCount = 0 for test_suite in self.__model.test_suite_list: - test_suite_widget_item = TestSuiteDataWidgetItem(data=test_suite) - test_suite_widget_item.test_suite_data.test_case_enablement_changed.connect( - self.__on_test_case_enablement_changed - ) - self.__test_suite_widget_list.append(test_suite_widget_item) - self.tree_widget_tests.addTopLevelItem(test_suite_widget_item) + testSuiteItem = TestSuiteTreeViewItem(data=test_suite) + testSuiteItem.testData.test_case_enablement_changed.connect(self.__onTestCaseEnablementChanged) + self.__testSuiteItemList.append(testSuiteItem) + + self.__testTreeView.appendItem(testSuiteItem) for test_suite in self.__model.generate_suite_list: - test_suite_widget_item = TestSuiteDataWidgetItem(data=test_suite) - test_suite_widget_item.test_suite_data.test_case_enablement_changed.connect( - self.__on_generate_case_enablement_changed - ) - self.__generate_suite_widget_list.append(test_suite_widget_item) - self.tree_widget_generate.addTopLevelItem(test_suite_widget_item) + testSuiteItem = TestSuiteTreeViewItem(data=test_suite) + testSuiteItem.testData.test_case_enablement_changed.connect(self.__onGenerateCaseEnablementChanged) + self.__generateSuiteItemList.append(testSuiteItem) - def __on_test_case_enablement_changed(self, state: bool): - value = 1 if state else -1 - self.test_cases_count = self.test_cases_count + value + self.__generateTreeView.appendItem(testSuiteItem) - def __on_generate_case_enablement_changed(self, state: bool): + def __onTestCaseEnablementChanged(self, state: bool): value = 1 if state else -1 - self.generate_cases_count = self.generate_cases_count + value + self.testCasesCount = self.testCasesCount + value - def __on_test_source_checkbox_changed(self, state): - selected_test_source = TestsSource.ANY - if self.__geoslicer_test_source_check_box.isChecked() and not self.__slicer_test_source_check_box.isChecked(): - selected_test_source = TestsSource.GEOSLICER - elif not self.__geoslicer_test_source_check_box.isChecked() and self.__slicer_test_source_check_box.isChecked(): - selected_test_source = TestsSource.SLICER - - if selected_test_source == self.__model.test_source: - return - - self.__model.test_source = selected_test_source - self.__populate_tree() + def __onGenerateCaseEnablementChanged(self, state: bool): + value = 1 if state else -1 + self.generateCasesCount = self.generateCasesCount + value - def __on_run_button_clicked(self, state): + def __onRunButtonClicked(self, state): if self.__model.is_running: return - selected_tab = self.tree_widget_tabs.currentIndex + selectedTab = self.__treeWidgetTabs.currentIndex + casesCount = self.testCasesCount if selectedTab == self.TEST_TAB else self.generateCasesCount - if selected_tab == 0: - cases_count = self.test_cases_count - else: - cases_count = self.generate_cases_count - - if cases_count <= 0: + if casesCount <= 0: slicer.util.infoDisplay("Please select a test.", parent=self) return - self.__log_text_browser.clear() + self.__logTextBrowser.clear() # Disable widgets - self.__set_all_tree_widget_item_enable(False) - self.__run_button.setEnabled(False) + self.__setAllTreeWidgetItemEnable(False) + self.__runButton.setEnabled(False) qt.QTimer.singleShot( - 1000, lambda: self.__cancel_button.setEnabled(True) + 1000, lambda: self.__cancelButton.setEnabled(True) ) # Avoid missclick after starting the test process. - self.__select_all_items_button.setEnabled(False) - self.__unselect_all_items_button.setEnabled(False) - self.__model.test_case_finished.connect(self.__on_test_case_finished) + self.__selectAllItemsButton.setEnabled(False) + self.__unselectAllItemsButton.setEnabled(False) + self.__model.test_case_finished.connect(self.__onTestCaseFinished) # Start progress bar - self.__progress_bar.reset() - self.__progress_bar.setRange(0, cases_count) - self.__progress_bar.setValue(0) - self.__progress_bar.setVisible(True) + self.__progressBar.reset() + self.__progressBar.setRange(0, casesCount) + self.__progressBar.setValue(0) + self.__progressBar.setVisible(True) # Update run/cancel buttons - self.__cancel_button.setVisible(True) - self.__run_button.setVisible(False) + self.__cancelButton.setVisible(True) + self.__runButton.setVisible(False) + self.__treeWidgetTabs.setEnabled(False) + + suiteList = self.__model.test_suite_list if selectedTab == self.TEST_TAB else self.__model.generate_suite_list + # Disable cases from the other suite list (other tab) + otherSuiteList = ( + self.__model.test_suite_list if selectedTab != self.TEST_TAB else self.__model.generate_suite_list + ) + for suiteData in otherSuiteList: + suiteData.enabled = False # Start running tests self.__model.run_tests( - suite_list=self.__model.test_suite_list if selected_tab == 0 else self.__model.generate_suite_list, - shuffle=self.__shuffle_check_box.isChecked(), - break_on_failure=self.__break_on_failure_check_box.isChecked(), - after_clear=self.__clear_scene_after_tests_check_box.isChecked(), + suite_list=suiteList, + shuffle=self.__shuffleCheckBox.isChecked(), + break_on_failure=self.__breakOnFailureCheckBox.isChecked(), + after_clear=self.__clearSceneAfterTestsCheckBox.isChecked(), ) test_process_result = self.__model.result() @@ -440,65 +471,45 @@ def __on_run_button_clicked(self, state): ) # User feedback - for test_suite_data in self.__model.test_suite_list: + for test_suite_data in self.__model.test_suite_list + self.__model.generate_suite_list: if warning_text := test_suite_data.warning_log_text: log(warning_text) if failure_text := test_suite_data.failure_log_text: log(failure_text) # Reset progress bar - self.__progress_bar.setValue(cases_count) - self.__progress_bar.setVisible(False) + self.__progressBar.setValue(casesCount) + self.__progressBar.setVisible(False) # Remove connections - self.__model.test_case_finished.disconnect(self.__on_test_case_finished) + self.__model.test_case_finished.disconnect(self.__onTestCaseFinished) # Re-enable widgets - self.__set_all_tree_widget_item_enable(True) - self.__cancel_button.setVisible(False) - self.__cancel_button.setEnabled(False) - self.__run_button.setEnabled(True) - self.__run_button.setVisible(True) - self.__select_all_items_button.setEnabled(True) - self.__unselect_all_items_button.setEnabled(True) - - def __on_cancel_button_clicked(self, state): + self.__setAllTreeWidgetItemEnable(True) + self.__cancelButton.setVisible(False) + self.__cancelButton.setEnabled(False) + self.__runButton.setEnabled(True) + self.__runButton.setVisible(True) + self.__selectAllItemsButton.setEnabled(True) + self.__unselectAllItemsButton.setEnabled(True) + self.__treeWidgetTabs.setEnabled(True) + + def __onCancelButtonClicked(self, state): if not self.__model.is_running: return - self.__cancel_button.setEnabled(False) - self.__cancel_button.setText("Cancelling...") - self.__model.tests_cancelled.connect(self.__on_test_cancelled) + self.__cancelButton.setEnabled(False) + self.__cancelButton.setText("Cancelling...") + self.__model.tests_cancelled.connect(self.onTestCancelled) self.__model.cancel() - def __on_test_cancelled(self): - self.__cancel_button.setVisible(False) - self.__cancel_button.setEnabled(False) - self.__cancel_button.setText("Cancel") - self.__run_button.setEnabled(True) - self.__run_button.setVisible(True) + def onTestCancelled(self): + self.__cancelButton.setVisible(False) + self.__cancelButton.setEnabled(False) + self.__cancelButton.setText("Cancel") + self.__runButton.setEnabled(True) + self.__runButton.setVisible(True) self.__model.tests_cancelled.disconnect() - def __on_test_case_finished(self, test_suite_data, test_case_data): - self.__progress_bar.setValue(self.__progress_bar.value + 1) - - def __on_tree_widget_item_clicked(self, item, column): - item_check_state = item.checkState(0) - - test_item = None - for test_suite_widget in self.__test_suite_widget_list + self.__generate_suite_widget_list: - if test_suite_widget == item: - test_item = test_suite_widget - break - for test_case_widget in test_suite_widget.test_case_data_widget_list: - if test_case_widget == item: - test_item = test_case_widget - break - - if test_item is None: - raise RuntimeError("Selected item data wasn't found in current state. Please restart the application.") - - if isinstance(test_item, TestSuiteDataWidgetItem): - test_item.test_suite_data.enabled = True if item_check_state == qt.Qt.Checked else False - else: - test_item.test_case_data.enabled = True if item_check_state == qt.Qt.Checked else False + def __onTestCaseFinished(self, testSuiteData, testCaseData): + self.__progressBar.setValue(self.__progressBar.value + 1) diff --git a/src/ltrace/ltrace/slicer/tests/mocks/__init__.py b/src/ltrace/ltrace/slicer/tests/mocks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ltrace/ltrace/slicer/tests/mocks/mock_qt_widgets.py b/src/ltrace/ltrace/slicer/tests/mocks/mock_qt_widgets.py new file mode 100644 index 0000000..bd1c589 --- /dev/null +++ b/src/ltrace/ltrace/slicer/tests/mocks/mock_qt_widgets.py @@ -0,0 +1,23 @@ +from typing import List +from unittest.mock import patch, MagicMock + +import contextlib + + +@contextlib.contextmanager +def mockFileDialog(selectedFiles: List[str] = None, getSaveFileName: str = None, cancel=False) -> None: + + fileDialogClsMock = MagicMock() + fileDialogMock = MagicMock() + fileDialogMock.selectedFiles.return_value = selectedFiles + fileDialogMock.exec.return_value = 0 if cancel else 1 + + fileDialogClsMock.return_value = fileDialogMock + fileDialogClsMock.getSaveFileName.return_value = getSaveFileName + + patcher = patch("qt.QFileDialog", fileDialogClsMock) + patcher.start() + + yield + + patcher.stop() diff --git a/src/ltrace/ltrace/slicer/tests/test_case.py b/src/ltrace/ltrace/slicer/tests/test_case.py index 991bfb4..3800ae3 100644 --- a/src/ltrace/ltrace/slicer/tests/test_case.py +++ b/src/ltrace/ltrace/slicer/tests/test_case.py @@ -42,6 +42,11 @@ def __generate_name(self, function: Callable) -> None: def __call__(self, *args, **kwargs) -> None: self.run() + def reset(self): + self.status = TestState.NOT_INITIALIZED + self.reason = "" + self.elapsed_time_sec = 0 + def run(self) -> None: self.status = TestState.RUNNING with self.__exception_hook_patch: diff --git a/src/ltrace/ltrace/slicer/tests/utils.py b/src/ltrace/ltrace/slicer/tests/utils.py index d8309c7..d5cefe3 100644 --- a/src/ltrace/ltrace/slicer/tests/utils.py +++ b/src/ltrace/ltrace/slicer/tests/utils.py @@ -8,10 +8,11 @@ from ltrace.constants import SaveStatus from ltrace.slicer.project_manager import ProjectManager from ltrace.slicer.helpers import make_directory_writable, WatchSignal +from ltrace.slicer.module_utils import loadModules from ltrace.utils.string_comparison import StringComparison from pathlib import Path from stopit import TimeoutException -from typing import Union +from typing import Callable, List, Union TEST_LOG_FILE_PATH = Path(slicer.app.temporaryPath) / "tests.log" @@ -62,27 +63,27 @@ def wait(seconds: float) -> None: raise TimeoutError("Test timeout reached!") -def wait_cli_to_finish(cli_node, timeout_sec: int = 3600) -> None: - """Lock thread until respective CLI node is not busy anymore. +def wait_cli_to_finish(cli_entity, timeout_sec: int = 3600) -> None: + """Lock thread until respective CLI node or queue is not busy anymore. Args: - cli_node (vtkMRMLCommandLineModuleNode): the CLI node object, + cli_entity (slicer.vtkMRMLCommandLineModuleNode or ltrace.slicer.CliQueue): the CLI entity object, timeout_sec (int, optional): the timeout in seconds. Defaults to 3600 seconds. """ - if cli_node is None: + if cli_entity is None: return start = time.perf_counter() try: - while cli_node.IsBusy(): + while cli_entity.IsBusy(): time.sleep(0.200) process_events() if time.perf_counter() - start >= timeout_sec: - cli_node.Cancel() + cli_entity.Cancel() raise TimeoutError("CLI timeout reached!") except TimeoutException: - cli_node.Cancel() + cli_entity.Cancel() raise TimeoutError("Test timeout reached!") @@ -126,22 +127,27 @@ def load_project(project_file_path, timeout_ms=300000): if not path.exists(): raise ValueError("Project file not found!") + status = False with WatchSignal(signal=slicer.mrmlScene.EndImportEvent, timeout_ms=timeout_ms): - try: - slicer.util.loadScene(path.as_posix()) - except Exception: - raise RuntimeError(f"Timeout! Failed to load {path.as_posix()} project file!") + status = ProjectManager().load(path) + + return status @contextlib.contextmanager -def check_for_message_box(message, should_accept=True, timeout_sec=2, buttonTextToClick=None): +def check_for_message_box( + message, should_accept: bool = True, timeout_sec: int = 2, buttonTextToClick: bool = None, closeOthers: bool = True +): """Context manager to handle the displaying of a QMessageBox during a test scenario Args: message (str): the expected QMessageBox text should_accept (bool, optional): Accept if True, otherwise Reject the message box action. Defaults to True. timeout_sec (int, optional): Timeout to wait for the message box to appear, in seconds. Defaults to 2. - buttonTextToClick (_type_, optional): The button text to click. Defaults to None. + buttonTextToClick (bool, optional): The button text to click. Defaults to None. + closeOthers (:bool, optional): Close other visible message boxes. + It might help to disable it when chaining multiple contexts to check + for a message box. Defaults to True. Raises: AttributeError: When the QMessageBox isn't identified. @@ -156,7 +162,7 @@ def check(): if result[0] is True: return - mw = slicer.util.mainWindow() + mw = slicer.modules.AppContextInstance.mainWindow message_boxes = mw.findChildren(qt.QMessageBox) the_message_box = None @@ -168,7 +174,9 @@ def check(): # Find possible visible QMessageBox and close it to avoid freezing the test process other_message_boxes = [msg_box for msg_box in message_boxes if msg_box.visible == True] for message_box in other_message_boxes: - message_box.close() + logging.debug(f"A different message box had appeared with the text: '{message_box.text}'") + if closeOthers: + message_box.close() if time.perf_counter() - start_time < timeout_sec: timer.start() @@ -190,6 +198,7 @@ def check(): the_button = button if the_button is None: + logging.error(f"The message box button {buttonTextToClick} doesn't exist in the current context.") the_message_box.reject() else: the_button.click() @@ -235,10 +244,11 @@ def save_project(project_path: Union[str, Path], timeout_ms=300000, properties=N elif project_path.is_dir(): shutil.rmtree(project_path, onerror=make_directory_writable) - project_manager = ProjectManager(folder_icon_path="") - + status = False with WatchSignal(signal=slicer.mrmlScene.EndImportEvent, timeout_ms=timeout_ms): - return project_manager.save_as(project_path.parent, properties=properties) == SaveStatus.SUCCEED + status = ProjectManager().saveAs(project_path.parent, properties=properties) == SaveStatus.SUCCEED + + return status def save_current_project(timeout_ms=300000, properties=None) -> bool: @@ -257,17 +267,17 @@ def save_current_project(timeout_ms=300000, properties=None) -> bool: if properties is None or not isinstance(properties, dict): properties = {} - project_manager = ProjectManager(folder_icon_path="") - + status = False with WatchSignal(signal=slicer.mrmlScene.EndSaveEvent, timeout_ms=timeout_ms): - return project_manager.save(url, properties=properties) == SaveStatus.SUCCEED + status = ProjectManager().save(url, properties=properties) == SaveStatus.SUCCEED + + return status def close_project(timeout_ms: int = 300000) -> None: with WatchSignal(signal=slicer.mrmlScene.EndCloseEvent, timeout_ms=timeout_ms): - slicer.mrmlScene.Clear(0) + ProjectManager().close() - slicer.mrmlScene.EndState(slicer.mrmlScene.CloseState) process_events() @@ -275,3 +285,58 @@ def compare_ignore_line_endings(file1, file2): with open(file1, "r") as f1: with open(file2, "r") as f2: return f1.read().replace("\r\n", "\n") == f2.read().replace("\r\n", "\n") + + +def handleFileDialog(accept: bool = True, directory: Path = None, projectName: str = None) -> None: + fileDialog = None + + existentFileDialogs = [widget for widget in slicer.app.topLevelWidgets() if isinstance(widget, qt.QFileDialog)] + if len(existentFileDialogs) == 0: + raise RuntimeError("File dialog wasn't detected.") + elif len(existentFileDialogs) > 1: + logging.warning(f"Found {len(existentFileDialogs)} file dialogs. Check if this is the expected behavior.") + + visibleFileDialogs = [widget for widget in existentFileDialogs if widget.visible is True] + + if len(visibleFileDialogs) == 0: + raise RuntimeError("A file dialog is instancied but wasn't visible.") + + fileDialog = visibleFileDialogs[0] + + if not accept: + fileDialog.reject() + return + + wait(1) + fileDialog.setDirectory(qt.QDir(directory.as_posix())) + fileNameEdit = fileDialog.findChild("QLineEdit") + fileNameEdit.setText(projectName) + wait(0.2) + fileDialog.accept() + wait(0.2) + + +def loadEnvironmentByName(displayName): + slicer.modules.AppContextInstance.modules.loadEnvironmentByName( + slicer.util.mainWindow().findChild("QToolBar", "ModuleToolBar"), displayName + ) + + +def loadAllModules(): + groups = slicer.modules.AppContextInstance.modules.groups + modules = {obj.key: obj for sublist in groups.values() for obj in sublist} + modules = list(modules.values()) + loadModules(modules, permanent=False, favorite=False) + + +def waitCondition(condition: Callable, timeoutSec: int = 5) -> None: + """Sleeps until a condition is met. + + Args: + condition (Callable): A function that returns True when the process is finished, otherwise it should returns False + timeoutSec (int): The time limit to wait for the condition to happen. + """ + start = time.perf_counter() + while time.perf_counter() - start < timeoutSec and not condition(): + time.sleep(0.2) + process_events() diff --git a/src/ltrace/ltrace/slicer/throat_analysis/throat_analysis.py b/src/ltrace/ltrace/slicer/throat_analysis/throat_analysis.py index a36120f..1bde753 100644 --- a/src/ltrace/ltrace/slicer/throat_analysis/throat_analysis.py +++ b/src/ltrace/ltrace/slicer/throat_analysis/throat_analysis.py @@ -199,12 +199,12 @@ def __generate_statistics(self, boundaries_array, params): SegmentOperator(operator, volume_operator.ijkToRasOperator), stepcb=lambda i, total: self.__progress_update_callback(i / total), ) - df.set_axis(operator.ATTRIBUTES, axis=1, inplace=True) - df.sort_values(by=["label"], ascending=True, inplace=True) + df = df.set_axis(operator.ATTRIBUTES, axis=1) + df = df.sort_values(by=["label"], ascending=True) if df.shape[1] > 1 and df.shape[0] > 0: - df.dropna(axis=1, how="all", inplace=True) # Remove unused columns - df.dropna(axis=0, how="any", inplace=True) # Remove unused columns + df = df.dropna(axis=1, how="all") # Remove unused columns + df = df.dropna(axis=0, how="any") # Remove unused columns # Relabel array due to report handling indices = np.array(df.label, copy=True, dtype=int) @@ -252,7 +252,7 @@ def __generate_ids(self, boundaries_label_image, input_label_image): id_list = rename_duplicated_ids(id_list) df = pd.DataFrame({"id": id_list, "label": label_list}) - df.sort_values(by=["label"], ascending=True, inplace=True) + df = df.sort_values(by=["label"], ascending=True) return df["id"].tolist() diff --git a/src/ltrace/ltrace/slicer/ui.py b/src/ltrace/ltrace/slicer/ui.py index b67f627..b621a6b 100644 --- a/src/ltrace/ltrace/slicer/ui.py +++ b/src/ltrace/ltrace/slicer/ui.py @@ -1,4 +1,3 @@ -import random import textwrap from functools import reduce from pathlib import Path @@ -12,13 +11,6 @@ from ltrace.slicer.helpers import getSegmentList, createLabelmapInput from ltrace.slicer.widget.global_progress_bar import LocalProgressBar -from pathlib import Path - -import ctk -import numpy as np - -from ltrace.slicer.widget.customized_pyqtgraph.GraphicsLayoutWidget import GraphicsLayoutWidget - def volumeInput(onChange=None, hasNone=False, nodeTypes=None, onActivation=None): inputSelector = slicer.qMRMLNodeComboBox() @@ -68,15 +60,22 @@ def hierarchyVolumeInput( tooltip=None, defaultText=None, showSegments=False, + allowFolders=False, ): from ltrace.slicer.widget.hierarchy_volume_input import HierarchyVolumeInput - widget = HierarchyVolumeInput(onChange or onActivation, hasNone, nodeTypes, defaultText) + widget = HierarchyVolumeInput(hasNone, nodeTypes, defaultText, allowFolders) if tooltip: widget.setToolTip(tooltip) if not showSegments: - widget.setExcludeItemAttributeNamesFilter(("segmentID",)) + widget.selectorWidget.setExcludeItemAttributeNamesFilter(("segmentID",)) + + if onChange is not None: + widget.currentItemChanged.connect(onChange) + + if onActivation is not None: # Not implemented + pass return widget @@ -279,6 +278,47 @@ def ApplyButton(onClick=None, tooltip="", text="Apply", enabled=True, object_nam return btn +class ApplyCancelButtons(qt.QWidget): + def __init__( + self, + onApplyClick=None, + onCancelClick=None, + applyTooltip="Apply", + cancelTooltip="Cancel", + applyText="Apply", + cancelText="Cancel", + enabled=True, + applyObjectName="Apply Button", + cancelObjectName="Cancel Button", + parent=None, + ): + super().__init__(parent) + + # Create Apply button + self.applyBtn = ButtonWidget(onApplyClick, applyText, applyTooltip, enabled, applyObjectName) + self.applyBtn.setProperty("class", "actionButtonBackground") + self.applyBtn.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Minimum) + + # Create Cancel button + self.cancelBtn = ButtonWidget(onCancelClick, cancelText, cancelTooltip, enabled, cancelObjectName) + self.cancelBtn.setStyleSheet("QPushButton {font-size: 11px; padding: 8px; margin: 0px}") + self.cancelBtn.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Minimum) + + # Create layout and add buttons + self.buttonLayout = qt.QHBoxLayout(self) + self.buttonLayout.addWidget(self.applyBtn) + self.buttonLayout.addWidget(self.cancelBtn) + + # Method to enable/disable both buttons at once + def setEnabled(self, enabled): + self.applyBtn.setEnabled(enabled) + self.cancelBtn.setEnabled(enabled) + + @property + def text(self): + return f"ApplyCancelButtons" + + def CheckBoxLayout(text="", tooltip="", onToggle=None): hbox = qt.QHBoxLayout() @@ -691,6 +731,8 @@ def add(self, directories=False): self.directoryListView.addDirectory(path) self.addCallback(lastPathString) + fileDialog.delete() + def numberParam(vrange, value=0.1, step=0.1, decimals=1): param = qt.QDoubleSpinBox() diff --git a/src/ltrace/ltrace/slicer/widget/custom_toolbar_buttons.py b/src/ltrace/ltrace/slicer/widget/custom_toolbar_buttons.py new file mode 100644 index 0000000..905a306 --- /dev/null +++ b/src/ltrace/ltrace/slicer/widget/custom_toolbar_buttons.py @@ -0,0 +1,161 @@ +import qt +import slicer + + +class CustomAction(qt.QWidgetAction): + # Define a custom signal that can be connected to any slot + clicked = qt.Signal(bool) + + def __init__(self, icon, text, parent=None): + super().__init__(parent) + + # Store the icon and text for later use + self.icon = icon + self.text = text # if " " in text else text + " " + + # Create a custom widget for the action + self.widget = qt.QWidget(parent) + self.layout = qt.QHBoxLayout() + self.layout.setAlignment(qt.Qt.AlignLeft) # Align content to the left initially + self.layout.setContentsMargins(0, 2, 0, 2) + + # Create a button that holds the icon and text + self.button = qt.QPushButton(parent) + self.button.setIcon(self.icon) + self.button.setFlat(True) # Make the button look like a label without borders + self.label = qt.QLabel(self.text) + self.label.visible = False + + # Connect the button's clicked signal to emit the custom clicked signal + self.button.clicked.connect(self.clicked) + + # Set up the layout + self.layout.addWidget(self.button) + self.layout.addWidget(self.label) + self.widget.setLayout(self.layout) + + # Set the custom widget as the default widget for this action + self.setDefaultWidget(self.widget) + + self.hideName() + + def showName(self): + """Switch to text beside icon (left-aligned).""" + self.label.visible = True # Show the text + + def hideName(self): + """Switch to icon only (centered).""" + self.label.visible = False + # self.layout.setAlignment(qt.Qt.AlignCenter) # Center the icon + + def toggleName(self): + """Toggle between text beside icon and icon only.""" + if self.label.visible: + self.hideName() + else: + self.showName() + + +class CustomToolButton(qt.QToolButton): + def __init__(self, parent): + super().__init__(parent) + + def showName(self): + """Switch to text beside icon (left-aligned).""" + self.setToolButtonStyle(qt.Qt.ToolButtonTextBesideIcon) + + def hideName(self): + """Switch to icon only (centered).""" + self.setToolButtonStyle(qt.Qt.ToolButtonIconOnly) + + def toggleName(self): + """Toggle between text beside icon and icon only.""" + if self.toolButtonStyle == qt.Qt.ToolButtonTextBesideIcon: + self.hideName() + else: + self.showName() + + +def addAction(module, toolbar, root="", parent=None): + if not parent: + parent = toolbar + m = getattr(slicer.modules, module.key.lower()) + button = CustomToolButton(parent) + action = qt.QAction(m.icon, m.title, parent) + action.setToolTip(m.title) + + def selectModule(): + slicer.util.selectModule(module.key) + + action.triggered.connect(selectModule) + button.setDefaultAction(action) + toolbar.addWidget(button) + return action + + +def addEntry(module, menu, parent=None): + if not parent: + parent = menu + m = getattr(slicer.modules, module.key.lower()) + action = qt.QAction(m.icon, m.title, parent) + action.triggered.connect(lambda _, name=module.key: slicer.util.selectModule(name)) + # menu.setToolButtonStyle(qt.Qt.ToolButtonTextBesideIcon) + menu.addAction(action) + + +def addMenu(icon, folder, modules, parent): + toolButton = CustomToolButton(parent) + toolButton.setIcon(icon) + toolButton.setText(folder) + toolButton.setToolTip(folder) + + menu = qt.QMenu(toolButton) + for module in modules: + addEntry(module, menu, parent) + toolButton.setMenu(menu) + + toolButton.setPopupMode(qt.QToolButton.MenuButtonPopup) + toolButton.clicked.connect(lambda _: toolButton.showMenu()) + parent.addWidget(toolButton) + + +# Future use, similar function but different name to avoid conflict + + +def addEntryRaw(module, menu, parent=None): + if not parent: + parent = menu + action = qt.QAction(module.icon, module.title, parent) + + def selectModule(): + slicer.util.selectModule(module.name) + + action.triggered.connect(selectModule) + # menu.setToolButtonStyle(qt.Qt.ToolButtonTextBesideIcon) + menu.addAction(action) + + +def addMenuRaw(icon, folder, modules, parent): + toolButton = CustomToolButton(parent) + toolButton.setIcon(icon) + toolButton.setText(folder) + toolButton.setToolTip(folder) + + menu = qt.QMenu(toolButton) + for module in modules: + addEntry(module, menu, parent) + toolButton.setMenu(menu) + + toolButton.setPopupMode(qt.QToolButton.MenuButtonPopup) + toolButton.clicked.connect(lambda _: toolButton.showMenu()) + parent.addWidget(toolButton) + + +def addActionWidget(icon, title, callback=None, parent=None): + button = CustomToolButton(parent) + action = qt.QAction(icon, title, parent) + action.setToolTip(title) + action.triggered.connect(lambda tb=parent: callback(tb)) + button.setDefaultAction(action) + parent.addWidget(button) + return action diff --git a/src/modules/Charts/Plots/BarPlot/AngleAxisItem.py b/src/ltrace/ltrace/slicer/widget/customized_pyqtgraph/AngleAxisItem.py similarity index 100% rename from src/modules/Charts/Plots/BarPlot/AngleAxisItem.py rename to src/ltrace/ltrace/slicer/widget/customized_pyqtgraph/AngleAxisItem.py diff --git a/src/modules/Charts/Plots/Crossplot/data_plot_widget.py b/src/ltrace/ltrace/slicer/widget/data_plot_widget.py similarity index 98% rename from src/modules/Charts/Plots/Crossplot/data_plot_widget.py rename to src/ltrace/ltrace/slicer/widget/data_plot_widget.py index 624ebde..1fa09b2 100644 --- a/src/modules/Charts/Plots/Crossplot/data_plot_widget.py +++ b/src/ltrace/ltrace/slicer/widget/data_plot_widget.py @@ -1,21 +1,21 @@ -from Plots.BarPlot.AngleAxisItem import AngleAxisItem import numpy as np import pyqtgraph as pg from pyqtgraph.Qt import QtGui from ltrace.algorithms.measurements import PORE_SIZE_CATEGORIES from ltrace.slicer.widget.custom_gradient_legend import CustomGradientLegend +from ltrace.slicer.widget.customized_pyqtgraph.AngleAxisItem import AngleAxisItem from ltrace.slicer.widget.customized_pyqtgraph.GraphicsLayoutWidget import GraphicsLayoutWidget PLOT_MINIMUM_HEIGHT = 480 PLOT_HISTOGRAM_SIZE = 100 -class DataPlotWidget(pg.QtCore.QObject): +class DataPlotWidget(pg.QtWidgets.QWidget): toggleLegendSignal = pg.QtCore.Signal() - def __init__(self): - super().__init__() + def __init__(self, parent=None): + super().__init__(parent) self.__legendItem = None self.__embeddedLegendVisibility = True diff --git a/src/ltrace/ltrace/slicer/widget/docked_data.py b/src/ltrace/ltrace/slicer/widget/docked_data.py index 27d066e..14765c7 100644 --- a/src/ltrace/ltrace/slicer/widget/docked_data.py +++ b/src/ltrace/ltrace/slicer/widget/docked_data.py @@ -1,36 +1,98 @@ -import logging - import slicer import qt from ltrace.constants import ImageLogConst +from ltrace.slicer.application_observables import ApplicationObservables +from typing import Union + + +def tryGetWidget(moduleName: str, copyWidget: bool = True) -> Union["LTracePluginWidget", None]: + module = getattr(slicer.modules, moduleName, None) + + if not module: + return None + + if copyWidget: + oldModuleWidget = module.widgetRepresentation() + newModuleWidget = module.createNewWidgetRepresentation() + setattr(slicer.modules, f"{oldModuleWidget.self().moduleName}Widget", oldModuleWidget.self()) + setattr(slicer.modules, f"{oldModuleWidget.self().moduleName}DockedWidget", newModuleWidget.self()) + return newModuleWidget + + return module.widgetRepresentation() class DockedData(qt.QDockWidget): def __init__(self): - super().__init__("Explorer") + super().__init__("") + self.objectName = "Docked Data" + self.tabs = None - try: - self.default_data = slicer.modules.customizeddata.createNewWidgetRepresentation() - self.image_log_data = slicer.modules.imagelogdata.createNewWidgetRepresentation() - except AttributeError: - logging.warn("CustomizedData and ImageLogData modules are not available yet.") - self.default_data = None - self.image_log_data = None + self.setAllowedAreas(qt.Qt.AllDockWidgetAreas) + ApplicationObservables().applicationLoadFinished.connect(self.__onApplicationLoadFinished) - slicer.app.layoutManager().layoutChanged.connect(self.on_layout_changed) - self.on_layout_changed() + def __onApplicationLoadFinished(self): + ApplicationObservables().applicationLoadFinished.disconnect(self.__onApplicationLoadFinished) + self.setupUI() - self.setAllowedAreas(qt.Qt.AllDockWidgetAreas) - main_window = slicer.util.mainWindow() - main_window.addDockWidget(qt.Qt.RightDockWidgetArea, self) - - def on_layout_changed(self): - current_layout = slicer.app.layoutManager().layout - if current_layout >= ImageLogConst.DEFAULT_LAYOUT_ID_START_VALUE: - data_widget = self.image_log_data - else: - data_widget = self.default_data - - if data_widget is not None: - self.setWidget(data_widget) + def __createImageLogDataWidget(self): + self.imageLogData = self._createScrollableWidget("imagelogdata") + if self.imageLogData is not None: + self.stackedWidget.addWidget(self.imageLogData) + + def setupUI(self): + self.defaultData = self._createScrollableWidget("customizeddata") + self.jobMonitorWidget = self._createScrollableWidget("jobmonitor", copyWidget=False) + self.stackedWidget = qt.QStackedWidget() + self.stackedWidget.addWidget(self.defaultData) + self.__createImageLogDataWidget() + self.stackedWidget.setCurrentWidget(self.defaultData) + self.tabs = qt.QTabWidget() + self.tabs.addTab(self.stackedWidget, "Explorer") + self.tabs.addTab(self.jobMonitorWidget, "Remote Jobs") + + mainWindow = slicer.modules.AppContextInstance.mainWindow + mainWindow.addDockWidget(qt.Qt.RightDockWidgetArea, self) + + self.setWidget(self.tabs) + slicer.app.layoutManager().layoutChanged.connect(self.onLayoutChanged) + self.onLayoutChanged() + + def _createScrollableWidget(self, pluginWidgetName: str, copyWidget: bool = True) -> qt.QScrollArea: + """Method to wrap the plugin's widget to a scroll area + + Args: + pluginWidget (LTracePluginWidget): the plugin's widget object. + + Returns: + qt.QScrollArea: the scroll area widget object. + """ + newWidgetRepresentation = tryGetWidget(pluginWidgetName, copyWidget) + if not newWidgetRepresentation: + return None + + scroll = qt.QScrollArea() + scroll.setVerticalScrollBarPolicy(qt.Qt.ScrollBarAsNeeded) + scroll.setHorizontalScrollBarPolicy(qt.Qt.ScrollBarAsNeeded) + scroll.setWidgetResizable(True) + scroll.setWidget(newWidgetRepresentation) + + return scroll + + def setCurrentWidget(self, index: int): + if self.tabs is None: + return + + self.tabs.setCurrentIndex(index) + + def onLayoutChanged(self): + currentLayout = slicer.app.layoutManager().layout + explorerWidget = self.defaultData + if currentLayout >= ImageLogConst.DEFAULT_LAYOUT_ID_START_VALUE: + if self.imageLogData is None: + self.__createImageLogDataWidget() + + explorerWidget = self.imageLogData + + if explorerWidget is not None: + self.stackedWidget.setCurrentWidget(explorerWidget) diff --git a/src/ltrace/ltrace/slicer/widget/filtered_node_combo_box.py b/src/ltrace/ltrace/slicer/widget/filtered_node_combo_box.py index a1e271a..563497c 100644 --- a/src/ltrace/ltrace/slicer/widget/filtered_node_combo_box.py +++ b/src/ltrace/ltrace/slicer/widget/filtered_node_combo_box.py @@ -31,7 +31,7 @@ def __init__( if node.GetAttribute("ShowInFilteredNodeComboBox") == "False": continue else: - self.addItem(node.GetName(), node.GetID()) + self.addNode(node) def changeNode(index): self.blockSignals(True) diff --git a/src/ltrace/ltrace/slicer/widget/fuzzysearch.py b/src/ltrace/ltrace/slicer/widget/fuzzysearch.py new file mode 100644 index 0000000..87a8012 --- /dev/null +++ b/src/ltrace/ltrace/slicer/widget/fuzzysearch.py @@ -0,0 +1,133 @@ +import logging +from collections import defaultdict +from typing import List, Iterable, Dict, Tuple + +import pandas as pd +import qt, slicer + +from ltrace.slicer.module_info import ModuleInfo +from ltrace.slicer.module_utils import loadModule +from ltrace.slicer_utils import getResourcePath + + +class FuzzySearchDialog(qt.QDialog): + def __init__(self, model=None, parent=None) -> None: + super().__init__(parent) + self.setWindowIcon(qt.QIcon((getResourcePath("Icons") / "GeoSlicer.ico").as_posix())) + + layout = qt.QVBoxLayout(self) + + self.search_field = qt.QLineEdit() + layout.addWidget(self.search_field) + + self.search_field.setPlaceholderText("Search for a module or a keyword") + self.search_field.textChanged.connect(self.on_search_changed) + + self.results_layout = qt.QHBoxLayout() + self.result_macros_list = qt.QListWidget() + self.result_modules_list = qt.QListWidget() + + self.results_layout.addWidget(self.result_macros_list) + self.results_layout.addWidget(self.result_modules_list) + + layout.addLayout(self.results_layout) + + self.model = model + self.model.setEventHandler(self.eventHandler) + + self.result_macros_list.currentTextChanged.connect(self.on_macro_selected) + self.result_modules_list.currentRowChanged.connect(self.on_module_selected) + + self.update_results([], self.model.macros) + + def on_search_changed(self, query: str) -> None: + macros, moduleNames = self.model.search("".join(query.split())) + self.update_results(moduleNames, macros) + + def update_results( + self, + moduleNames: List[str], + macros: List[str] = None, + ) -> None: + if macros is not None: + self.result_macros_list.clear() + self.result_macros_list.addItems(macros) + + self.result_modules_list.clear() + + self.displayData(moduleNames) + + def displayData(self, moduleNames): + for name in moduleNames: + try: + module = getattr(slicer.modules, name.lower()) + item = qt.QListWidgetItem() + item.setIcon(module.icon) + item.setText(module.title) + item.setData(qt.Qt.UserRole, name) + self.result_modules_list.addItem(item) + + except AttributeError: + pass + + def on_macro_selected(self, macro: str) -> None: + moduleNames = self.model.select_macro(macro) + self.update_results(moduleNames) + + def on_module_selected(self, index) -> None: + listItem = self.result_modules_list.item(index) + if listItem is None: + return + + moduleName = listItem.data(qt.Qt.UserRole) + slicer.util.selectModule(moduleName) + + def eventHandler(self, event): + if event == "new-data": + self.update_results([], self.model.macros) + + +class LinearSearchModel: + def __init__(self): + self.db: pd.DataFrame = None + self.modules = None + self.grouped = defaultdict(list) + self.macros = [] + self.__eventHandler = None + + def setEventHandler(self, handler): + self.__eventHandler = handler + + def setDataSource(self, modules: dict): + self.modules = modules + self.db, self.grouped = self.compile(modules) + self.macros = [k for k in self.grouped] + self.__eventHandler("new-data") + + @staticmethod + def compile(modules: Dict[str, ModuleInfo]) -> Tuple[pd.DataFrame, Dict[str, List[ModuleInfo]]]: + db_raw = [] + by_category = defaultdict(list) + for name in modules: + module = modules[name] + if module.hidden: + continue + + db_raw.append((module.key, module.key)) + for tag in module.categories: + db_raw.append((tag, module.key)) + by_category[tag].append(module) + + return pd.DataFrame(db_raw, columns=["tag", "module"]), by_category + + def search(self, text): + if len(self.db.index) == 0: + return self.macros, [] + + res = self.db.loc[self.db["tag"].str.contains(text, case=False)] + lowtext = text.lower() + macros = [v for v in self.macros if lowtext in v.lower()] + return macros, res["module"].unique().tolist() + + def select_macro(self, macro: str): + return self.db.loc[self.db["tag"].str.lower() == macro.lower(), "module"].unique().tolist() diff --git a/src/ltrace/ltrace/slicer/widget/global_progress_bar.py b/src/ltrace/ltrace/slicer/widget/global_progress_bar.py index ddda8e3..1bf99ce 100644 --- a/src/ltrace/ltrace/slicer/widget/global_progress_bar.py +++ b/src/ltrace/ltrace/slicer/widget/global_progress_bar.py @@ -1,6 +1,10 @@ -import ctk +import vtk import qt import slicer +import mrml + +from ltrace.slicer.helpers import svgToQIcon +from ltrace.slicer_utils import getResourcePath class GlobalProgressBar(qt.QWidget): @@ -23,55 +27,96 @@ def __init__(self): layout = qt.QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) - - self.progressBar = slicer.qSlicerCLIProgressBar() - self.progressBar.findChild(ctk.ctkExpandButton).setVisible(False) - self.progressBar.setStatusVisibility(False) - self.progressBar.setNameVisibility(False) - self.progressBar.setMaximumHeight(25) - self.progressBar.setMinimumHeight(25) - self.progressBar.setMaximumWidth(400) - self.progressBar.setMinimumWidth(400) - newFont = self.progressBar.font - newFont.setPixelSize(7) - self.progressBar.setFont(newFont) + layout.setSpacing(2) + layout.setAlignment(qt.Qt.AlignRight) + + self.progressBar = qt.QProgressBar() + self.progressBar.setFixedHeight(10) + self.progressBar.setMinimumWidth(128) + self.progressBar.setMaximumWidth(256) + self.progressBar.setProperty("style", "thin") + # newFont = self.progressBar.font + # newFont.setPixelSize(7) + # self.progressBar.setFont(newFont) self.toolButton = qt.QToolButton() self.toolButton.setToolTip("Return to the last CLI started.") self.toolButton.setEnabled(False) - icon = ( - slicer.util.mainWindow().moduleSelector().findChildren(qt.QToolButton)[-2].icon - ) # Next module tool button + icon = svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Open.svg") self.toolButton.setObjectName("GoBackButton") self.toolButton.setPopupMode(qt.QToolButton.MenuButtonPopup) self.toolButton.setIcon(icon) + self.toolButton.setAutoRaise(True) self.lastCLILabel = qt.QLabel() self.lastCLILabel.setObjectName("lastCLILabel") + layout.addStretch(1) layout.addWidget(self.lastCLILabel) layout.addWidget(self.toolButton) layout.addWidget(self.progressBar) self.setLayout(layout) + self.visible = False + + self.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Fixed) + GlobalProgressBar._instance = self - def setCommandLineModuleNode(self, cliNode, localProgressBar): + def setCommandLineModuleNode(self, cliNode, localProgressBar, customText=""): GlobalProgressBar._instance.currentCliNode = cliNode - GlobalProgressBar._instance.progressBar.setCommandLineModuleNode(cliNode) + cliNode.AddObserver("ModifiedEvent", GlobalProgressBar._instance.updateUiFromCommandLineModuleNode) GlobalProgressBar._instance.toolButton.setEnabled(True) GlobalProgressBar._instance.toolButton.clicked.disconnect() GlobalProgressBar._instance.toolButton.clicked.connect(localProgressBar.returnToCLIWidget) - GlobalProgressBar._instance.lastCLILabel.setText( - f"Last scheduled job from: {localProgressBar.getCLIWidgetName()}" - ) + GlobalProgressBar._instance.lastCLILabel.setText(customText) + localProgressBar.refToGlobal = self + self.visible = True def actionCreated(self, action): GlobalProgressBar._instance.toolButton.addAction(action) + def disableWhenJobIsCompleted(self): + currentCLiNode = GlobalProgressBar._instance.currentCliNode + if currentCLiNode and "Completed" in currentCLiNode.GetStatusString(): + GlobalProgressBar._instance.toolButton.setEnabled(False) + GlobalProgressBar._instance.lastCLILabel.setText("") + self.visible = False + + def updateUiFromCommandLineModuleNode(self, cliNode, event): + if cliNode is None: + return + + status = cliNode.GetStatus() + info = cliNode.GetModuleDescriptionAsString() + + if status == mrml.vtkMRMLCommandLineModuleNode.Cancelled: + self.progressBar.setRange(0, 0) + self.progressBar.setValue(0) + self.visible = False + elif status == mrml.vtkMRMLCommandLineModuleNode.Scheduled: + self.progressBar.setRange(0, 0) + self.progressBar.setValue(0) + GlobalProgressBar._instance.lastCLILabel.setText("Scheduling...") + elif status == mrml.vtkMRMLCommandLineModuleNode.Running: + maxRange = 100 if cliNode.GetProgress() != 0 else 0 + self.progressBar.setRange(0, maxRange) + self.progressBar.setValue(cliNode.GetProgress()) + GlobalProgressBar._instance.lastCLILabel.setText("Running...") + elif ( + status == mrml.vtkMRMLCommandLineModuleNode.Completed + or status == mrml.vtkMRMLCommandLineModuleNode.CompletedWithErrors + ): + self.progressBar.setRange(0, 100) + self.progressBar.setValue(100) + message = ( + "Completed with errors" if status == mrml.vtkMRMLCommandLineModuleNode.CompletedWithErrors else "Done" + ) + GlobalProgressBar._instance.lastCLILabel.setText(message) + class LocalProgressBar(qt.QWidget): def __init__(self, parent=None): @@ -89,11 +134,16 @@ def __init__(self, parent=None): self.completed = 0 self.running = False self.goBackAction = None + self.refToGlobal = None def setCommandLineModuleNode(self, cliNode): - self.module = slicer.util.mainWindow().moduleSelector().selectedModule - self.moduleWidget = eval(f"slicer.modules.{self.module}Widget") + self.module = slicer.modules.AppContextInstance.mainWindow.moduleSelector().selectedModule + + if not self.module: + return + + self.moduleWidget = slicer.util.getModuleWidget(self.module) try: self.tabIndex = self.moduleWidget.mainTab.currentIndex @@ -140,7 +190,7 @@ def _onCLIModified(self, cliNode, event): if cliNode is None: return - if cliNode.GetStatusString() == "Completed" and self.running: + if "Completed" in cliNode.GetStatusString() and self.running: self.completed += 1 self.running = False elif cliNode.GetStatusString() == "Running" and cliNode in self.scheduled: @@ -154,7 +204,7 @@ def getCLIWidgetName(self): name = currentModule.parent.title try: name += f" → {self.moduleWidget.mainTab.tabText(self.tabIndex)}" - if self.subtabIndex != None: + if self.subtabIndex is not None: currentTab = self.moduleWidget.mainTab.widget(self.tabIndex) name += f" → {currentTab.tabText(self.subtabIndex)}" except AttributeError as e: # no attribute 'mainTab' @@ -162,7 +212,10 @@ def getCLIWidgetName(self): return name def returnToCLIWidget(self): - moduleSelectorToolBar = slicer.util.mainWindow().findChild(slicer.qSlicerModuleSelectorToolBar) + moduleSelectorToolBar = slicer.modules.AppContextInstance.mainWindow.findChild( + slicer.qSlicerModuleSelectorToolBar + ) moduleSelectorToolBar.selectModule(self.module) self.completed = 0 + self.refToGlobal.disableWhenJobIsCompleted() self.changeAction() diff --git a/src/ltrace/ltrace/slicer/widget/help_button.py b/src/ltrace/ltrace/slicer/widget/help_button.py index 18f7108..9a811ff 100644 --- a/src/ltrace/ltrace/slicer/widget/help_button.py +++ b/src/ltrace/ltrace/slicer/widget/help_button.py @@ -1,47 +1,91 @@ +from pathlib import Path import markdown import qt +from ltrace.slicer.helpers import get_scripted_modules_path, svgToQIcon +from ltrace.slicer_utils import getResourcePath class HelpButton(qt.QToolButton): - def __init__(self, message: str, *args, **kwargs) -> None: + def __init__(self, message: str = None, url: str = None, *args, **kwargs) -> None: super().__init__(*args, **kwargs) + self.message = message - self.html_message = markdown.markdown(message) + self.url = url self.setStyleSheet("border : none;") - self.setIcon(qt.QApplication.style().standardIcon(qt.QStyle.SP_MessageBoxQuestion)) - self.setIconSize(qt.QSize(20, 20)) - self.setEnabled(True) - self.clicked.connect(lambda: self.showFloatingMessage(self.html_message)) + self.setIcon(svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "CircleHelp.svg")) + self.setIconSize(qt.QSize(18, 18)) + self.clicked.connect(self.handleClick) + + def handleClick(self): + self.updateMessage(self.message) + if self.url: + self.handleLinkClick(self.url) + else: + self.showFloatingMessage(self.html_message) def updateMessage(self, message: str) -> None: self.message = message - self.html_message = markdown.markdown(message) - - def showFloatingMessage(self, message: str = "") -> None: - pos = qt.QCursor.pos() - qt.QPoint(10, 10) - - label = qt.QLabel(self) - label.setWindowFlags(qt.Qt.ToolTip) - label.setTextFormat(qt.Qt.RichText) - label.setTextInteractionFlags(qt.Qt.TextBrowserInteraction) - label.setOpenExternalLinks(True) - label.setStyleSheet( - """ - padding: 20px; - border: 1px; - border-style: outset; - -qt-block-indent: 0; - font-size: 12px; - """ - ) - label.setText(message) - label.setWordWrap(True) - label.setMaximumWidth(500) - label.move(pos) - label.show() - - label.installEventFilter(self) - - def eventFilter(self, object, event) -> None: + if self.message: + self.html_message = markdown.markdown(message) + + def updateLink(self, helpURL: str) -> None: + self.url = helpURL + + def showFloatingMessage(self, message: str = None) -> None: + if message: + pos = qt.QCursor.pos() - qt.QPoint(10, 10) + + text_browser = qt.QTextBrowser(self) + text_browser.setWindowFlags(qt.Qt.ToolTip) + text_browser.setHtml(message) + text_browser.setOpenExternalLinks(False) # Disable automatic link handling + text_browser.anchorClicked.connect(self.handleLinkClick) + text_browser.setStyleSheet( + """ + padding: 20px; + border: 1px; + border-style: outset; + font-size: 12px; + """ + ) + text_browser.setWordWrapMode(qt.QTextOption.WordWrap) + text_browser.setHorizontalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff) + text_browser.setVerticalScrollBarPolicy(qt.Qt.ScrollBarAlwaysOff) + + # Adjust size to fit content + document = text_browser.document + document.setTextWidth(400) # Set a maximum width + size = document.size.toSize() + padding = 70 + text_browser.setFixedSize(qt.QSize(size.width() + padding, size.height() + padding)) + + text_browser.move(pos) + text_browser.show() + + text_browser.installEventFilter(self) + + text_browser.move(pos) + text_browser.show() + + text_browser.installEventFilter(self) + self.text_browser = text_browser + + def handleLinkClick(self, url): + if isinstance(url, qt.QUrl) and url.isValid(): + qt.QDesktopServices.openUrl(url) + if hasattr(self, "text_browser"): + self.text_browser.hide() + return + if isinstance(url, str) and url.lower().endswith(".html"): + fileUrl = qt.QUrl(url) + qt.QDesktopServices.openUrl(fileUrl) + else: + manualPath = (getResourcePath("manual") / "Welcome" / "welcome.html").as_posix() + qt.QDesktopServices.openUrl(qt.QUrl(f"file:///{manualPath}")) + + if hasattr(self, "text_browser"): + self.text_browser.hide() + + def eventFilter(self, obj, event) -> None: if event.type() == qt.QEvent.Leave or event.type() == qt.QEvent.MouseButtonRelease: - object.hide() + obj.hide() diff --git a/src/ltrace/ltrace/slicer/widget/hierarchy_volume_input.py b/src/ltrace/ltrace/slicer/widget/hierarchy_volume_input.py index 6e366be..03b8b39 100644 --- a/src/ltrace/ltrace/slicer/widget/hierarchy_volume_input.py +++ b/src/ltrace/ltrace/slicer/widget/hierarchy_volume_input.py @@ -1,63 +1,150 @@ +import logging + import qt import slicer +from ltrace.slicer.helpers import BlockSignals from ltrace.utils.custom_event_filter import CustomEventFilter -class HierarchyVolumeInput(slicer.qMRMLSubjectHierarchyComboBox): +class HierarchyVolumeInput(qt.QWidget): + + currentItemChanged = qt.Signal(object) + def __init__( self, - onChange=None, hasNone=False, nodeTypes=["vtkMRMLScalarVolumeNode", "vtkMRMLLabelMapVolumeNode"], defaultText=None, + allowFolders=False, + parent=None, ): - super().__init__() + super().__init__(parent) + + self.__itemSelectedHandlerConnected = False + + self.__resetStylesheet = False + + self.allowedNodeTypes = nodeTypes + self.foldersAllowed = allowFolders + + self.selectorWidget = slicer.qMRMLSubjectHierarchyComboBox(self) + self.subjectHierarchy = slicer.mrmlScene.GetSubjectHierarchyNode() - self.setNodeTypes(nodeTypes) - self.setMRMLScene(slicer.mrmlScene) - self.noneEnabled = hasNone - if onChange: - self.currentItemChanged.connect(lambda: onChange(self.currentItem())) + self.selectorWidget.setNodeTypes(nodeTypes) + self.selectorWidget.setMRMLScene(slicer.mrmlScene) + self.selectorWidget.noneEnabled = hasNone + self.selectorWidget.setCurrentItem(0) + + layout = qt.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.selectorWidget) + + self.__previousItemId = self.selectorWidget.currentItem() + self.customDefaultText = defaultText if self.customDefaultText: - self.setProperty("defaultText", self.customDefaultText) + self.selectorWidget.setProperty("defaultText", self.customDefaultText) self.node_attribute_filter_list = [] - self.event_filter = CustomEventFilter(self.eventFilter, self) + self.event_filter = CustomEventFilter(self.eventFilter, self.selectorWidget) self.event_filter.install() self.end_close_scene_observer_handler = slicer.mrmlScene.AddObserver( slicer.mrmlScene.EndCloseEvent, self.__onEndCloseScene ) + self.setLayout(layout) + self.destroyed.connect(self.__del__) + + def __del__(self, obj: qt.QObject = None) -> None: + slicer.mrmlScene.RemoveObserver(self.end_close_scene_observer_handler) + + def setMRMLScene(self, scene: slicer.mrmlScene) -> None: + """Wrapper for qMRMLSubjectHierarchyComboBox setMRMLScene method. + + Args: + scene (slicer.mrmlScene): the scene object. + """ + if self.selectorWidget is None: + return + + self.selectorWidget.setMRMLScene(scene) + def eventFilter(self, object, event): - if type(event) == qt.QMouseEvent: - if event.type() == qt.QEvent.MouseButtonPress: - self.refreshAttributeFilter() - return True + event_type = event.type() + + if event_type == qt.QEvent.MouseButtonPress or event_type == qt.QEvent.KeyPress: + self.refreshAttributeFilter() # This is a workaround to properly update the node list + elif event_type == qt.QEvent.Show: + self._connectItemChangedHandler() + elif event_type == qt.QEvent.Hide: + self._disconnectItemChangedHandler() + return False + def _connectItemChangedHandler(self): + if not self.__itemSelectedHandlerConnected: + self.selectorWidget.currentItemChanged.connect(self.itemChangedHandler) + self.__itemSelectedHandlerConnected = True + + def _disconnectItemChangedHandler(self): + if self.__itemSelectedHandlerConnected: + self.selectorWidget.currentItemChanged.disconnect(self.itemChangedHandler) + self.__itemSelectedHandlerConnected = False + + def itemChangedHandler(self, itemId): + + if itemId == self.__previousItemId: + return + + if itemId > 0: + selectedNode = slicer.mrmlScene.GetSubjectHierarchyNode().GetItemDataNode(itemId) + + if selectedNode is None and not self.foldersAllowed: + self.selectorWidget.setCurrentItem(0) + return + + if selectedNode and ( + selectedNode.GetHideFromEditors() + or not any(selectedNode.IsA(nodeType) for nodeType in self.allowedNodeTypes) + ): + self.selectorWidget.setCurrentItem(0) + return + + if self.__resetStylesheet: + self.resetStyleSheetOnChange() + + self.currentItemChanged.emit(itemId) + self.__previousItemId = itemId + def refreshAttributeFilter(self): # This is a workaround to properly update the node list for attribute_name, attribute_value in self.node_attribute_filter_list: - self.removeNodeAttributeFilter(attribute_name, attribute_value) - self.addNodeAttributeFilter(attribute_name, attribute_value) + self.selectorWidget.removeNodeAttributeFilter(attribute_name, attribute_value) + self.selectorWidget.addNodeAttributeFilter(attribute_name, attribute_value) def currentNode(self): - itemId = self.currentItem() + itemId = self.selectorWidget.currentItem() if itemId: return self.subjectHierarchy.GetItemDataNode(itemId) return None + def currentItem(self): + """Deprecated. Use currentNode() instead. Keeping just for back compatibility.""" + return self.selectorWidget.currentItem() + def setCurrentNode(self, node): self.refreshAttributeFilter() if node is None or isinstance(node, slicer.vtkMRMLNode): - self.setCurrentItem(self.subjectHierarchy.GetItemByDataNode(node)) + self.selectorWidget.setCurrentItem(self.subjectHierarchy.GetItemByDataNode(node)) + + def setCurrentItem(self, itemId: int): + self.selectorWidget.setCurrentItem(itemId) def addNodeAttributeIncludeFilter(self, attribute_name, attribute_value): self.node_attribute_filter_list.append((attribute_name, attribute_value)) - self.addNodeAttributeFilter(attribute_name, attribute_value) + self.selectorWidget.addNodeAttributeFilter(attribute_name, attribute_value) def removeNodeAttributeIncludeFilter(self, attribute_name, attribute_value=None): """Remove attribute filter that includes items in the combobox @@ -77,26 +164,31 @@ def removeNodeAttributeIncludeFilter(self, attribute_name, attribute_value=None) self.node_attribute_filter_list.remove((attribute_name, attribute_value)) except ValueError: pass - self.removeNodeAttributeFilter(attribute_name, attribute_value) + + self.selectorWidget.removeNodeAttributeFilter(attribute_name, attribute_value) def resetStyleOnValidNode(self): - def inputChanged(): - if self.currentNode(): - self.setStyleSheet("") + self.__resetStylesheet = True - self.currentItemChanged.connect(inputChanged) + def resetStyleSheetOnChange(self): + if self.currentNode(): + self.setStyleSheet("") def clearSelection(self): - slicer.qMRMLSubjectHierarchyComboBox.clearSelection(self) if self.customDefaultText: - self.setProperty("defaultText", self.customDefaultText) + self.selectorWidget.setProperty("defaultText", self.customDefaultText) + previousStateConnected = self.__itemSelectedHandlerConnected + self._connectItemChangedHandler() + if self.selectorWidget.currentItem() != 0: + self.selectorWidget.setCurrentItem(0) + else: + self.itemChangedHandler(0) + + if not previousStateConnected: + self._disconnectItemChangedHandler() def __onEndCloseScene(self, *args): try: - self.children() - except ValueError: - # Widget was destroyed - slicer.mrmlScene.RemoveObserver(self.end_close_scene_observer_handler) - return - if self.customDefaultText: - self.setProperty("defaultText", self.customDefaultText) + self.clearSelection() + except Exception as e: + logging.error(e) diff --git a/src/ltrace/ltrace/slicer/widget/memory_usage.py b/src/ltrace/ltrace/slicer/widget/memory_usage.py new file mode 100644 index 0000000..ee3b3a8 --- /dev/null +++ b/src/ltrace/ltrace/slicer/widget/memory_usage.py @@ -0,0 +1,45 @@ +import qt +import psutil + +from ltrace.slicer_utils import getResourcePath + + +class MemoryUsageWidget(qt.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + layout = qt.QHBoxLayout() + layout.setContentsMargins(8, 0, 16, 0) + layout.setSpacing(3) + icon = qt.QIcon((getResourcePath("Icons") / "IconSet-dark" / "Memory.svg").as_posix()) + self.labelIcon = qt.QLabel() + self.labelIcon.setPixmap(icon.pixmap(16, 16)) + self.memoryCounter = qt.QLabel("") + self.memoryUsedTimer = qt.QTimer() + + layout.addWidget(self.labelIcon) + layout.addWidget(self.memoryCounter) + self.setLayout(layout) + + self.setStyleSheet( + "QLabel {\ + font-size: 12px;\ + }" + ) + self.toolTip = "Memory Usage" + + def update(self, value, total): + percent = int((value / total) * 100) + text = f"{value} / {total}GB ({percent}%)" + if text != self.memoryCounter.text: + self.memoryCounter.setText(text) + + def start(self): + self.memoryUsedTimer.setInterval(1000) + self.memoryUsedTimer.timeout.connect(self.__poller) + self.memoryUsedTimer.start() + + def __poller(self): + used_gb = int(round(psutil.virtual_memory().used / (1024**3))) + total_gb = int(round(psutil.virtual_memory().total / (1024**3))) + self.update(used_gb, total_gb) diff --git a/src/ltrace/ltrace/slicer/widget/module_header.py b/src/ltrace/ltrace/slicer/widget/module_header.py new file mode 100644 index 0000000..c83c9c2 --- /dev/null +++ b/src/ltrace/ltrace/slicer/widget/module_header.py @@ -0,0 +1,32 @@ +import qt + +from ltrace.slicer.helpers import svgToQIcon +from ltrace.slicer_utils import getResourcePath +from ltrace.slicer.widget.help_button import HelpButton + + +class ModuleHeader(qt.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # Initialize layout + self.headerLayout = qt.QHBoxLayout(self) + self.headerLayout.setContentsMargins(6, 6, 6, 6) + self.headerLayout.setSpacing(3) + self.headerLayout.setAlignment(qt.Qt.AlignLeft) + + self.baseIcon = svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "ChevronRight.svg") + self.baseLabel = qt.QLabel() + self.baseLabel.setPixmap(self.baseIcon.pixmap(qt.QSize(16, 16))) + self.moduleTitle = qt.QLabel("") + self.moduleHelp = HelpButton() + + self.headerLayout.setAlignment(qt.Qt.AlignLeft) + self.headerLayout.addWidget(self.baseLabel) + self.headerLayout.addWidget(self.moduleTitle) + self.headerLayout.addStretch(1) + self.headerLayout.addWidget(self.moduleHelp) + + def update(self, title, helpURL): + self.moduleTitle.setText(title.upper()) + self.moduleHelp.updateLink(helpURL) diff --git a/src/ltrace/ltrace/slicer/widget/module_indicator.py b/src/ltrace/ltrace/slicer/widget/module_indicator.py new file mode 100644 index 0000000..5c8c9f7 --- /dev/null +++ b/src/ltrace/ltrace/slicer/widget/module_indicator.py @@ -0,0 +1,64 @@ +import qt +import slicer + +from ltrace.slicer.module_info import ModuleInfo +from ltrace.slicer_utils import getResourcePath + + +class ModuleIndicator(qt.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(8, 0, 0, 0) + layout.setSpacing(2) + + self.moduleBtn = qt.QToolButton(self) + self.moduleBtn.setToolButtonStyle(qt.Qt.ToolButtonTextBesideIcon) + self.moduleBtn.setAutoRaise(True) + + # TODO check if it worth it to have a bookmark + # self.bookmarkBtn = self.checkableButton( + # lambda checked: print("Bookmark checked" if checked else "Bookmark unchecked"), + # svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "BookmarkCheck.svg"), + # svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Bookmark.svg"), + # checked=False + # ) + # self.bookmarkBtn.setToolButtonStyle(qt.Qt.ToolButtonIconOnly) + # self.bookmarkBtn.setAutoRaise(True) + + self.docBtn = qt.QToolButton(self) + self.docBtn.setIcon( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "CircleHelp.svg"), + ) + self.docBtn.setToolButtonStyle(qt.Qt.ToolButtonIconOnly) + self.docBtn.setAutoRaise(True) + + layout.addWidget(self.moduleBtn) + # layout.addWidget(self.bookmarkBtn) + layout.addWidget(self.docBtn) + + def setModule(self, moduleInfo: ModuleInfo): + module = getattr(slicer.modules, moduleInfo.key.lower()) + self.moduleBtn.setIcon(module.icon) + self.moduleBtn.setText(module.title) + + @staticmethod + def checkableButton(func, checkedIcon, uncheckedIcon, checked=False): + button = qt.QToolButton() + button.setCheckable(True) + button.setChecked(checked) + button.setIcon(checkedIcon if checked else uncheckedIcon) + + # Connect the toggled signal to change the icon + def update_icon(checked_): + if checked_: + button.setIcon(checkedIcon) + else: + button.setIcon(uncheckedIcon) + + func(checked_) + + button.toggled.connect(update_icon) + + return button diff --git a/src/ltrace/ltrace/slicer/widget/pixel_size_editor.py b/src/ltrace/ltrace/slicer/widget/pixel_size_editor.py index 1d0c46e..7d7204d 100644 --- a/src/ltrace/ltrace/slicer/widget/pixel_size_editor.py +++ b/src/ltrace/ltrace/slicer/widget/pixel_size_editor.py @@ -1,8 +1,9 @@ import slicer import qt -from Customizer import Customizer + from ltrace.slicer import helpers from ltrace.slicer.node_observer import NodeObserver +from ltrace.slicer_utils import getResourcePath from ltrace.utils.Markup import MarkupLine @@ -32,7 +33,8 @@ def __init__(self, parent=None): self.scaleSizePxLineEdit.setToolTip("Scale size in pixels") self.scaleSizePxLineEdit.textEdited.connect(lambda: self.__on_size_field_edited(self.FIELD_SCALE_SIZE_PX)) self.scaleSizePxRuler = qt.QPushButton() - self.scaleSizePxRuler.setIcon(qt.QIcon(str(Customizer.ANNOTATION_DISTANCE_ICON_PATH))) + + self.scaleSizePxRuler.setIcon(qt.QIcon(getResourcePath("Icons") / "AnnotationDistance.png")) self.scaleSizePxRuler.connect("clicked()", self.onScaleSizeRulerButtonClicked) self.scaleSizePxFrame = qt.QFrame() scaleSizePxLayout = qt.QHBoxLayout(self.scaleSizePxFrame) @@ -65,11 +67,14 @@ def __init__(self, parent=None): self.__set_retain_size_when_hidden(self.loadFormLayout.labelForField(self.imageSpacingLineEdit), True) def onScaleSizeRulerButtonClicked(self): - def finish(caller_markup, point_index=None): + def finish_callback(caller_markup, point_index=None): self.scaleSizePxLineEdit.text = round(caller_markup.get_line_length_in_pixels()) self.__on_size_field_edited(self.FIELD_SCALE_SIZE_PX) - self.markup = MarkupLine(finish) + def finish_criterion(caller_markup, point_index=None): + return caller_markup.get_number_of_selected_points() >= 2 + + self.markup = MarkupLine(finish_callback=finish_callback, finish_criterion=finish_criterion) self.markup.start_picking() def reset(self): diff --git a/src/ltrace/ltrace/slicer/widget/save_netcdf.py b/src/ltrace/ltrace/slicer/widget/save_netcdf.py index 714cdba..657a973 100644 --- a/src/ltrace/ltrace/slicer/widget/save_netcdf.py +++ b/src/ltrace/ltrace/slicer/widget/save_netcdf.py @@ -2,10 +2,11 @@ import ctk import vtk import slicer + from ltrace.slicer import ui +from ltrace.slicer import export, netcdf from pathlib import Path -import Export -import NetCDFExport + EXPORTABLE_TYPES = ( slicer.vtkMRMLLabelMapVolumeNode, @@ -19,7 +20,7 @@ def getNodesFromFolder(folderId): ids = vtk.vtkIdList() ids.SetNumberOfIds(1) ids.SetId(0, folderId) - return Export.ExportLogic().getDataNodes(ids, EXPORTABLE_TYPES) + return export.getDataNodes(ids, EXPORTABLE_TYPES) class SaveNetcdfWidget(qt.QFrame): @@ -67,13 +68,17 @@ def __init__(self, *args): detailsLayout.addWidget(detailsLabel) layout.addRow(detailsGroup) - self.folderSelector = ui.hierarchyVolumeInput(nodeTypes=EXPORTABLE_TYPES) - self.folderSelector.setToolTip("All images in this folder that are not yet in the file will be added.") + self.folderSelector = ui.hierarchyVolumeInput( + nodeTypes=EXPORTABLE_TYPES, + tooltip="All images in this folder that are not yet in the file will be added.", + allowFolders=True, + ) layout.addRow("Folder:", self.folderSelector) self.fileLabel = qt.QLabel() self.fileLabel.setToolTip( - "The selected folder was previously imported from this file. New images will be saved to the file. Existing images and attributes will remain in the file." + "The selected folder was previously imported from this file. New images will be saved to the file. " + "Existing images and attributes will remain in the file." ) layout.addRow("Save as:", self.fileLabel) @@ -104,5 +109,5 @@ def onSave(self): path = Path(self.fileLabel.text) folderId = self.folderSelector.currentItem() nodes = getNodesFromFolder(folderId) - NetCDFExport.exportNetcdf(path, nodes, single_coords=True, save_in_place=True) + netcdf.exportNetcdf(path, nodes, single_coords=True, save_in_place=True) self.close() diff --git a/src/ltrace/ltrace/slicer/widget/simulation/__init__.py b/src/ltrace/ltrace/slicer/widget/simulation/__init__.py new file mode 100644 index 0000000..982183c --- /dev/null +++ b/src/ltrace/ltrace/slicer/widget/simulation/__init__.py @@ -0,0 +1 @@ +from .widgets import * diff --git a/src/modules/PoreNetworkSimulation/PoreNetworkSimulationLib/widgets.py b/src/ltrace/ltrace/slicer/widget/simulation/widgets.py similarity index 100% rename from src/modules/PoreNetworkSimulation/PoreNetworkSimulationLib/widgets.py rename to src/ltrace/ltrace/slicer/widget/simulation/widgets.py diff --git a/src/ltrace/ltrace/slicer/widgets.py b/src/ltrace/ltrace/slicer/widgets.py index 1f347c2..f1d4838 100644 --- a/src/ltrace/ltrace/slicer/widgets.py +++ b/src/ltrace/ltrace/slicer/widgets.py @@ -15,6 +15,8 @@ from typing import Union, List, Tuple +from ltrace.slicer_utils import getResourcePath + def findSOISegmentID(segmentation): n_segments = segmentation.GetNumberOfSegments() @@ -353,6 +355,12 @@ def getSelectedSegments(self): return selectedItems + def allSegmentsSelected(self): + return all( + self.segmentListGroup[1].item(nth).checkState() == qt.Qt.Checked + for nth in range(self.segmentListGroup[1].count) + ) + def _onAutoPoreCalcToggled(self, state): mainNode = self.mainInput.currentNode() if mainNode: @@ -366,11 +374,8 @@ def _onAutoPoreCalcToggled(self, state): ) def updateRefNode(self, node): - self.referenceInput.blockSignals(True) self.referenceInput.setCurrentNode(node) - self.referenceInput.blockSignals(False) self.referenceInput.setStyleSheet("") - self._onReferenceSelected(node) def _onMainSelected(self, item): try: @@ -562,7 +567,7 @@ def editTargetSegment(self): self.showEditTargetDialog(self.mainInput.currentNode()) def showEditTargetDialog(self, segmentationNode): - dialogWidget = qt.QDialog(slicer.util.mainWindow()) + dialogWidget = qt.QDialog(slicer.modules.AppContextInstance.mainWindow) dialogWidget.setModal(True) dialogWidget.setWindowTitle("Edit Target") @@ -623,7 +628,9 @@ def updateSegmentList(self, segments): self.segmentListUpdated.emit((mainNode, soiNode, referenceNode), segments) # TODO check for overlaps - [s.show() for s in self.segmentListGroup] + for s in self.segmentListGroup: + s.show() + self.dimensionsGroup.show() @@ -932,12 +939,10 @@ def __init__(self): self.setChecked(True) def update(self): - from Customizer import Customizer - if self.checked: - self.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + self.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) else: - self.setIcon(qt.QIcon(str(Customizer.CLOSED_EYE_ICON_PATH))) + self.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeClosed.png")) def isOpen(self): return self.checked == 1 diff --git a/src/ltrace/ltrace/slicer_utils.py b/src/ltrace/ltrace/slicer_utils.py index 951ce4c..40518db 100644 --- a/src/ltrace/ltrace/slicer_utils.py +++ b/src/ltrace/ltrace/slicer_utils.py @@ -1,19 +1,17 @@ -import qt -import slicer -import vtk -import json import markdown2 as markdown import numpy as np -import os import pandas as pd +from typing import Union +from pathlib import Path +from abc import abstractmethod + +from SegmentEditorEffects import * +from slicer import ScriptedLoadableModule from ltrace.slicer.application_observables import ApplicationObservables +from ltrace.slicer.helpers import svgToQIcon from ltrace.slicer.tests.ltrace_plugin_test import LTracePluginTest from ltrace.slicer.tests.ltrace_tests_widget import LTraceTestsWidget -from pathlib import Path -from SegmentEditorEffects import * -from slicer import ScriptedLoadableModule -from typing import Union __all__ = [ @@ -47,6 +45,12 @@ def __init__(self, *args, **kwargs): if self.SETTING_KEY is None: raise NotImplementedError + moduleDir = Path(self.parent.path).parent + iconPath = moduleDir / "Resources" / "Icons" / f"{self.moduleName}.svg" + + if iconPath.is_file(): + self.parent.icon = svgToQIcon(iconPath) + @classmethod def help(cls): htmlHelp = "" @@ -69,7 +73,9 @@ def runTest(self, useGui=True, msec=100, **kwargs): msec: delay to associate with :func:`ScriptedLoadableModuleTest.delayDisplay()`. """ if useGui: - tests_widget = LTraceTestsWidget(parent=slicer.util.mainWindow(), current_module=self.__class__.__name__) + tests_widget = LTraceTestsWidget( + parent=slicer.modules.AppContextInstance.mainWindow, currentModule=self.__class__.__name__ + ) tests_widget.exec() return @@ -83,18 +89,19 @@ def get_setting(cls, key, default=None): def set_setting(cls, key, value): slicer.app.settings().setValue(f"{cls.SETTING_KEY}/{key}", value) + def resource(self, resourceName): + return Path(slicer.util.modulePath(self.moduleName)).parent / "Resources" / resourceName + + def title(self): + return self.parent.title + class LTracePluginWidget(ScriptedLoadableModule.ScriptedLoadableModuleWidget): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - def isReloading(self) -> bool: - return slicer.reloadingWidget.get(self.moduleName, False) if hasattr(slicer, "reloadingWidget") else False - - def onReload(self) -> None: - slicer.reloadingWidget[self.moduleName] = True - ScriptedLoadableModule.ScriptedLoadableModuleWidget.onReload(self) - slicer.reloadingWidget[self.moduleName] = False + def setup(self): + super().setup() def enter(self) -> None: ApplicationObservables().moduleWidgetEnter.emit(self) @@ -183,6 +190,44 @@ def is_bool(value): return tableNode +class LTraceEnvironmentMixin: + @property + def modulesToolbar(self): + if not self.__modulesToolbar: + raise AttributeError("Modules toolbar not set") + return self.__modulesToolbar + + @modulesToolbar.setter + def modulesToolbar(self, value): + self.__modulesToolbar = value + + def segmentEditor(self): + return slicer.util.getModuleWidget("CustomizedSegmentEditor") + + def setCategory(self, category): + self.category = category + + @abstractmethod + def setupEnvironment(self): + pass + + def setupSegmentation(self): + pass + + def enter(self): + pass + + def setupTools(self): + self.getModuleManager().addToolsMenu(self.__modulesToolbar) + + def setupLoaders(self): + self.getModuleManager().addLoadersMenu(self.__modulesToolbar) + + @staticmethod + def getModuleManager(): + return slicer.modules.AppContextInstance.modules + + def tableNodeToDict(tableNode): """ Returns a dictionary of 1D numpy arrays @@ -228,7 +273,7 @@ def tableNodeToDict(tableNode): def restartSlicerIn2s(): text = "Slicer has been successfully configured and must be restarted." - mb = qt.QMessageBox(slicer.util.mainWindow()) + mb = qt.QMessageBox(slicer.modules.AppContextInstance.mainWindow) mb.text = text mb.setWindowTitle("Configuration finished") qt.QTimer.singleShot(2000, lambda: killSlicer(mb)) @@ -242,18 +287,6 @@ def killSlicer(messagebox=None): slicer.app.restart() -def get_json_data(): - folders = [ - *(os.path.dirname(slicer.app.launcherExecutableFilePath).split("/")), - *(f"lib\\{base_version()}\\qt-scripted-modules\\Resources\\json\\WelcomeGeoSlicer.json".split("\\")), - ] - JSON_PATH = os.path.join(folders[0], os.sep, *folders[1:]) - with open(JSON_PATH, "r") as json_file: - JSON_DATA = json.load(json_file) - - return JSON_DATA - - def addNodeToSubjectHierarchy(node, dirPaths: list = None): """Add node to the subject hierarchy list regarding the folder hierarchy specified at 'dirPaths' argument. ex: dirPaths = ["folder_a", "folder_b"] means that node will be displayed inside folder hierarchy: folder_a > folder_b > node. @@ -453,34 +486,5 @@ def tableWidgetToDataFrame(tableWidget: qt.QTableWidget) -> pd.DataFrame: return df -class DebounceSignal: - """Wrapper for qt.Signal with debouncing, emiting the signal only one time after a given interval.""" - - def __init__( - self, parent: Union[qt.QWidget, qt.QObject], signal: qt.Signal, intervalMs: int = 500, qtTimer=qt.QTimer - ) -> None: - assert signal is not None, "Invalid signal reference." - assert parent is not None, "Invalid parent reference." - - self.__signal = signal - self.timer = qtTimer(parent) - self.timer.setSingleShot(True) - self.timer.setInterval(intervalMs) - self.timer.timeout.connect(self.__onTimeout) - self.timer.stop() - self.__args = None - self.__kwargs = None - - def emit(self, *args, **kwargs) -> None: - self.__args = args - self.__kwargs = kwargs - - if self.timer.isActive(): - self.timer.stop() - - self.timer.start() - - def __onTimeout(self) -> None: - self.__signal.emit(*self.__args, **self.__kwargs) - self.__args = None - self.__kwargs = None +def getResourcePath(rtype: str) -> Path: + return Path(slicer.app.slicerHome) / "LTrace" / "Resources" / rtype diff --git a/src/ltrace/ltrace/utils/Markup.py b/src/ltrace/ltrace/utils/Markup.py index d08ae40..807ec30 100644 --- a/src/ltrace/ltrace/utils/Markup.py +++ b/src/ltrace/ltrace/utils/Markup.py @@ -43,7 +43,7 @@ def __init__( self.last_slice_view_name = None self.markups_node = createTemporaryNode(cls=self.TYPE_TO_SLICER_TYPE[self.type], name="markups_node") - slicer.util.mainWindow().installEventFilter(self) + slicer.modules.AppContextInstance.mainWindow.installEventFilter(self) def __del__(self): self.stop_picking() @@ -82,8 +82,9 @@ def next_pick_or_finish(interaction_node=None, event=None): else: # pick another point if self.update_instruction: self.update_instruction(self, point_index) - if interaction_node.GetCurrentInteractionMode() != 1: # (i.e., 2) - interaction_node.SetCurrentInteractionMode(1) + if interaction_node.GetCurrentInteractionMode() != 1: + self.stop_picking() + self.__reset_markups_node() self.__reset_markups_node() self.__removeInteractionObserverTags() @@ -119,7 +120,7 @@ def stop_picking(self): interactionNode.SetPlaceModePersistence(0) interactionNode.SetCurrentInteractionMode(2) interactionNode.SwitchToViewTransformMode() - slicer.util.mainWindow().removeEventFilter(self) + slicer.modules.AppContextInstance.mainWindow.removeEventFilter(self) def cancel_picking(self): if self.markups_node is not None: @@ -182,6 +183,9 @@ def __removeMarkupsObserverTags(self): def __ras_to_ijk(self, ras, volume_node=None, as_int=True): if volume_node is None: volume_node = slicer.mrmlScene.GetNodeByID(self.markups_node.GetNthControlPointAssociatedNodeID(0)) + if volume_node is None: + self.__reset_markups_node() + return ras1Arr = np.c_[ras, np.ones((ras.shape[0], 1))] ras_to_ijk = vtk.vtkMatrix4x4() volume_node.GetRASToIJKMatrix(ras_to_ijk) diff --git a/src/ltrace/ltrace/utils/callback.py b/src/ltrace/ltrace/utils/callback.py new file mode 100644 index 0000000..d2d9eb4 --- /dev/null +++ b/src/ltrace/ltrace/utils/callback.py @@ -0,0 +1,3 @@ +class Callback(object): + def __init__(self, on_update=None): + self.on_update = on_update or (lambda *args, **kwargs: None) diff --git a/src/ltrace/ltrace/utils/custom_event_filter.py b/src/ltrace/ltrace/utils/custom_event_filter.py index 24b7f52..53a57ae 100644 --- a/src/ltrace/ltrace/utils/custom_event_filter.py +++ b/src/ltrace/ltrace/utils/custom_event_filter.py @@ -6,7 +6,7 @@ class CustomEventFilter(qt.QObject): def __init__(self, filter_callback, target=None): super().__init__() self.filter_callback = filter_callback - self.target = target or slicer.util.mainWindow() + self.target = target or slicer.modules.AppContextInstance.mainWindow def install(self): self.target.installEventFilter(self) diff --git a/src/ltrace/ltrace/workflow/Workflow.py b/src/ltrace/ltrace/workflow/Workflow.py index 85336fe..6d2901c 100644 --- a/src/ltrace/ltrace/workflow/Workflow.py +++ b/src/ltrace/ltrace/workflow/Workflow.py @@ -1,17 +1,16 @@ import collections -import ctk import json -import numpy as np -import vtk - -from Customizer import Customizer from dataclasses import dataclass +from pathlib import Path + from ltrace.utils.ProgressBarProc import ProgressBarProc from ltrace.workflow.workstep import * from ltrace.workflow.workstep.data import * from ltrace.workflow.workstep.segmentation import * from ltrace.workflow.workstep.simulation import * -from pathlib import Path + +from ltrace.slicer_utils import getResourcePath + WORKSTEPS = { module.NAME: module @@ -326,8 +325,8 @@ def menuBarInterface(self): optionsMenu = menuBar.addMenu("&Options") helpMenu = menuBar.addMenu("&Help") - loadWorkflowAction = qt.QAction(qt.QIcon(str(Customizer.LOAD_ICON_PATH)), "&Load...", fileMenu) - saveWorkflowAction = qt.QAction(qt.QIcon(str(Customizer.SAVE_ICON_PATH)), "&Save", fileMenu) + loadWorkflowAction = qt.QAction(qt.QIcon(getResourcePath("Icons") / "Load.png"), "&Load...", fileMenu) + saveWorkflowAction = qt.QAction(qt.QIcon(getResourcePath("Icons") / "Save.png"), "&Save", fileMenu) saveAsWorkflowAction = qt.QAction("Save as...", fileMenu) closeWorkflowAction = qt.QAction("&Close", fileMenu) exitWorkflowAction = qt.QAction("E&xit", fileMenu) @@ -385,9 +384,9 @@ def toolBarInterface(self): toolBar.setObjectName("toolBar") toolBar.setToolButtonStyle(qt.Qt.ToolButtonTextBesideIcon) toolBar.setStyleSheet("QToolBar {border-top: 1px solid gray; border-bottom: 1px solid gray;}") - runWorkflowAction = qt.QAction(qt.QIcon(str(Customizer.RUN_ICON_PATH)), "Run workflow", toolBar) + runWorkflowAction = qt.QAction(qt.QIcon(getResourcePath("Icons") / "Run.png"), "Run workflow", toolBar) toolBar.addAction(runWorkflowAction) - stopWorkflowAction = qt.QAction(qt.QIcon(str(Customizer.STOP_ICON_PATH)), "Stop workflow", toolBar) + stopWorkflowAction = qt.QAction(qt.QIcon(getResourcePath("Icons") / "Stop.png"), "Stop workflow", toolBar) toolBar.addAction(stopWorkflowAction) runWorkflowAction.triggered.connect(self.run) @@ -494,10 +493,10 @@ def workflowPanel(self): addWorkstepButton = qt.QPushButton("Add workstep") addWorkstepButton.setObjectName("addWorkstepButton") addWorkstepButton.setAutoDefault(False) - addWorkstepButton.setIcon(qt.QIcon(str(Customizer.ADD_ICON_PATH))) + addWorkstepButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Add.png")) addCloneDeleteWorkstepsButtonsLayout.addWidget(addWorkstepButton) self.deleteWorkstepButton = qt.QPushButton("Delete workstep") - self.deleteWorkstepButton.setIcon(qt.QIcon(str(Customizer.DELETE_ICON_PATH))) + self.deleteWorkstepButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Delete.png")) self.deleteWorkstepButton.setEnabled(False) self.deleteWorkstepButton.setAutoDefault(False) addCloneDeleteWorkstepsButtonsLayout.addWidget(self.deleteWorkstepButton) @@ -632,7 +631,7 @@ def workstepPanel(self): workstepStateButtonsLayout = qt.QHBoxLayout() workstepStateButtonsLayout.addWidget(qt.QWidget()) resetWorkstepButton = qt.QPushButton("Reset to default") - resetWorkstepButton.setIcon(qt.QIcon(str(Customizer.RESET_ICON_PATH))) + resetWorkstepButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Reset.png")) workstepStateButtonsLayout.addWidget(resetWorkstepButton) workstepStateButtonsLayout.addWidget(qt.QWidget()) workstepLayout.addLayout(workstepStateButtonsLayout) diff --git a/src/ltrace/ltrace/workflow/workstep/Workstep.py b/src/ltrace/ltrace/workflow/workstep/Workstep.py index c440a69..5217261 100644 --- a/src/ltrace/ltrace/workflow/workstep/Workstep.py +++ b/src/ltrace/ltrace/workflow/workstep/Workstep.py @@ -1,13 +1,14 @@ import qt import slicer from ltrace.slicer.helpers import getSourceVolume -from Customizer import Customizer + +from ltrace.slicer_utils import getResourcePath class Workstep: NAME = None - CHECK_ICON_PATH = Customizer.CHECK_CIRCLE_ICON_PATH - ERROR_ICON_PATH = Customizer.ERROR_CIRCLE_ICON_PATH + CHECK_ICON_PATH = getResourcePath("Icons") / "GreenCheckCircle.png" + ERROR_ICON_PATH = getResourcePath("Icons") / "RedBangCircle.png" INPUT_TYPES = None OUTPUT_TYPE = None diff --git a/src/ltrace/ltrace/workflow/workstep/data/Export.py b/src/ltrace/ltrace/workflow/workstep/data/Export.py index 2cc1253..b872441 100644 --- a/src/ltrace/ltrace/workflow/workstep/data/Export.py +++ b/src/ltrace/ltrace/workflow/workstep/data/Export.py @@ -302,6 +302,7 @@ def setup(self): self.layout().addLayout(self.formLayout) self.exportDirectoryButton = ctk.ctkDirectoryButton() + self.exportDirectoryButton.setMaximumWidth(374) self.exportDirectoryButton.caption = "Export directory" self.formLayout.addRow("Export directory:", self.exportDirectoryButton) self.formLayout.addRow(" ", None) diff --git a/src/ltrace/ltrace/workflow/workstep/data/NetCDFLoader.py b/src/ltrace/ltrace/workflow/workstep/data/NetCDFLoader.py index e67095d..1aa7056 100644 --- a/src/ltrace/ltrace/workflow/workstep/data/NetCDFLoader.py +++ b/src/ltrace/ltrace/workflow/workstep/data/NetCDFLoader.py @@ -107,6 +107,7 @@ def setup(self): self.layout().addLayout(self.form_layout) self.input_directory_button = ctk.ctkDirectoryButton() + self.input_directory_button.setMaximumWidth(374) self.input_directory_button.setToolTip("Load all images from all NetCDF files in this directory.") self.form_layout.addRow("Input directory:", self.input_directory_button) diff --git a/src/ltrace/ltrace/workflow/workstep/segmentation/BoundaryRemoval.py b/src/ltrace/ltrace/workflow/workstep/segmentation/BoundaryRemoval.py index ebe14ff..0d60bf5 100644 --- a/src/ltrace/ltrace/workflow/workstep/segmentation/BoundaryRemoval.py +++ b/src/ltrace/ltrace/workflow/workstep/segmentation/BoundaryRemoval.py @@ -1,5 +1,4 @@ import SimpleITK as sitk -import qt import sitkUtils from SegmentEditorEffects import * from ltrace.workflow.workstep import Workstep, WorkstepWidget diff --git a/src/ltrace/pyproject.toml b/src/ltrace/pyproject.toml index 4618653..84d60c7 100644 --- a/src/ltrace/pyproject.toml +++ b/src/ltrace/pyproject.toml @@ -1,2 +1,2 @@ [build-system] -requires = ["setuptools==59.8.0", "wheel==0.37.0", "Cython==0.29.28"] +requires = ["setuptools==60.2.0", "wheel==0.37.0", "Cython==0.29.28"] diff --git a/src/ltrace/requirements.txt b/src/ltrace/requirements.txt index d9b8774..58dd0eb 100644 --- a/src/ltrace/requirements.txt +++ b/src/ltrace/requirements.txt @@ -7,48 +7,53 @@ dask-image==0.4.0 dask[complete]==2.30.0 distinctipy==1.1.5 dlisio==1.0.1 +drd==0.1.2 h5netcdf==1.1.0 h5py==3.6.0 h5pyd==0.14.1 humanize==3.13.1 -Jinja2==2.11.1 +Jinja2==3.1.4 +ipywidgets==7.6.3 +itkwidgets==0.32.6 joblib==1.1.1 keyring==24.2.0 -keyrings.cryptfile==1.3.9 -lasio @ git+https://github.com/kinverarity1/lasio@b472b1f +keyrings.cryptfile==1.3.9 ; platform_system != "Windows" +lasio @ git+https://github.com/kinverarity1/lasio@b472b1f17f545fed03559e0e4fe26761e6769567 loguru==0.6.0 -markdown2==2.4.2 +markdown2==2.5.0 markupsafe==2.0.1 -matplotlib==3.5.1 +matplotlib==3.9.2 mkl==2022.2.1 -mmcv @ https://download.openmmlab.com/mmcv/dist/cu102/torch1.12.0/mmcv-2.0.0-cp39-cp39-manylinux1_x86_64.whl ; platform_system == "Linux" mmcv @ https://download.openmmlab.com/mmcv/dist/cpu/torch1.12.0/mmcv-2.0.0-cp39-cp39-win_amd64.whl ; platform_system == "Windows" +mmcv @ https://download.openmmlab.com/mmcv/dist/cu102/torch1.12.0/mmcv-2.0.0-cp39-cp39-manylinux1_x86_64.whl ; platform_system == "Linux" mmdet==3.1.0 mmengine==0.8.4 monai==1.1.0 natsort==6.2.0 netCDF4==1.5.4 -numba==0.56.2 -numexpr==2.7.2 +numba==0.60.0 +numexpr==2.8.4 numpy==1.23.1 opencv-contrib-python-headless==4.5.5.64 -scikit-fmm==2022.3.26 # Workaround for failed wheels bulding on porespy package install in linux -pypardiso==0.4.3 # Fix version because they changed the package metadata and defined an invalid string for licence openpnm==3.4.0 -pandas==1.4.2 +pandas==2.2.2 paramiko==3.4.0 pathvalidate==2.5.0 -pint==0.19.2 pint-pandas==0.2 +pint==0.19.2 +plotly==5.18.0 psutil==5.9.0 pyedt==0.1.4 -pygments==2.11.2 +Pygments==2.18.0 +pynrrd==1.0.0 +pypardiso==0.4.3 # Fix version because they changed the package metadata and defined an invalid string for licence pyqtgraph @ git+https://github.com/ltracegeo/pyqtgraph.git@pyqtgraph-0.12.4.2 PySide2==5.15.2 pytesseract==0.3.7 pywin32==306 ; platform_system == "Windows" pyzmq==22.3.0 recordtype==1.3.0 +requests_toolbelt>=1.0.0 sahi==0.11.15 scikit-gstat==1.0.1 scikit-image==0.19.2 @@ -57,21 +62,14 @@ scikit-mps==0.5.0 scipy==1.8.1 shapely==2.0.0 statsmodels==0.14.0 +stopit==1.1.2 +streamlit==1.22.0 sympy==1.10.1 +#tbb>=2021.6.0 tensorflow==2.8.2 toolz==0.11.1 torch==1.12.1 trimesh==3.9.35 +widgetsnbextension==3.5.2 xarray==2022.3.0 zarr==2.5.0 -statsmodels== 0.14.0 -detect_delimiter==0.1.1 -stopit==1.1.2 -streamlit==1.22.0 -pynrrd==1.0.0 -plotly==5.18.0 -itkwidgets==0.32.6 -ipywidgets==7.6.3 -widgetsnbextension==3.5.2 -drd==0.1.2 -requests_toolbelt==1.0.0 diff --git a/src/modules/AppContext/AppContext.py b/src/modules/AppContext/AppContext.py new file mode 100644 index 0000000..4f93332 --- /dev/null +++ b/src/modules/AppContext/AppContext.py @@ -0,0 +1,420 @@ +import itertools +import logging +import shutil +import slicer +import qt +import vtk + +from functools import partial +from ltrace.constants import SaveStatus +from ltrace.slicer.app import updateWindowTitle, getApplicationVersion, parseApplicationVersion, getJsonData +from ltrace.slicer.app.custom_3dview import customize_3d_view +from ltrace.slicer.app.custom_colormaps import customize_color_maps +from ltrace.slicer.app.drawer import ExpandDataDrawer +from ltrace.slicer.app.onboard import showDataLoaders, loadEnvironmentByName +from ltrace.slicer.application_observables import ApplicationObservables +from ltrace.slicer.custom_main_window_event_filter import CustomizerEventFilter +from ltrace.slicer.helpers import BlockSignals, svgToQIcon +from ltrace.slicer.lazy import lazy +from ltrace.slicer.module_info import ModuleInfo +from ltrace.slicer.module_utils import fetchModulesFrom, mapByCategory +from ltrace.slicer.project_manager import ProjectManager, handleCopySuffixOnClonedNodes +from ltrace.slicer.tracking.tracking_manager import TrackingManager +from ltrace.slicer.widget.custom_toolbar_buttons import addMenuRaw, addAction, addActionWidget +from ltrace.slicer.widget.docked_data import DockedData +from ltrace.slicer.widget.fuzzysearch import FuzzySearchDialog, LinearSearchModel +from ltrace.slicer_utils import LTracePlugin, getResourcePath +from ltrace.constants import ImageLogConst +from pathlib import Path +from typing import List, Any, Tuple, Dict + +toBool = slicer.util.toBool + + +class AppContext(LTracePlugin): + SETTING_KEY = "AppContext" + + def __init__(self, parent): + LTracePlugin.__init__(self, parent) + self.parent.title = "App Context" + self.parent.categories = ["System"] + self.parent.dependencies = [] + self.parent.hidden = True + self.parent.contributors = [] + self.parent.helpText = "" + self.parent.acknowledgementText = "" + + #################################################################################### + # Custom properties + #################################################################################### + + self.appData = getJsonData() + + self.__mainWindow = None + self.appVersionString = parseApplicationVersion(self.appData) + self.modulesDir = "" + self.__imageLogLayoutId = ImageLogConst.DEFAULT_LAYOUT_ID_START_VALUE + + self.fuzzySearchModel = LinearSearchModel() + self.fuzzySearch = FuzzySearchDialog(self.fuzzySearchModel, parent=self.mainWindow) + + try: + self.__trackingManager = TrackingManager() + except Exception as e: + self.__trackingManager = None + + self.___projectManager = ProjectManager(folderIconPath=getResourcePath("Icons") / "ProjectIcon.ico") + + self.projectEventsLogic = ProjectEventsLogic(self.___projectManager) + + self.modules = ModuleManager(self) + + self.rightDrawer = ExpandDataDrawer(DockedData()) + + @property + def imageLogLayoutId(self): + return self.__imageLogLayoutId + + @imageLogLayoutId.setter + def imageLogLayoutId(self, lid): + self.__imageLogLayoutId = lid + + @property + def mainWindow(self): + if self.__mainWindow is None: + self.__mainWindow = slicer.util.mainWindow() + + return self.__mainWindow + + def setupObservers(self): + + if self.__trackingManager: + self.__trackingManager.installTrackers() + + self.projectEventsLogic.register() + + qt.QTimer.singleShot(500, lambda: ApplicationObservables().applicationLoadFinished.emit()) + + def getTracker(self): + return self.__trackingManager + + def getAboutGeoSlicer(self) -> str: + """Returns the HTML string that describes GeoSlicer in the about dialog. + + Returns: + str: An HTML-formatted string containing the GeoSlicer's description. + """ + return """ +

+ GeoSlicer is an AI-powered digital rocks platform, developed in collaboration between LTrace, Petrobras, and Equinor. It provides an integrated computational environment for processing digital rocks at all scales, combining machine learning and advanced data processing tools to support geoscientific analysis. +

+ Built on the open-source 3DSlicer platform and powered by the Qt for Open Source Development, +

+ For more information, visit our website or contact us at contact@ltrace.com.br. + """ + + +class ModuleManager: + def __init__(self, context): + self.__ctx = context + self.groups = {} + self.availableModules = {} + self.currentWorkingDataType = None + + def initCache(self, modules): + logging.info(f"Found {len(modules)} available LTrace's modules to load.") + self.availableModules = modules + logging.info("Building reverse index...") + self.groups = mapByCategory(modules.values()) # replace this with the movel below + self.__ctx.fuzzySearchModel.setDataSource(modules) + + def setEnvironment(self, environment: Tuple[str, Any]): + if self.currentWorkingDataType == environment: + return + + self.currentWorkingDataType = environment + ApplicationObservables().environmentChanged.emit() + + def fetchByCategory(self, query, intersectWith=None) -> Dict[str, ModuleInfo]: + if intersectWith: + result = set(self.groups.get(intersectWith, [])) + for category in query: + result.intersection_update(self.groups.get(category, [])) + + else: + result = set(self.groups.get(query[0], [])) + for category in query[1:]: + result.update(self.groups.get(category, [])) + + return {m.key: m for m in result} + + def addToolsMenu(self, toolbar): + tools = [ + "VolumeCalculator", + "CustomizedTables", + "TableFilter", + "Charts", + ] + + toolModules = [self.availableModules[m] for m in tools] + + addMenuRaw( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Table.svg"), + "More tools", + toolModules, + toolbar, + ) + + def addLoadersMenu(self, toolbar): + loaders = [ + "BIAEPBrowser", + "OpenRockData", + "NetCDF", + ] + + toolModules = [self.availableModules[m] for m in loaders] + + addMenuRaw( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Database.svg"), + "Import", + toolModules, + toolbar, + ) + + def showDataLoaders(self, toolbar): + showDataLoaders(toolbar) + + def loadEnvironmentByName(self, toolbar, displayName): + loadEnvironmentByName(toolbar, displayName) + + +class ProjectEventsLogic: + def __init__(self, projectManager): + self.__projectManager = projectManager + self.__customizerEventFilter = None + + self.startCloseSceneObserverHandler = None + self.endCloseSceneObserverHandler = None + self.nodeAddedObserverHandler = None + + def __del__(self): + super().__del__() + slicer.mrmlScene.RemoveObserver(self.startCloseSceneObserverHandler) + slicer.mrmlScene.RemoveObserver(self.endCloseSceneObserverHandler) + slicer.mrmlScene.RemoveObserver(self.nodeAddedObserverHandler) + + def register(self): + self.__projectManager.setup() + self.__projectManager.projectChangedSignal.connect( + partial(updateWindowTitle, versionString=getApplicationVersion()) + ) + + self.__customizerEventFilter = CustomizerEventFilter(saveSceneCallback=self.saveScene) + slicer.modules.AppContextInstance.mainWindow.installEventFilter(self.__customizerEventFilter) + + self.startCloseSceneObserverHandler = slicer.mrmlScene.AddObserver( + slicer.mrmlScene.StartCloseEvent, self.__beginSceneClosing + ) + self.endCloseSceneObserverHandler = slicer.mrmlScene.AddObserver( + slicer.mrmlScene.EndCloseEvent, self.__endSceneClosing + ) + + self.nodeAddedObserverHandler = slicer.mrmlScene.AddObserver( + slicer.mrmlScene.NodeAddedEvent, self.__onNodeAdded + ) + + self.endImportSceneObserverHandler = slicer.mrmlScene.AddObserver( + slicer.mrmlScene.EndImportEvent, self.__onEndImportEvent + ) + + lazy.register_eye_event() + self.__projectManager.projectChangedSignal.connect(lazy.register_eye_event) + + self.setupRecentlyLoadedMenu() + + def loadScene(self): + fileDialog = qt.QFileDialog( + slicer.modules.AppContextInstance.mainWindow, + "Load a scene", + "", + "GeoSlicer scene file (*.mrml)", + ) + try: + if fileDialog.exec(): + paths = fileDialog.selectedFiles() + projectFilePath = paths[0] + status = self.__projectManager.load(projectFilePath) + if not status: + slicer.util.errorDisplay( + "An error occurred while loading the project. Please check the GeoSlicer log file.", + "Failed to load project", + ) + self.setupRecentlyLoadedMenu() + return True + return False + finally: + fileDialog.deleteLater() + + def saveScene(self): + """Save current scene/project + + Returns: + bool: True if scene was saved successfully, otherwise returns False + """ + url = slicer.mrmlScene.GetURL() + if url == "": + return self.saveSceneAs() + + status = self.__projectManager.save(url) + + if status == SaveStatus.FAILED: + slicer.util.errorDisplay( + "An error occurred while saving the project. Please check the following:\n\n" + + "1. Ensure that there is sufficient disk space available.\n" + + "2. Verify that you have the necessary file writing permissions.\n\n" + + "For further details, please consult the GeoSlicer log file. If the problem persists, consider reaching out to support.", + "Failed to save project", + ) + + return status + + def saveSceneAs(self): + """Handles save button clicked on save scene as dialog""" + # Save directory + path = qt.QFileDialog.getSaveFileName( + slicer.modules.AppContextInstance.mainWindow, + "Save project", + slicer.app.defaultScenePath, + "GeoSlicer project folder (*)", + "", + qt.QFileDialog.DontConfirmOverwrite, + ) + + if not path: + return SaveStatus.CANCELLED # Nothing to do + + status = self.__projectManager.saveAs(path) + + if status == SaveStatus.FAILED: + slicer.util.errorDisplay( + "An error occurred while saving the project. Please check the following:\n\n" + + "1. Ensure that there is sufficient disk space available.\n" + + "2. Verify that you have the necessary file writing permissions.\n\n" + + "For further details, please consult the GeoSlicer log file. If the problem persists, consider reaching out to support.", + "Failed to save project", + ) + + failedProjectpath = Path(path) + if failedProjectpath.exists(): + shutil.rmtree(failedProjectpath, ignore_errors=True) + + return status + + def onCloseScene(self): + """Handle close scene event""" + + def wrapper(save=False): + if save: + status = self.saveScene() + if status != SaveStatus.SUCCEED: + # saveScene handles possible errors and warns the user + return + + if status == SaveStatus.IN_PROGRESS: + logging.debug("Unexpected state from the saving process.") + return + + self.__projectManager.close() + updateWindowTitle(versionString=getApplicationVersion()) + + mainWindow = slicer.modules.AppContextInstance.mainWindow + isModified = mainWindow.isWindowModified() + if not isModified: + wrapper(save=False) + return + + messageBox = qt.QMessageBox(mainWindow) + messageBox.setWindowTitle("Close scene") + messageBox.setIcon(messageBox.Warning) + messageBox.setText("Save the changes before closing the scene?") + saveButton = messageBox.addButton("&Save and Close", qt.QMessageBox.AcceptRole) + dismissButton = messageBox.addButton("Close &without Saving", qt.QMessageBox.RejectRole) + cancelButton = messageBox.addButton("&Cancel", qt.QMessageBox.ResetRole) + messageBox.exec_() + + if messageBox.clickedButton() == cancelButton: + return + + shouldSave = messageBox.clickedButton() == saveButton + wrapper(save=shouldSave) + + def __beginSceneClosing(self, *args): + """Handle the beginning of the scene closing process""" + + selectedModule = slicer.util.moduleSelector().selectedModule + + # Switch to another module so exit() gets called for the current module + # and then switch back to the original module and restore layout + layout = slicer.app.layoutManager().layout + slicer.util.selectModule(selectedModule) + slicer.app.layoutManager().setLayout(layout) + + def __endSceneClosing(self, *args): + """Handle the end of the scene closing process""" + + customize_3d_view() + customize_color_maps() + + @vtk.calldata_type(vtk.VTK_OBJECT) + def __onNodeAdded(self, caller, eventId, callData): + if isinstance(callData, slicer.vtkMRMLSegmentationDisplayNode): + display_node = callData + display_node.SetOpacity(0.50) + display_node.SetOpacity2DFill(1.00) + display_node.Visibility2DOutlineOff() + display_node.SetOpacity2DOutline(0.00) + display_node.SetOpacity3D(1.00) + self.__noInterpolate() + + if callData and callData.IsA("vtkMRMLVolumeArchetypeStorageNode"): + handleCopySuffixOnClonedNodes(callData) + + def __noInterpolate(self, *args): + for node in slicer.util.getNodes("*").values(): + if node.IsA("vtkMRMLScalarVolumeDisplayNode") or node.IsA("vtkMRMLVectorVolumeDisplayNode"): + node.SetInterpolate(0) + + def onRecentLoadedActionTriggered(self, sender: qt.QAction, state: bool) -> None: + fileParameters = sender.property("fileParameters") + fileType = fileParameters.get("fileType") + fileName = fileParameters.get("fileName") + + if not fileName: + return + + status = self.__projectManager.load(fileName) + if not status: + slicer.util.errorDisplay( + "An error occurred while loading the project. Please check the GeoSlicer log file.", + "Failed to load project", + ) + self.setupRecentlyLoadedMenu() + + def setupRecentlyLoadedMenu(self) -> None: + """Method to install project manager load method into the recently loaded project actions.""" + fileMenu = slicer.modules.AppContextInstance.mainWindow.findChild("QMenu", "FileMenu") + recentMenu = fileMenu.findChild("QMenu", "RecentlyLoadedMenu") + + for action in recentMenu.actions(): + if action.text == "Clear History": + continue + + try: + action.triggered.disconnect() + action.triggered.connect(partial(self.onRecentLoadedActionTriggered, action)) + except Exception as error: + logging.error(error) + + def __onEndImportEvent(self, *args, **kwargs) -> None: + # Assure method is called after the file history update from the last project load. + qt.QTimer.singleShot(10, self.setupRecentlyLoadedMenu) diff --git a/src/modules/AzimuthShiftTool/AzimuthShiftTool.py b/src/modules/AzimuthShiftTool/AzimuthShiftTool.py index d576556..ab5db8f 100644 --- a/src/modules/AzimuthShiftTool/AzimuthShiftTool.py +++ b/src/modules/AzimuthShiftTool/AzimuthShiftTool.py @@ -23,7 +23,7 @@ class AzimuthShiftTool(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Azimuth Shift Tool" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "ImageLog", "Multiscale"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = AzimuthShiftTool.help() diff --git a/src/modules/AzimuthShiftTool/Resources/Icons/AzimuthShiftTool.svg b/src/modules/AzimuthShiftTool/Resources/Icons/AzimuthShiftTool.svg new file mode 100644 index 0000000..e371412 --- /dev/null +++ b/src/modules/AzimuthShiftTool/Resources/Icons/AzimuthShiftTool.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/BoundaryRemovalEffect/BoundaryRemovalEffect.py b/src/modules/BoundaryRemovalEffect/BoundaryRemovalEffect.py index d72c84d..1780694 100644 --- a/src/modules/BoundaryRemovalEffect/BoundaryRemovalEffect.py +++ b/src/modules/BoundaryRemovalEffect/BoundaryRemovalEffect.py @@ -1,6 +1,5 @@ import os -import slicer from ltrace.slicer_utils import LTracePlugin diff --git a/src/modules/BoundaryRemovalEffect/BoundaryRemovalEffectLib/SegmentEditorEffect.py b/src/modules/BoundaryRemovalEffect/BoundaryRemovalEffectLib/SegmentEditorEffect.py index 546faec..32587dc 100644 --- a/src/modules/BoundaryRemovalEffect/BoundaryRemovalEffectLib/SegmentEditorEffect.py +++ b/src/modules/BoundaryRemovalEffect/BoundaryRemovalEffectLib/SegmentEditorEffect.py @@ -301,7 +301,7 @@ def setEnabledSegmentationButtons(self, enabled: bool): def createCursor(self, widget): # Turn off effect-specific cursor for this effect - return slicer.util.mainWindow().cursor + return slicer.modules.AppContextInstance.mainWindow.cursor def sourceVolumeNodeChanged(self): # Set scalar range of master volume image data to threshold slider diff --git a/src/modules/CTAutoRegistration/CTAutoRegistration.py b/src/modules/CTAutoRegistration/CTAutoRegistration.py index 0066f86..bb6df07 100644 --- a/src/modules/CTAutoRegistration/CTAutoRegistration.py +++ b/src/modules/CTAutoRegistration/CTAutoRegistration.py @@ -27,8 +27,8 @@ class CTAutoRegistration(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "CT Auto Registration" - self.parent.categories = ["Registration"] + self.parent.title = "MicroCT Auto Registration" + self.parent.categories = ["Registration", "MicroCT"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = CTAutoRegistration.help() diff --git a/src/modules/CTAutoRegistration/Resources/Icons/CTAutoRegistration.svg b/src/modules/CTAutoRegistration/Resources/Icons/CTAutoRegistration.svg new file mode 100644 index 0000000..98c1389 --- /dev/null +++ b/src/modules/CTAutoRegistration/Resources/Icons/CTAutoRegistration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/Charts/Charts.py b/src/modules/Charts/Charts.py index 04bf20c..1aba6ec 100644 --- a/src/modules/Charts/Charts.py +++ b/src/modules/Charts/Charts.py @@ -5,13 +5,14 @@ from ltrace.slicer.node_attributes import NodeEnvironment from ltrace.slicer_utils import * from pathlib import Path + from Plots.Crossplot.CrossplotWidget import CrossplotWidget from Plots.BarPlot.BarPlotBuilder import BarPlotBuilder from Plots.Windrose.WindrosePlotBuilder import WindrosePlotBuilder from Plots.Crossplot.CrossplotPlotBuilder import CrossplotBuilder from Plots.HistogramInDepthPlot.HistogramInDepthPlotBuilder import HistogramInDepthPlotBuilder from Plots.HistogramPlot.HistogramPlotBuilder import HistogramPlotBuilder -from DLISImportLib.DLISImportLogic import WELL_NAME_TAG +from ltrace.constants import DLISImportConst import numpy as np from ltrace.slicer.helpers import createTemporaryNode, removeTemporaryNodes from ltrace.slicer_utils import dataframeFromTable, dataFrameToTableNode @@ -48,7 +49,7 @@ class Charts(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Charts" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "Charts", "MicroCT"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] # replace with "Firstname Lastname (Organization)" self.parent.helpText = Charts.help() @@ -119,7 +120,7 @@ def setup(self): parametersFormLayout.addRow(self.plotButton) # connections - self.plotButton.connect("clicked(bool)", self.onPlotButtonClicked) + self.plotButton.clicked.connect(self.onPlotButtonClicked) self.plotTypeComboBox.currentTextChanged.connect(self.__onPlotTypeComboBoxChanged) # Add vertical spacer @@ -163,7 +164,6 @@ def __showNewPlotWidgetDialog(self): dialog.setWindowFlags(dialog.windowFlags() & ~qt.Qt.WindowContextHelpButtonHint) dialog.setWindowTitle("New plot") dialog.setWindowIcon(qt.QIcon(str(self.WINDOWN_ICON))) - dialog.setFixedSize(200, 60) # Question Layout formLayout = qt.QFormLayout() @@ -194,6 +194,7 @@ def okButtonClicked(): buttonsLayout = qt.QHBoxLayout() buttonsLayout.addWidget(okButton) + buttonsLayout.addSpacing(10) buttonsLayout.addWidget(cancelButton) formLayout.addRow(buttonsLayout) formLayout.setVerticalSpacing(10) @@ -235,7 +236,9 @@ def __handle_plot_type_creation(self, plotType, plotLabel): plotWidget.show() except (RuntimeError, Exception) as error: logging.warning(error) - slicer.util.errorDisplay(text=INCOMPATIBLE_MESSAGE, parent=slicer.util.mainWindow()) + slicer.util.errorDisplay( + text=INCOMPATIBLE_MESSAGE, parent=slicer.modules.AppContextInstance.mainWindow + ) plotWidget.deleteLater() plotWidget = None else: @@ -289,7 +292,6 @@ def onPlotButtonClicked(self): self.plot(nodes) def plot(self, nodes): - failures = [node.GetName() for node in nodes if isVarDescriptor(node)] if failures: @@ -297,7 +299,7 @@ def plot(self, nodes): "The nodes below cannot be plotted because its format is not compatible with the chosen chart type.\n" ) message += "\n".join([f" - {name}" for name in failures]) - slicer.util.errorDisplay(text=message, parent=slicer.util.mainWindow()) + slicer.util.errorDisplay(text=message, parent=slicer.modules.AppContextInstance.mainWindow) return selectedPlotWidgetLabel = self.plotWidgetsComboBox.currentText @@ -323,12 +325,13 @@ def plot(self, nodes): except (ValueError, RuntimeError) as error: if self.__plotWidgets.get(selectedPlotWidgetLabel) is None and selectPlotWidget is not None: selectPlotWidget.deleteLater() - slicer.util.errorDisplay(text=error, parent=slicer.util.mainWindow()) + logging.warning(error) + slicer.util.errorDisplay(text=error, parent=slicer.modules.AppContextInstance.mainWindow) except Exception as error: - # traceback.print_exc() if self.__plotWidgets.get(selectedPlotWidgetLabel) is None and selectPlotWidget is not None: selectPlotWidget.deleteLater() - slicer.util.errorDisplay(text=INCOMPATIBLE_MESSAGE, parent=slicer.util.mainWindow()) + logging.warning(error) + slicer.util.errorDisplay(text=INCOMPATIBLE_MESSAGE, parent=slicer.modules.AppContextInstance.mainWindow) else: self.__plotWidgets[selectedPlotWidgetLabel] = selectPlotWidget self.__populatePlotWidgetsComboBox() @@ -352,7 +355,7 @@ def wrapWidget(self): import PythonQt import shiboken2 - self.pyqtwidget = PythonQt.Qt.QWidget(slicer.util.mainWindow()) + self.pyqtwidget = PythonQt.Qt.QWidget(slicer.modules.AppContextInstance.mainWindow) self.pysidewidget = shiboken2.wrapInstance(hash(self.pyqtwidget), QWidget) return self.pysidewidget @@ -374,7 +377,7 @@ def getNodesMergedByWell(nodes): wellsIndexesNodes = [] # tables from wells need to be merged into a multi-colun table per well wells = [] for node in nodes: - wellName = node.GetAttribute(WELL_NAME_TAG) + wellName = node.GetAttribute(DLISImportConst.WELL_NAME_TAG) wellsIndexesNodes.append( { "WellName": wellName, diff --git a/src/modules/Charts/Plots/BarPlot/BarPlotWidget.py b/src/modules/Charts/Plots/BarPlot/BarPlotWidget.py index 8db7662..337e7b5 100644 --- a/src/modules/Charts/Plots/BarPlot/BarPlotWidget.py +++ b/src/modules/Charts/Plots/BarPlot/BarPlotWidget.py @@ -1,7 +1,7 @@ -from .AngleAxisItem import AngleAxisItem from .BarPlotWidgetModel import BarPlotWidgetModel from ..BasePlotWidget import BasePlotWidget from ltrace.slicer.helpers import segmentListAndProportionsFromSegmentation +from ltrace.slicer.widget.customized_pyqtgraph.AngleAxisItem import AngleAxisItem from pyqtgraph.Qt import QtGui, QtCore diff --git a/src/modules/Charts/Plots/Crossplot/CrossplotWidget.py b/src/modules/Charts/Plots/Crossplot/CrossplotWidget.py index e083f33..295bb53 100644 --- a/src/modules/Charts/Plots/Crossplot/CrossplotWidget.py +++ b/src/modules/Charts/Plots/Crossplot/CrossplotWidget.py @@ -12,15 +12,15 @@ from ltrace.slicer import helpers, ui from ltrace.slicer.equations.equation_base import EquationBase from ltrace.slicer.equations.fit_data import FitData +from ltrace.slicer.widget.data_plot_widget import DataPlotWidget from ltrace.slicer.widget.help_button import HelpButton from matplotlib import cm as matplotlibcm from pint import Unit, UndefinedUnitError, DefinitionSyntaxError from pint_pandas import PintArray -from Plots.BasePlotWidget import BasePlotWidget -from Plots.Crossplot.data_table_widget import DataTableWidget -from Plots.Crossplot.data_plot_widget import DataPlotWidget -from Plots.Crossplot.equations.line import Line -from Plots.Crossplot.equations.timur_coates import TimurCoates +from ..BasePlotWidget import BasePlotWidget +from .data_table_widget import DataTableWidget +from .equations.line import Line +from .equations.timur_coates import TimurCoates from pyqtgraph.Qt import QtGui, QtCore RESOURCES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "Resources") @@ -77,8 +77,8 @@ class UnitConversionWidget(qt.QWidget): unitsChanged = qt.Signal() - def __init__(self): - super().__init__() + def __init__(self, parent=None): + super().__init__(parent) self.__lastUnits = None self.__currentUnits = None @@ -199,7 +199,6 @@ def setupUi(self): parametersLayout = QtGui.QVBoxLayout() parametersWidget.setLayout(parametersLayout) plot_layout = QtGui.QVBoxLayout() - # Data table widget self.__tableWidget = DataTableWidget() self.__tableWidget.signal_style_changed.connect(self.__updatePlot) @@ -207,14 +206,12 @@ def setupUi(self): self.__tableWidget.signal_all_style_changed.connect(self.__updateAllDataStyles) self.__tableWidget.signal_all_visible_changed.connect(self.__updateAllDataVisibility) parametersLayout.addWidget(self.__tableWidget) - # Plot widget self.dataPlotWidget = DataPlotWidget() self.dataPlotWidget.toggleLegendSignal.connect(self.__toggleLegend) plot_layout.addWidget(self.dataPlotWidget.widget) plot_layout.addStretch() - # X axis # Histogram options self.__xAxisHistogramEnableCheckBox = QtGui.QCheckBox() @@ -246,9 +243,12 @@ def setupUi(self): xAxisParameterLayout = QtGui.QFormLayout() xAxisParameterLayout.addRow("Parameter", self.__xAxisComboBox) xAxisParameterLayout.setHorizontalSpacing(8) - # Layout self.__xAxisGridLayout = QtGui.QGridLayout() + self.__xAxisGroupBox = QtGui.QGroupBox("X axis") + self.__xAxisGroupBox.setLayout(self.__xAxisGridLayout) + parametersLayout.addWidget(self.__xAxisGroupBox) + self.__xAxisGridLayout.setHorizontalSpacing(5) self.__xAxisGridLayout.addLayout(xAxisParameterLayout, 0, 0, 1, -1) self.__xAxisGridLayout.addWidget(xHistogramCheckBoxLabel, 1, 0, 1, 1) @@ -261,12 +261,7 @@ def setupUi(self): self.__xAxisGridLayout.addWidget( shiboken2.wrapInstance(hash(self.__xUnitConversion), QtGui.QWidget), 2, 0, 1, 4 ) - # Groupbox - self.__xAxisGroupBox = QtGui.QGroupBox("X axis") - self.__xAxisGroupBox.setLayout(self.__xAxisGridLayout) - - parametersLayout.addWidget(self.__xAxisGroupBox) # Y axis # Histogram options @@ -302,6 +297,10 @@ def setupUi(self): # Layout self.__yAxisGridLayout = QtGui.QGridLayout() + self.__yAxisGroupBox = QtGui.QGroupBox("Y axis") + self.__yAxisGroupBox.setLayout(self.__yAxisGridLayout) + parametersLayout.addWidget(self.__yAxisGroupBox) + self.__yAxisGridLayout.setHorizontalSpacing(5) self.__yAxisGridLayout.addLayout(yAxisParameterLayout, 0, 0, 1, -1) self.__yAxisGridLayout.addWidget(yHistogramCheckBoxLabel, 1, 0, 1, 1) @@ -314,12 +313,7 @@ def setupUi(self): self.__yAxisGridLayout.addWidget( shiboken2.wrapInstance(hash(self.__yUnitConversion), QtGui.QWidget), 2, 0, 1, 4 ) - # Groupbox - self.__yAxisGroupBox = QtGui.QGroupBox("Y axis") - self.__yAxisGroupBox.setLayout(self.__yAxisGridLayout) - - parametersLayout.addWidget(self.__yAxisGroupBox) # Z axis self.__zAxisComboBox = QtGui.QComboBox() @@ -334,52 +328,45 @@ def setupUi(self): self.__colorMapComboBox.setIconSize(QtCore.QSize(80, 20)) self.__populateColorMapComboBox() - self.__zUnitConversion = UnitConversionWidget() - # Manual/Auto range widgets layout - range_layout = QtGui.QHBoxLayout() - range_layout.setSpacing(5) - auto_range_layout = QtGui.QHBoxLayout() - auto_range_layout.setSpacing(5) - auto_range_layout.addWidget(QtGui.QLabel("Auto Range")) - auto_range_layout.addWidget(self.__autoRangeCheckBox) - range_layout.addLayout(auto_range_layout) - minimum_layout = QtGui.QHBoxLayout() - minimum_layout.setSpacing(5) - minimum_layout.addWidget(QtGui.QLabel("Min")) - minimum_layout.addWidget(self.__ZMinValueRangeDoubleSpinBox) - range_layout.addLayout(minimum_layout) - maximum_layout = QtGui.QHBoxLayout() - maximum_layout.setSpacing(5) - maximum_layout.addWidget(QtGui.QLabel("Max")) - maximum_layout.addWidget(self.__ZMaxValueRangeDoubleSpinBox) - range_layout.addLayout(maximum_layout) + rangeLayout = QtGui.QHBoxLayout() + rangeLayout.setSpacing(5) + autoRangeLayout = QtGui.QHBoxLayout() + autoRangeLayout.setSpacing(5) + autoRangeLayout.addWidget(QtGui.QLabel("Auto Range")) + autoRangeLayout.addWidget(self.__autoRangeCheckBox) + rangeLayout.addLayout(autoRangeLayout) + minimumLayout = QtGui.QHBoxLayout() + minimumLayout.setSpacing(5) + minimumLayout.addWidget(QtGui.QLabel("Min")) + minimumLayout.addWidget(self.__ZMinValueRangeDoubleSpinBox) + rangeLayout.addLayout(minimumLayout) + maximumLayout = QtGui.QHBoxLayout() + maximumLayout.setSpacing(5) + maximumLayout.addWidget(QtGui.QLabel("Max")) + maximumLayout.addWidget(self.__ZMaxValueRangeDoubleSpinBox) + rangeLayout.addLayout(maximumLayout) + self.__zUnitConversion = UnitConversionWidget() formLayout = QtGui.QFormLayout() - formLayout.addRow("Parameter", self.__zAxisComboBox) - formLayout.addRow(range_layout) - formLayout.addRow("Color map", self.__colorMapComboBox) - formLayout.addRow(shiboken2.wrapInstance(hash(self.__zUnitConversion), QtGui.QWidget)) - zStyleGroupBox = QtGui.QGroupBox("Z axis") zStyleGroupBox.setLayout(formLayout) - parametersLayout.addWidget(zStyleGroupBox) + formLayout.addRow("Parameter", self.__zAxisComboBox) + formLayout.addRow(rangeLayout) + formLayout.addRow("Color map", self.__colorMapComboBox) + formLayout.addRow(shiboken2.wrapInstance(hash(self.__zUnitConversion), QtGui.QWidget)) # Settings self.__settingsGroupBox = QtGui.QGroupBox("Settings") - self.__themeComboBox = QtGui.QComboBox() for themeName in self.dataPlotWidget.themes: self.__themeComboBox.addItem(themeName) - self.__embeddedLegendVisibilityCheckBox = QtGui.QCheckBox() self.__embeddedLegendVisibilityCheckBox.setChecked(self.dataPlotWidget.embeddedLegendVisibility) self.__embeddedLegendVisibilityCheckBox.stateChanged.connect(self.__onEmbeddedLegendVisibilityChange) - settingsFormLayout = QtGui.QFormLayout() settingsFormLayout.setHorizontalSpacing(8) - settingsFormLayout.addRow("Theme", self.__themeComboBox) settingsFormLayout.addRow("Show legend", self.__embeddedLegendVisibilityCheckBox) self.__settingsGroupBox.setLayout(settingsFormLayout) @@ -444,8 +431,12 @@ def setupUi(self): def graphDataList(self): return list(self.__graphDataList) - def __createFitTab(self): - ## New fit widget + def __createFitTab(self) -> qt.QFrame: + """Creates a tab for the curve fitting functionality. + + Returns: + qt.QFrame: a QFrame containing the layout of the tab. + """ self.__fitDataInputComboBox = qt.QComboBox() self.__fitEquationComboBox = qt.QComboBox() @@ -574,6 +565,8 @@ def __onImportClicked(self): importedVolume.SetAttribute("table_type", "equation") self.appendData(importedVolume) + fileDialog.delete() + def __onExportClicked(self, functionCurveName): fitData = self.__getFitData(functionCurveName) path = qt.QFileDialog.getSaveFileName( diff --git a/src/modules/Charts/Resources/Icons/Charts.png b/src/modules/Charts/Resources/Icons/Charts.png deleted file mode 100644 index 7c3c02b..0000000 Binary files a/src/modules/Charts/Resources/Icons/Charts.png and /dev/null differ diff --git a/src/modules/Charts/Resources/Icons/Charts.svg b/src/modules/Charts/Resources/Icons/Charts.svg new file mode 100644 index 0000000..f7738bb --- /dev/null +++ b/src/modules/Charts/Resources/Icons/Charts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ColorThresholdEffect/ColorThresholdEffectLib/SegmentEditorEffect.py b/src/modules/ColorThresholdEffect/ColorThresholdEffectLib/SegmentEditorEffect.py index 3832a2d..9ca6b2f 100644 --- a/src/modules/ColorThresholdEffect/ColorThresholdEffectLib/SegmentEditorEffect.py +++ b/src/modules/ColorThresholdEffect/ColorThresholdEffectLib/SegmentEditorEffect.py @@ -283,7 +283,7 @@ def setupOptionsFrame(self): def createCursor(self, widget): # Turn off effect-specific cursor for this effect - return slicer.util.mainWindow().cursor + return slicer.modules.AppContextInstance.mainWindow.cursor def sourceVolumeNodeChanged(self): pass diff --git a/src/modules/ConnectivityEffect/ConnectivityEffectLib/SegmentEditorEffect.py b/src/modules/ConnectivityEffect/ConnectivityEffectLib/SegmentEditorEffect.py index fb7113a..2bbb0b9 100644 --- a/src/modules/ConnectivityEffect/ConnectivityEffectLib/SegmentEditorEffect.py +++ b/src/modules/ConnectivityEffect/ConnectivityEffectLib/SegmentEditorEffect.py @@ -111,7 +111,7 @@ def deactivate(self): def createCursor(self, widget): # Turn off effect-specific cursor for this effect - return slicer.util.mainWindow().cursor + return slicer.modules.AppContextInstance.mainWindow.cursor def onHopsChanged(self, hops): if self.scriptedEffect.parameterSetNode() is None: diff --git a/src/modules/CoreEnv/CoreEnv.py b/src/modules/CoreEnv/CoreEnv.py index 02789d7..19ef1a0 100644 --- a/src/modules/CoreEnv/CoreEnv.py +++ b/src/modules/CoreEnv/CoreEnv.py @@ -1,16 +1,11 @@ import os from pathlib import Path -import qt import slicer -from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget -from CTAutoRegistration import CTAutoRegistration -from CustomizedCropVolume import CustomizedCropVolume -from CustomizedData import CustomizedData -from Multicore import Multicore -from MulticoreTransforms import MulticoreTransforms -from SegmentationEnv import SegmentationEnv +from ltrace.slicer.helpers import svgToQIcon +from ltrace.slicer.widget.custom_toolbar_buttons import addAction, addMenu +from ltrace.slicer_utils import LTracePlugin, LTracePluginLogic, LTraceEnvironmentMixin, getResourcePath class CoreEnv(LTracePlugin): @@ -21,70 +16,63 @@ class CoreEnv(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Core Environment" - self.parent.categories = ["Environments"] + self.parent.categories = ["Environment", "Core"] self.parent.dependencies = [] + self.parent.hidden = True self.parent.contributors = ["LTrace Geophysical Solutions"] - self.parent.helpText = ( - CoreEnv.help() - + CustomizedData.help() - + Multicore.help() - + MulticoreTransforms.help() - + CustomizedCropVolume.help() - + SegmentationEnv.help() - ) + self.parent.helpText = "" + self.environment = CoreEnvLogic() @classmethod def readme_path(cls): return str(cls.MODULE_DIR / "README.md") -class CoreEnvWidget(LTracePluginWidget): - def __init__(self, parent) -> None: - super().__init__(parent) - self.lastAccessedWidget = None - - def setup(self): - LTracePluginWidget.setup(self) - self.mainTab = qt.QTabWidget() - self.layout.addWidget(self.mainTab) - - # Data tab - dataTab = qt.QTabWidget() - dataTab.addTab(slicer.modules.customizeddata.createNewWidgetRepresentation(), "Explorer") - dataTab.addTab(slicer.modules.multicore.createNewWidgetRepresentation(), "CT Import") - dataTab.addTab(slicer.modules.corephotographloader.createNewWidgetRepresentation(), "Photo Import (beta)") - dataTab.addTab(slicer.modules.multicoreexport.createNewWidgetRepresentation(), "Export") - - self.mainTab.addTab(dataTab, "Data") - self.mainTab.addTab(slicer.modules.multicoretransforms.createNewWidgetRepresentation(), "Transforms") - self.mainTab.addTab(slicer.modules.customizedcropvolume.createNewWidgetRepresentation(), "Crop") - self.segmentationEnv = slicer.modules.segmentationenv.createNewWidgetRepresentation() - self.mainTab.addTab(self.segmentationEnv, "Segmentation") - self.mainTab.addTab(slicer.modules.coreinpaint.createNewWidgetRepresentation(), "Inpaint") - - self.lastAccessedWidget = dataTab.widget(0) - - self.segmentationEnv.self().segmentEditorWidget.self().selectParameterNodeByTag(CoreEnv.SETTING_KEY) - - # Start connections - self.mainTab.tabBarClicked.connect(self.onMainTabClicked) - - def onMainTabClicked(self, index): - self.lastAccessedWidget.exit() - self.lastAccessedWidget = self.mainTab.widget(index) - if type(self.lastAccessedWidget) is qt.QTabWidget: - self.lastAccessedWidget = self.lastAccessedWidget.currentWidget() - self.lastAccessedWidget.enter() - - def enter(self) -> None: - super().enter() - if self.lastAccessedWidget is None: - return - - self.lastAccessedWidget.enter() - - def exit(self): - if self.lastAccessedWidget is None: - return +class CoreEnvLogic(LTracePluginLogic, LTraceEnvironmentMixin): + def __init__(self): + super().__init__() + self.__modulesToolbar = None + + @property + def modulesToolbar(self): + if not self.__modulesToolbar: + raise AttributeError("Modules toolbar not set") + return self.__modulesToolbar + + @modulesToolbar.setter + def modulesToolbar(self, value): + self.__modulesToolbar = value + + def setupEnvironment(self): + relatedModules = self.getModuleManager().fetchByCategory([self.category]) + + addAction(relatedModules["CustomizedData"], self.modulesToolbar) + addAction(relatedModules["Multicore"], self.modulesToolbar) + addAction(relatedModules["CorePhotographLoader"], self.modulesToolbar) + addAction(relatedModules["CustomizedCropVolume"], self.modulesToolbar) + + addAction(relatedModules["MulticoreTransforms"], self.modulesToolbar) + self.setupSegmentation() + addAction(relatedModules["CoreInpaint"], self.modulesToolbar) + addAction(relatedModules["MulticoreExport"], self.modulesToolbar) + + self.getModuleManager().setEnvironment(("Core", "CoreEnv")) + + def setupSegmentation(self): + modules = self.getModuleManager().fetchByCategory(("Core",), intersectWith="Segmentation") + + addMenu( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Layers.svg"), + "Segmentation", + [ + modules["CustomizedSegmentEditor"], + modules["Segmenter"], + modules["SegmentInspector"], + modules["LabelMapEditor"], + modules["PoreStats"], + ], + self.modulesToolbar, + ) - self.lastAccessedWidget.exit() + segmentEditor = slicer.util.getModuleWidget("CustomizedSegmentEditor") + segmentEditor.configureEffects() diff --git a/src/modules/CoreInpaint/CoreInpaint.py b/src/modules/CoreInpaint/CoreInpaint.py index 0a2c68e..b192da4 100644 --- a/src/modules/CoreInpaint/CoreInpaint.py +++ b/src/modules/CoreInpaint/CoreInpaint.py @@ -22,7 +22,7 @@ class CoreInpaint(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Core Inpaint" - self.parent.categories = ["Core"] + self.parent.categories = ["Core", "ImageLog", "Multiscale"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = CoreInpaint.help() @@ -47,6 +47,7 @@ def setup(self): self.outputNameField = qt.QLineEdit() self.outputNameField.setToolTip("Name of the output volume") + self.outputNameField.objectName = "Output Name Line Edit" self.progressBar = qt.QProgressBar() @@ -86,15 +87,16 @@ def onApplyClicked(self): if not selectedSegments: highlight_error(self.inputWidget.segmentListGroup[1]) + self.applyButton.enabled = True return labelMapNode = createTemporaryVolumeNode(slicer.vtkMRMLLabelMapVolumeNode, "mask") labelMapNode.CopyContent(segmentNode) slicer.modules.segmentations.logic().ExportVisibleSegmentsToLabelmapNode(segmentNode, labelMapNode, imageNode) - outNode = slicer.vtkSlicerVolumesLogic().CloneVolume( - slicer.mrmlScene, imageNode, self.outputNameField.text, False - ) + outNode = slicer.vtkSlicerVolumesLogic().CloneVolume(slicer.mrmlScene, imageNode, "clonedNode", False) + outNode.SetName(slicer.mrmlScene.GenerateUniqueName(self.outputNameField.text)) + outNode.Modified() try: self.logic.apply(imageNode, labelMapNode, selectedSegments, outNode, self.onProgress) @@ -106,6 +108,8 @@ def onApplyClicked(self): slicer.util.errorDisplay(str(err)) raise + finally: + self.applyButton.enabled = True self.progressBar.setValue(100) self.onReferenceSelected(self.inputWidget.referenceInput.currentNode(), updateName=False) diff --git a/src/modules/CoreInpaint/Resources/Icons/CoreInpaint.png b/src/modules/CoreInpaint/Resources/Icons/CoreInpaint.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/CoreInpaint/Resources/Icons/CoreInpaint.png and /dev/null differ diff --git a/src/modules/CoreInpaint/Resources/Icons/CoreInpaint.svg b/src/modules/CoreInpaint/Resources/Icons/CoreInpaint.svg new file mode 100644 index 0000000..19c79d6 --- /dev/null +++ b/src/modules/CoreInpaint/Resources/Icons/CoreInpaint.svg @@ -0,0 +1 @@ + diff --git a/src/modules/CorePhotographLoader/CorePhotographLoader.py b/src/modules/CorePhotographLoader/CorePhotographLoader.py index adbd443..3f1fca0 100644 --- a/src/modules/CorePhotographLoader/CorePhotographLoader.py +++ b/src/modules/CorePhotographLoader/CorePhotographLoader.py @@ -9,6 +9,7 @@ ) from ltrace.slicer.cli_queue import CliQueue from ltrace.slicer.widget.global_progress_bar import LocalProgressBar +from ltrace.utils.callback import Callback from pathlib import Path import ctk @@ -21,13 +22,13 @@ class CorePhotographLoader(LTracePlugin): - SETTING_KEY = "MultipleImageAnalysis" + SETTING_KEY = "CorePhotographLoader" MODULE_DIR = Path(os.path.dirname(os.path.realpath(__file__))) def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Core Photograph Loader" # TODO make this more human readable by adding spaces - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "Core"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] # replace with "Firstname Lastname (Organization)" self.parent.helpText = CorePhotographLoader.help() @@ -63,6 +64,7 @@ def setup(self): # Directory selection widget self.directory_selector = ctk.ctkDirectoryButton() + self.directory_selector.setMaximumWidth(374) self.directory_selector.caption = "Export directory" self.directory_selector.directoryChanged.connect(self.on_directory_input_changed) parameters_form_layout.addRow("Input folder:", self.directory_selector) @@ -335,7 +337,7 @@ def run(self, data: dict, cli_progress_bar): ) self.__cli_queue.create_cli_node(slicer.modules.corephotographloadercli, cli_config, modified_callback) - self.__cli_queue.signal_queue_finished.connect(self._on_process_finished) + self.__cli_queue.signal_queue_finished.connect(self._on__process_finished) self.__cli_queue.run() self.process_started.emit() diff --git a/src/modules/CorePhotographLoader/Resources/Icons/CorePhotographLoader.png b/src/modules/CorePhotographLoader/Resources/Icons/CorePhotographLoader.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/CorePhotographLoader/Resources/Icons/CorePhotographLoader.png and /dev/null differ diff --git a/src/modules/CorePhotographLoader/Resources/Icons/CorePhotographLoader.svg b/src/modules/CorePhotographLoader/Resources/Icons/CorePhotographLoader.svg new file mode 100644 index 0000000..3339f5a --- /dev/null +++ b/src/modules/CorePhotographLoader/Resources/Icons/CorePhotographLoader.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/CorePluggingExporter/CorePluggingExporter.py b/src/modules/CorePluggingExporter/CorePluggingExporter.py index 35549eb..7f76b42 100644 --- a/src/modules/CorePluggingExporter/CorePluggingExporter.py +++ b/src/modules/CorePluggingExporter/CorePluggingExporter.py @@ -23,7 +23,7 @@ class CorePluggingExporter(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Core Plugging Exporter" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] # replace with "Firstname Lastname (Organization)" self.parent.helpText = "" diff --git a/src/modules/CustomResampleScalarVolume/CustomResampleScalarVolume.py b/src/modules/CustomResampleScalarVolume/CustomResampleScalarVolume.py index fee8038..1f2c1ff 100644 --- a/src/modules/CustomResampleScalarVolume/CustomResampleScalarVolume.py +++ b/src/modules/CustomResampleScalarVolume/CustomResampleScalarVolume.py @@ -40,8 +40,8 @@ class CustomResampleScalarVolume(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "LTrace Resample Scalar Volume" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Resample" + self.parent.categories = ["Tools", "MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = CustomResampleScalarVolume.help() diff --git a/src/modules/CustomResampleScalarVolume/Resources/Icons/CustomResampleScalarVolume.png b/src/modules/CustomResampleScalarVolume/Resources/Icons/CustomResampleScalarVolume.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/CustomResampleScalarVolume/Resources/Icons/CustomResampleScalarVolume.png and /dev/null differ diff --git a/src/modules/CustomResampleScalarVolume/Resources/Icons/CustomResampleScalarVolume.svg b/src/modules/CustomResampleScalarVolume/Resources/Icons/CustomResampleScalarVolume.svg new file mode 100644 index 0000000..e1a5ea0 --- /dev/null +++ b/src/modules/CustomResampleScalarVolume/Resources/Icons/CustomResampleScalarVolume.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/CustomizedCropVolume/CustomizedCropVolume.py b/src/modules/CustomizedCropVolume/CustomizedCropVolume.py index be9f8d4..25f0889 100644 --- a/src/modules/CustomizedCropVolume/CustomizedCropVolume.py +++ b/src/modules/CustomizedCropVolume/CustomizedCropVolume.py @@ -7,9 +7,11 @@ import slicer import vtk +from ltrace.slicer import ui from ltrace.slicer.helpers import bounds2size, copy_display from ltrace.slicer_utils import * -from ltrace.slicer.node_observer import NodeObserver +from ltrace.slicer_utils import getResourcePath +from ltrace.utils.callback import Callback try: from Test.CustomizedCropVolumeTest import CustomizedCropTest @@ -25,11 +27,11 @@ class CustomizedCropVolume(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Customized Crop Volume" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Volumes Crop" + self.parent.categories = ["Tools", "MicroCT", "Thin Section", "Core", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] - self.parent.helpText = CustomizedCropVolume.help() + self.parent.helpText = f"file:///{(getResourcePath('manual') / 'Modules/Thin_section/Crop.html').as_posix()}" @classmethod def readme_path(cls): @@ -107,18 +109,16 @@ def setup(self): parametersFormLayout.addRow(" ", None) - self.cropButton = qt.QPushButton("Crop") - self.cropButton.setFixedHeight(40) - self.cropButton.clicked.connect(self.onCropButtonClicked) - - self.cancelButton = qt.QPushButton("Cancel") - self.cancelButton.setFixedHeight(40) - self.cancelButton.clicked.connect(self.onCancelButtonClicked) - - buttonsHBoxLayout = qt.QHBoxLayout() - buttonsHBoxLayout.addWidget(self.cropButton) - buttonsHBoxLayout.addWidget(self.cancelButton) - loadFormLayout.addRow(buttonsHBoxLayout) + self.applyCancelButtons = ui.ApplyCancelButtons( + onApplyClick=self.onCropButtonClicked, + onCancelClick=self.onCancelButtonClicked, + applyTooltip="Crop", + cancelTooltip="Cancel", + applyText="Crop", + cancelText="Cancel", + enabled=True, + ) + loadFormLayout.addWidget(self.applyCancelButtons) statusLabel = qt.QLabel("Status: ") self.currentStatusLabel = qt.QLabel("Idle") @@ -208,12 +208,18 @@ def enter(self) -> None: self.logic.roi = slicer.mrmlScene.AddNewNodeByClass(slicer.vtkMRMLMarkupsROINode.__name__, "Crop ROI") self.logic.roi.SetDisplayVisibility(False) self.logic.roi.GetDisplayNode().SetFillOpacity(0.5) - self.roiObserver = NodeObserver(node=self.logic.roi, parent=None) - self.roiObserver.modifiedSignal.connect(self.onRoiModified) + self.roiObserver = self.logic.roi.AddObserver( + slicer.vtkMRMLDisplayableNode.DisplayModifiedEvent, self.onRoiModified + ) + # self.roiObserver = NodeObserver(node=self.logic.roi, parent=None) + # self.roiObserver.modifiedSignal.connect(self.onRoiModified) def exit(self): + if self.roiObserver: + self.logic.roi.RemoveObserver(self.roiObserver) self.roiObserver = None slicer.mrmlScene.RemoveNode(self.logic.roi) + self.logic.roi = None def updateStatus(self, message, progress=None, processEvents=True): self.progressBar.show() @@ -238,11 +244,6 @@ def cleanup(self): self.exit() -class Callback(object): - def __init__(self, on_update=None): - self.on_update = on_update or (lambda *args, **kwargs: None) - - class CustomizedCropVolumeLogic(LTracePluginLogic): def __init__(self): LTracePluginLogic.__init__(self) @@ -264,7 +265,8 @@ def initializeVolume(self, volume): def crop(self, volume, ijkSize): position_ras = [0] * 3 - self.roi.GetXYZ(position_ras) + if self.roi is not None: + self.roi.GetXYZ(position_ras) ras_to_ijk = vtk.vtkMatrix4x4() volume.GetRASToIJKMatrix(ras_to_ijk) @@ -300,14 +302,16 @@ def crop(self, volume, ijkSize): slicer.util.setSliceViewerLayers(background=croppedVolume, fit=True) copy_display(volume, croppedVolume) - self.roi.SetDisplayVisibility(False) + if self.roi is not None: + self.roi.SetDisplayVisibility(False) self.lastCroppedVolume = croppedVolume def getCroppedSize(self, volume): position = [0] * 3 radius = [0] * 3 - self.roi.GetRadiusXYZ(radius) - self.roi.GetXYZ(position) + if self.roi is not None: + self.roi.GetRadiusXYZ(radius) + self.roi.GetXYZ(position) volumeExtents = [0] * 6 volume.GetRASBounds(volumeExtents) @@ -327,7 +331,8 @@ def getCroppedSize(self, volume): def setRoiSizeIjk(self, volume, ijkSize): spacing = volume.GetSpacing() rasSize = tuple(ijkDim * spacingDim / 2 for ijkDim, spacingDim in zip(ijkSize, spacing)) - self.roi.SetRadiusXYZ(rasSize) + if self.roi is not None: + self.roi.SetRadiusXYZ(rasSize) class CropInfo(RuntimeError): diff --git a/src/modules/CustomizedCropVolume/Resources/Icons/CustomizedCropVolume.png b/src/modules/CustomizedCropVolume/Resources/Icons/CustomizedCropVolume.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/CustomizedCropVolume/Resources/Icons/CustomizedCropVolume.png and /dev/null differ diff --git a/src/modules/CustomizedCropVolume/Resources/Icons/CustomizedCropVolume.svg b/src/modules/CustomizedCropVolume/Resources/Icons/CustomizedCropVolume.svg new file mode 100644 index 0000000..ee34d04 --- /dev/null +++ b/src/modules/CustomizedCropVolume/Resources/Icons/CustomizedCropVolume.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/CustomizedCurvatureAnisotropicDiffusion/CustomizedCurvatureAnisotropicDiffusion.py b/src/modules/CustomizedCurvatureAnisotropicDiffusion/CustomizedCurvatureAnisotropicDiffusion.py index cc3b60e..8bb0663 100644 --- a/src/modules/CustomizedCurvatureAnisotropicDiffusion/CustomizedCurvatureAnisotropicDiffusion.py +++ b/src/modules/CustomizedCurvatureAnisotropicDiffusion/CustomizedCurvatureAnisotropicDiffusion.py @@ -21,7 +21,7 @@ class CustomizedCurvatureAnisotropicDiffusion(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Curvature Anisotropic Diffusion" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = CustomizedCurvatureAnisotropicDiffusion.help() diff --git a/src/modules/CustomizedData/CustomizedData.py b/src/modules/CustomizedData/CustomizedData.py index 829a30d..ade42be 100644 --- a/src/modules/CustomizedData/CustomizedData.py +++ b/src/modules/CustomizedData/CustomizedData.py @@ -26,8 +26,8 @@ class CustomizedData(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Customized Data" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Explorer" + self.parent.categories = ["Project", "MicroCT", "Thin Section", "Core", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = CustomizedData.help() @@ -64,7 +64,12 @@ def setup(self): self.subjectHierarchyTreeView = dataWidget.findChild(qt.QObject, "SubjectHierarchyTreeView") self.subjectHierarchyTreeView.setEditTriggers(qt.QAbstractItemView.NoEditTriggers) - self.subjectHierarchyTreeView.hideColumn(3) + + def showSearchPopup(current): + if current.column() == 0: + slicer.modules.AppContextInstance.fuzzySearch.exec_() + + self.subjectHierarchyTreeView.doubleClicked.connect(showSearchPopup) # Adds confirmation step before delete action nodeMenu = self.subjectHierarchyTreeView.findChild(qt.QMenu, "nodeMenuTreeView") @@ -92,7 +97,7 @@ def confirmDeleteSelectedItems(): ) self.subjectHierarchyTreeView.setMinimumHeight(310) - self.subjectHierarchyTreeView.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Minimum) + self.subjectHierarchyTreeView.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum) self.formLayout.addRow(self.subjectHierarchyTreeView) self.scalarVolumeWidget = ScalarVolumeWidget(isLabelMap=False) diff --git a/src/modules/CustomizedData/Resources/Icons/CustomizedData.png b/src/modules/CustomizedData/Resources/Icons/CustomizedData.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/CustomizedData/Resources/Icons/CustomizedData.png and /dev/null differ diff --git a/src/modules/CustomizedData/Resources/Icons/CustomizedData.svg b/src/modules/CustomizedData/Resources/Icons/CustomizedData.svg new file mode 100644 index 0000000..bc5899b --- /dev/null +++ b/src/modules/CustomizedData/Resources/Icons/CustomizedData.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/CustomizedGaussianBlurImageFilter/CustomizedGaussianBlurImageFilter.py b/src/modules/CustomizedGaussianBlurImageFilter/CustomizedGaussianBlurImageFilter.py index f43f1e4..0a1a87d 100644 --- a/src/modules/CustomizedGaussianBlurImageFilter/CustomizedGaussianBlurImageFilter.py +++ b/src/modules/CustomizedGaussianBlurImageFilter/CustomizedGaussianBlurImageFilter.py @@ -24,7 +24,7 @@ class CustomizedGaussianBlurImageFilter(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Gaussian Blur Image Filter" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = CustomizedGaussianBlurImageFilter.help() diff --git a/src/modules/CustomizedGradientAnisotropicDiffusion/CustomizedGradientAnisotropicDiffusion.py b/src/modules/CustomizedGradientAnisotropicDiffusion/CustomizedGradientAnisotropicDiffusion.py index 9920d31..e8e732c 100644 --- a/src/modules/CustomizedGradientAnisotropicDiffusion/CustomizedGradientAnisotropicDiffusion.py +++ b/src/modules/CustomizedGradientAnisotropicDiffusion/CustomizedGradientAnisotropicDiffusion.py @@ -53,7 +53,7 @@ class CustomizedGradientAnisotropicDiffusion(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Gradient Anisotropic Diffusion" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = CustomizedGradientAnisotropicDiffusion.help() diff --git a/src/modules/CustomizedMedianImageFilter/CustomizedMedianImageFilter.py b/src/modules/CustomizedMedianImageFilter/CustomizedMedianImageFilter.py index f8d1b67..42456e9 100644 --- a/src/modules/CustomizedMedianImageFilter/CustomizedMedianImageFilter.py +++ b/src/modules/CustomizedMedianImageFilter/CustomizedMedianImageFilter.py @@ -22,7 +22,7 @@ class CustomizedMedianImageFilter(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Median Image Filter" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = CustomizedMedianImageFilter.help() diff --git a/src/modules/CustomizedSegmentEditor/CustomizedEffects/Margin/SegmentEditorMarginEffect.py b/src/modules/CustomizedSegmentEditor/CustomizedEffects/Margin/SegmentEditorMarginEffect.py index 2fc86e6..8672694 100644 --- a/src/modules/CustomizedSegmentEditor/CustomizedEffects/Margin/SegmentEditorMarginEffect.py +++ b/src/modules/CustomizedSegmentEditor/CustomizedEffects/Margin/SegmentEditorMarginEffect.py @@ -73,7 +73,7 @@ def setupOptionsFrame(self): def createCursor(self, widget): # Turn off effect-specific cursor for this effect - return slicer.util.mainWindow().cursor + return slicer.modules.AppContextInstance.mainWindow.cursor def setMRMLDefaults(self): self.scriptedEffect.setParameterDefault("MarginSizePixels", 3) diff --git a/src/modules/CustomizedSegmentEditor/CustomizedSegmentEditor.py b/src/modules/CustomizedSegmentEditor/CustomizedSegmentEditor.py index 14b609d..82e75e7 100644 --- a/src/modules/CustomizedSegmentEditor/CustomizedSegmentEditor.py +++ b/src/modules/CustomizedSegmentEditor/CustomizedSegmentEditor.py @@ -1,18 +1,16 @@ import os from pathlib import Path +import qSlicerSegmentationsEditorEffectsPythonQt +import qSlicerSegmentationsModuleWidgetsPythonQt import qt import slicer +from distinctipy import distinctipy from slicer.util import VTKObservationMixin -import qSlicerSegmentationsEditorEffectsPythonQt -import qSlicerSegmentationsModuleWidgetsPythonQt -from distinctipy import distinctipy -from ltrace.slicer.application_observables import ApplicationObservables -from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget from CustomizedEffects.Margin import SegmentEditorMarginEffect from CustomizedEffects.Threshold import SegmentEditorThresholdEffect - +from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, getResourcePath # Checks if closed source code is available try: @@ -30,10 +28,12 @@ class CustomizedSegmentEditor(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Manual Segmentation" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "Segmentation", "Thin Section", "ImageLog", "Core", "MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] - self.parent.helpText = CustomizedSegmentEditor.help() + self.parent.helpText = ( + f"file:///{(getResourcePath('manual') / 'Modules/Thin_section/SegmentEditor.html').as_posix()}" + ) @classmethod def readme_path(cls): @@ -114,15 +114,10 @@ def setup(self): self.configureEffects() - ApplicationObservables().applicationLoadFinished.connect(self.__onApplicationLoadFinished) - - def __onApplicationLoadFinished(self): - # Connect observers to scene events self.addObserver(slicer.mrmlScene, slicer.mrmlScene.StartCloseEvent, self.onSceneStartClose) self.addObserver(slicer.mrmlScene, slicer.mrmlScene.EndCloseEvent, self.onSceneEndClose) self.addObserver(slicer.mrmlScene, slicer.mrmlScene.EndImportEvent, self.onSceneEndImport) self.activateEditorRegisteredCallback() - ApplicationObservables().applicationLoadFinished.disconnect() def activateEditorRegisteredCallback(self): self.effectFactorySingleton.effectRegistered.connect(self.editorEffectRegistered) @@ -148,6 +143,8 @@ def onSourceVolumeNodeChanged(self, node): self.configureColorSupport(color_support=color_support) def configureEffectsForThinSectionEnvironment(self): + self.selectParameterNodeByTag("ThinSectionEnv") + self.editor.setEffectNameOrder( [ "Paint", @@ -162,7 +159,9 @@ def configureEffectsForThinSectionEnvironment(self): "Mask Image", "Connectivity", "Level tracing", + "Smart foreground", "Color threshold", + "Boundary removal", ] ) self.editor.unorderedEffectsVisible = False @@ -188,6 +187,7 @@ def configureEffects(self, color_support=False): "Boundary removal", "Expand segments", "Sample segmentation", + "Smart foreground", ] if color_support: effects.append("Color threshold") @@ -282,4 +282,3 @@ def cleanup(self): super().cleanup() self.removeObservers() self.deactivateEditorRegisteredCallback() - self.__applicationObservables.applicationLoadFinished.disconnect() diff --git a/src/modules/CustomizedSegmentEditor/Resources/Icons/CustomizedSegmentEditor.svg b/src/modules/CustomizedSegmentEditor/Resources/Icons/CustomizedSegmentEditor.svg new file mode 100644 index 0000000..7ad384b --- /dev/null +++ b/src/modules/CustomizedSegmentEditor/Resources/Icons/CustomizedSegmentEditor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/CustomizedSmoothingEffect/CustomizedSmoothingEffectLib/SegmentEditorSmoothingEffect.py b/src/modules/CustomizedSmoothingEffect/CustomizedSmoothingEffectLib/SegmentEditorSmoothingEffect.py index 92939c3..5f9c9ec 100644 --- a/src/modules/CustomizedSmoothingEffect/CustomizedSmoothingEffectLib/SegmentEditorSmoothingEffect.py +++ b/src/modules/CustomizedSmoothingEffect/CustomizedSmoothingEffectLib/SegmentEditorSmoothingEffect.py @@ -13,17 +13,41 @@ from ltrace.slicer.helpers import getSourceVolume from pathlib import Path -from scipy import ndimage from SegmentEditorEffects import * +from scipy import ndimage +from slicer.i18n import tr as _ from vtk.util import numpy_support +def _batch_sum_conv_3d(array, kernel_size): + assert array.ndim == 4 + maxValue = kernel_size[0] * kernel_size[1] * kernel_size[2] + if maxValue <= 255: + array = array.astype(np.uint8) + elif maxValue <= 65535: + array = array.astype(np.uint16) + else: + array = array.astype(np.uint32) + + kernel_x = np.ones(kernel_size[2], dtype=array.dtype) + kernel_y = np.ones(kernel_size[1], dtype=array.dtype) + kernel_z = np.ones(kernel_size[0], dtype=array.dtype) if kernel_size[0] > 1 else None + + array = ndimage.convolve1d(array, kernel_x, axis=1, mode="reflect") + array = ndimage.convolve1d(array, kernel_y, axis=2, mode="reflect") + if kernel_z is not None: + array = ndimage.convolve1d(array, kernel_z, axis=3, mode="reflect") + + return array + + class SegmentEditorSmoothingEffect(AbstractScriptedSegmentEditorPaintEffect): """SmoothingEffect is an Effect that smoothes a selected segment""" def __init__(self, scriptedEffect): - scriptedEffect.name = "Smoothing" AbstractScriptedSegmentEditorPaintEffect.__init__(self, scriptedEffect) + scriptedEffect.name = "Smoothing" + scriptedEffect.title = _("Smoothing") def clone(self): import qSlicerSegmentationsEditorEffectsPythonQt as effects @@ -42,12 +66,10 @@ def helpText(self): return """Make segment boundaries smoother
by removing extrusions and filling small holes. The effect can be either applied locally (by painting in viewers) or to the whole segment (by clicking Apply button). Available methods:

""" def setupOptionsFrame(self): @@ -56,7 +78,7 @@ def setupOptionsFrame(self): self.methodSelectorComboBox.addItem("Opening (remove extrusions)", MORPHOLOGICAL_OPENING) self.methodSelectorComboBox.addItem("Fill holes", MORPHOLOGICAL_CLOSING) self.methodSelectorComboBox.addItem("Gaussian", GAUSSIAN) - self.methodSelectorComboBox.addItem("Joint smoothing", JOINT_TAUBIN) + # self.methodSelectorComboBox.addItem("Joint smoothing", JOINT_TAUBIN) self.scriptedEffect.addLabeledOptionsWidget("Smoothing method:", self.methodSelectorComboBox) self.kernelSizeMMSpinBox = slicer.qMRMLSpinBox() @@ -155,8 +177,8 @@ def updateParameterWidgetsVisibility(self): self.gaussianStandardDeviationMMSpinBox.setVisible(smoothingMethod == GAUSSIAN) self.jointTaubinSmoothingFactorLabel.setVisible(smoothingMethod == JOINT_TAUBIN) self.jointTaubinSmoothingFactorSlider.setVisible(smoothingMethod == JOINT_TAUBIN) - self.applyToAllVisibleSegmentsLabel.setVisible(smoothingMethod != JOINT_TAUBIN) - self.applyToAllVisibleSegmentsCheckBox.setVisible(smoothingMethod != JOINT_TAUBIN) + self.applyToAllVisibleSegmentsLabel.setVisible(smoothingMethod == MEDIAN) + self.applyToAllVisibleSegmentsCheckBox.setVisible(smoothingMethod == MEDIAN) def getKernelSizePixel(self): selectedSegmentLabelmapSpacing = [1.0, 1.0, 1.0] @@ -238,11 +260,6 @@ def showStatusMessage(self, msg, timeoutMsec=500): def onApply(self, maskImage=None, maskExtent=None): """maskImage: contains nonzero where smoothing will be applied""" smoothingMethod = self.scriptedEffect.parameter("SmoothingMethod") - applyToAllVisibleSegments = ( - int(self.scriptedEffect.parameter("ApplyToAllVisibleSegments")) != 0 - if self.scriptedEffect.parameter("ApplyToAllVisibleSegments") - else False - ) if smoothingMethod != JOINT_TAUBIN: # Make sure the user wants to do the operation, even if the segment is not visible @@ -258,27 +275,6 @@ def onApply(self, maskImage=None, maskExtent=None): return if smoothingMethod == JOINT_TAUBIN: self.smoothMultipleSegments(maskImage, maskExtent) - elif applyToAllVisibleSegments: - # Smooth all visible segments - inputSegmentIDs = vtk.vtkStringArray() - segmentationNode = self.scriptedEffect.parameterSetNode().GetSegmentationNode() - segmentationNode.GetDisplayNode().GetVisibleSegmentIDs(inputSegmentIDs) - segmentEditorWidget = slicer.modules.segmenteditor.widgetRepresentation().self().editor - segmentEditorNode = segmentEditorWidget.mrmlSegmentEditorNode() - # store which segment was selected before operation - selectedStartSegmentID = segmentEditorNode.GetSelectedSegmentID() - if inputSegmentIDs.GetNumberOfValues() == 0: - logging.info("Smoothing operation skipped: there are no visible segments.") - return - for index in range(inputSegmentIDs.GetNumberOfValues()): - segmentID = inputSegmentIDs.GetValue(index) - self.showStatusMessage( - f"Smoothing {segmentationNode.GetSegmentation().GetSegment(segmentID).GetName()}..." - ) - segmentEditorNode.SetSelectedSegmentID(segmentID) - self.smoothSelectedSegment(maskImage, maskExtent) - # restore segment selection - segmentEditorNode.SetSelectedSegmentID(selectedStartSegmentID) else: self.smoothSelectedSegment(maskImage, maskExtent) finally: @@ -417,44 +413,65 @@ def smoothSelectedSegment(self, maskImage=None, maskExtent=None): clippedSelectedSegmentLabelmap = selectedSegmentLabelmap if smoothingMethod == MEDIAN: - # Median filter does not require a particular label value + applyToVisible = ( + int(self.scriptedEffect.parameter("ApplyToAllVisibleSegments")) != 0 + if self.scriptedEffect.parameter("ApplyToAllVisibleSegments") + else False + ) if maskExtent: smoothingFilter = vtk.vtkImageMedian3D() smoothingFilter.SetInputData(clippedSelectedSegmentLabelmap) + elif applyToVisible: + useVtk = False + + segmentationNode = self.scriptedEffect.parameterSetNode().GetSegmentationNode() + visibleSegmentIds = vtk.vtkStringArray() + segmentationNode.GetDisplayNode().GetVisibleSegmentIDs(visibleSegmentIds) + + nSegments = visibleSegmentIds.GetNumberOfValues() + if nSegments == 0: + return + + segmentIds = [visibleSegmentIds.GetValue(i) for i in range(nSegments)] + firstSegment = slicer.util.arrayFromSegmentBinaryLabelmap(segmentationNode, segmentIds[0]) > 0 + + batches = np.zeros((nSegments + 1, *firstSegment.shape), dtype=bool) + batches[0] = ~firstSegment + batches[1] = firstSegment + + for i, segmentId in enumerate(segmentIds[1:]): + array = slicer.util.arrayFromSegmentBinaryLabelmap(segmentationNode, segmentId) > 0 + # 0 is background, 1 was already set + batches[i + 2] = array + batches[0] &= ~array + batches = _batch_sum_conv_3d(batches, kernel_size=kernelSizePixel) + labelmap_array = np.argmax(batches, axis=0) + + for i, segmentId in enumerate(segmentIds): + array = (labelmap_array == (i + 1)).astype(np.uint8) + selectedSegmentLabelmap.GetPointData().SetScalars(numpy_support.numpy_to_vtk(array.ravel())) + self.scriptedEffect.modifySegmentByLabelmap( + segmentationNode, + segmentId, + selectedSegmentLabelmap, + slicer.qSlicerSegmentEditorAbstractEffect.ModificationModeSet, + ) else: useVtk = False scalars = selectedSegmentLabelmap.GetPointData().GetScalars() - dims = selectedSegmentLabelmap.GetDimensions() - array = numpy_support.vtk_to_numpy(scalars).reshape(dims, order="F") + dims = selectedSegmentLabelmap.GetDimensions()[::-1] # Fortran to C order + array = numpy_support.vtk_to_numpy(scalars).reshape(dims) array = array > 0 + array = _batch_sum_conv_3d(array[np.newaxis, ...], kernel_size=kernelSizePixel) maxValue = kernelSizePixel[0] * kernelSizePixel[1] * kernelSizePixel[2] - if maxValue <= 255: - array = array.astype(np.uint8) - elif maxValue <= 65535: - array = array.astype(np.uint16) - else: - array = array.astype(np.uint32) - - kernel_x = np.ones(kernelSizePixel[0], dtype=array.dtype) - kernel_y = np.ones(kernelSizePixel[1], dtype=array.dtype) - kernel_z = np.ones(kernelSizePixel[2], dtype=array.dtype) - - array = ndimage.convolve1d(array, kernel_x, axis=0, mode="constant", cval=0) - array = ndimage.convolve1d(array, kernel_y, axis=1, mode="constant", cval=0) - array = ndimage.convolve1d(array, kernel_z, axis=2, mode="constant", cval=0) - threshold = maxValue // 2 array = (array > threshold).astype(np.uint8) - selectedSegmentLabelmap.GetPointData().SetScalars( - numpy_support.numpy_to_vtk(array.ravel(order="F")) - ) - bypassMasking = True + selectedSegmentLabelmap.GetPointData().SetScalars(numpy_support.numpy_to_vtk(array.ravel())) self.scriptedEffect.modifySelectedSegmentByLabelmap( selectedSegmentLabelmap, slicer.qSlicerSegmentEditorAbstractEffect.ModificationModeSet, - bypassMasking, ) else: # We need to know exactly the value of the segment voxels, apply threshold to make force the selected label value @@ -472,10 +489,11 @@ def smoothSelectedSegment(self, maskImage=None, maskExtent=None): if not maskImage and smoothingMethod == MORPHOLOGICAL_CLOSING: # Invert + islands + invert pipeline - bypassMasking = True - selectedSegmentLabelmap = self.scriptedEffect.selectedSegmentLabelmap() - invertedSelectedSegmentLabelmap = self.getInvertedBinaryLabelmap(selectedSegmentLabelmap) + originalLabelmap = slicer.vtkOrientedImageData() + originalLabelmap.ShallowCopy(selectedSegmentLabelmap) + invertedSelectedSegmentLabelmap = self.getInvertedBinaryLabelmap(originalLabelmap) + bypassMasking = True self.scriptedEffect.modifySelectedSegmentByLabelmap( invertedSelectedSegmentLabelmap, slicer.qSlicerSegmentEditorAbstractEffect.ModificationModeSet, @@ -506,9 +524,19 @@ def smoothSelectedSegment(self, maskImage=None, maskExtent=None): slicer.mrmlScene.RemoveNode(segmentEditorNode) selectedSegmentLabelmap = self.scriptedEffect.selectedSegmentLabelmap() - invertedSelectedSegmentLabelmap = self.getInvertedBinaryLabelmap(selectedSegmentLabelmap) + resultLabelmap = self.getInvertedBinaryLabelmap(selectedSegmentLabelmap) + + # Selected segment was used as a buffer + # Now we restore it without masking and apply the result with masking + bypassMasking = True self.scriptedEffect.modifySelectedSegmentByLabelmap( - invertedSelectedSegmentLabelmap, + originalLabelmap, + slicer.qSlicerSegmentEditorAbstractEffect.ModificationModeSet, + bypassMasking, + ) + bypassMasking = False + self.scriptedEffect.modifySelectedSegmentByLabelmap( + resultLabelmap, slicer.qSlicerSegmentEditorAbstractEffect.ModificationModeSet, bypassMasking, ) diff --git a/src/modules/CustomizedTables/CustomizedTables.py b/src/modules/CustomizedTables/CustomizedTables.py index fd5d758..977855d 100644 --- a/src/modules/CustomizedTables/CustomizedTables.py +++ b/src/modules/CustomizedTables/CustomizedTables.py @@ -7,7 +7,10 @@ import numpy as np import qt import slicer + +from ltrace.slicer.helpers import svgToQIcon from ltrace.slicer_utils import * +from ltrace.slicer_utils import getResourcePath RESOURCES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Resources", "Icons") CHARTS_ICON_PATH = os.path.join(RESOURCES_PATH, "Charts.png") @@ -22,7 +25,7 @@ class CustomizedTables(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Tables" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = CustomizedTables.help() @@ -83,7 +86,7 @@ def fixInitialDataType(node): plotButton = plotLayoutItem.widget() plotButton.setVisible(False) - icon = qt.QIcon(str(CHARTS_ICON_PATH)) + icon = svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Charts.svg") self.chartsButton = qt.QToolButton() self.chartsButton.text = "Charts" self.chartsButton.icon = icon diff --git a/src/modules/CustomizedTables/Resources/Icons/CustomizedTables.png b/src/modules/CustomizedTables/Resources/Icons/CustomizedTables.png deleted file mode 100644 index 6dcc840..0000000 Binary files a/src/modules/CustomizedTables/Resources/Icons/CustomizedTables.png and /dev/null differ diff --git a/src/modules/CustomizedTables/Resources/Icons/CustomizedTables.svg b/src/modules/CustomizedTables/Resources/Icons/CustomizedTables.svg new file mode 100644 index 0000000..32edfb3 --- /dev/null +++ b/src/modules/CustomizedTables/Resources/Icons/CustomizedTables.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/DLISImport/DLISImport.py b/src/modules/DLISImport/DLISImport.py deleted file mode 100644 index 7a51000..0000000 --- a/src/modules/DLISImport/DLISImport.py +++ /dev/null @@ -1,269 +0,0 @@ -import pickle -import re -import time -from pathlib import Path -from queue import Queue -from threading import Thread - -import numpy as np -import qt -import slicer -from dlisio import dlis as dlisio -from ltrace.slicer_utils import * - -import DLISImportLib - -try: - from Test.DLISImportTest import DLISImportTest -except ImportError: - DLISImportTest = None # tests not deployed to final version or closed source - - -class DLISImport(LTracePlugin): - - SETTING_KEY = "DLISImport" - - def __init__(self, parent): - super().__init__(parent) - self.parent.title = "Image Log Loader" - self.parent.categories = ["Image Log"] - self.parent.dependencies = [] - self.parent.contributors = ["LTrace Geophysical Solutions"] - self.parent.helpText = """This module loads volumes from DLIS, LAS, CSV or PDF files into slicer as volumes.""" - self.parent.acknowledgementText = """""" - - -class DLISImportLogic(LTracePluginLogic): - def add_volume(self, top_folder, folder, name, domain, image, well_diameter_mm): - total_circumference_millimeters = np.pi * well_diameter_mm - vertical_spacing_millimeters = (domain[0] - domain[-1]) / image.shape[0] - horizontal_spacing_millimeters = total_circumference_millimeters / image.shape[1] - - image = image.reshape(image.shape[0], 1, image.shape[1]) - read_volume = self._add_volume_from_data(top_folder, folder, name, image) - - read_volume.SetSpacing(horizontal_spacing_millimeters, 0.48, vertical_spacing_millimeters) - read_volume.SetOrigin( - -total_circumference_millimeters / 2, - 0, - -int(domain[-0]), - ) - - return read_volume - - def _add_volume_from_data(self, root_folder, folder, name, data): - volume_node = slicer.vtkMRMLScalarVolumeNode() - volume_node.SetName(name) - slicer.mrmlScene.AddNode(volume_node) - slicer.util.updateVolumeFromArray(volume_node, data) - - subject_hierarchy = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) - - root_folder_id = subject_hierarchy.GetItemByName(root_folder) - if root_folder_id == 0: - top_level_id = subject_hierarchy.GetSceneItemID() - root_folder_id = subject_hierarchy.CreateFolderItem(top_level_id, root_folder) - - folder_id = subject_hierarchy.GetItemByName(folder) - if folder_id == 0: - folder_id = subject_hierarchy.CreateFolderItem(root_folder_id, folder) - - subject_hierarchy.CreateItem(folder_id, volume_node) - - return volume_node - - def load_volumes( - self, file_path, mnemonic_and_files, should_stop_check, progress_callback, add_volume_callback, well_diameter_mm - ): - def get_mnemonic_search_exact_re(mnemonic): - return "^{}$".format(re.escape(mnemonic)) - - progress_callback("Opening file", 0) - - filesystem_filename = Path(file_path).stem - - def stopped(): - will_stop = should_stop_check() - if will_stop: - progress_callback("Stopped", len(mnemonic_and_files) + 2) - - return will_stop - - with dlisio.load(file_path) as files: - id_to_file = {} - for f in files: - id_to_file[f.fileheader.id] = f - - if stopped(): - return - - print("matching here") - - for i, (mnemonic, file) in enumerate(mnemonic_and_files): - for c in id_to_file[file].match(get_mnemonic_search_exact_re(mnemonic)): - curve_name = "{} [{}]".format(mnemonic, c.units) - progress_name = "{} - {}".format(curve_name, file) - progress_callback(progress_name, i + 1) - - image = c.curves() - - if stopped(): - return - - if len(image.shape) == 1: - image = image.reshape(-1, 1) - domain_channel = c.frame.channels[0] - domain = domain_channel.curves() - domain = domain * self._conversion_factor_to_millimeters(domain_channel.units) - - add_volume_callback(filesystem_filename, file, curve_name, domain, image, well_diameter_mm) - - if stopped(): - return - - progress_callback("Finished", len(mnemonic_and_files) + 2) - - def _conversion_factor_to_millimeters(self, unit): - to_mm_factors = { - "um": 0.001, - "mm": 1, - "cm": 10, - "dm": 100, - "m": 1000, - "km": 1000000, - "in": 25.4, - "ft": 304.8, - } - - parts = unit.strip().split(" ") - - if not (1 <= len(parts) <= 2 and parts[-1] in to_mm_factors): - raise ValueError("Unknown unit: {}".format(unit)) - - unit = parts[-1] - fraction = 1 - if len(parts) == 2: - fraction = float(parts[0]) - - return fraction * to_mm_factors[unit] - - -class DLISImportWidget(LTracePluginWidget): - def __init__(self, parent) -> None: - super().__init__(parent) - self.logic = DLISImportLogic() - - def setup(self): - super().setup() - - self.dlis_widget = DLISImportLib.WellLogImportWidget() - self.dlis_widget.loadClicked = self._on_load_clicked - - frame = qt.QFrame() - self.layout.addWidget(frame) - loadFormLayout = qt.QFormLayout(frame) - loadFormLayout.setLabelAlignment(qt.Qt.AlignRight) - loadFormLayout.setContentsMargins(0, 0, 0, 0) - - loadFormLayout.addRow(self.dlis_widget) - - if slicer_is_in_developer_mode(): - self.reload_last_button = qt.QPushButton("Reload last configuration") - self.reload_last_button.clicked.connect(self._on_reload_last_button_clicked) - - self.reload_last_button.setEnabled(self._get_last_load_options() is not None) - self.layout.addWidget(self.reload_last_button) - - def _on_load_clicked(self, mnemonic_and_files): - well_diameter = float(self.dlis_widget.wellDiameter.text) * 25.4 # inches to mm - self._set_last_load_options((self.dlis_widget.currentPath(), mnemonic_and_files, well_diameter)) - - if slicer_is_in_developer_mode(): - self.reload_last_button.setEnabled(True) - self.reload_last_button.setVisible(True) - - def _load_curves(self, filename, mnemonic_and_logic_files, well_diameter): - if slicer_is_in_developer_mode(): - self.reload_last_button.setEnabled(True) - self.reload_last_button.setVisible(True) - - progress_queue = Queue(maxsize=len(mnemonic_and_logic_files) + 2) - add_volume_queue = Queue(maxsize=len(mnemonic_and_logic_files)) - - def progress_callback(name, progress_index): - progress_queue.put((name, progress_index)) - - def add_volume_callback(filesystem_filename, logic_filename, name, domain, image, well_diameter_mm): - add_volume_queue.put((filesystem_filename, logic_filename, name, domain, image, well_diameter_mm)) - - progress_dialog = qt.QProgressDialog() - progress_dialog.setWindowModality(qt.Qt.WindowModal) - progress_dialog.setLabelText("Loading Files...") - progress_dialog.setCancelButtonText("Stop") - progress_dialog.setRange(0, len(mnemonic_and_logic_files) + 2) - - should_stop = False - - def should_stop_check(): - return should_stop - - def stop(): - nonlocal should_stop - should_stop = True - - progress_dialog.canceled.connect(stop) - - progress_dialog.show() - load_thread = Thread( - target=self.logic.load_volumes, - args=( - filename, - mnemonic_and_logic_files, - should_stop_check, - progress_callback, - add_volume_callback, - well_diameter, - ), - ) - load_thread.start() - - def process_queue(queue, process_function): - while not queue.empty(): - params = queue.get() - process_function(*params) - qt.QApplication.instance().processEvents() - - def update_progress(progress_text, progress_index): - progress_dialog.setLabelText(progress_text) - progress_dialog.setValue(progress_index) - - def add_volume(*args): - self.logic.add_volume(*args) - - while load_thread.is_alive(): - process_queue(progress_queue, update_progress) - process_queue(add_volume_queue, add_volume) - qt.QApplication.instance().processEvents() - time.sleep(0.01) - - process_queue(progress_queue, update_progress) - process_queue(add_volume_queue, add_volume) - - load_thread.join() - - def _get_last_load_options(self): - load_options = DLISImport.get_setting("last-load") - if load_options is not None: - try: - load_options = pickle.loads(load_options.data()) - except RuntimeError: - pass - - return load_options - - def _set_last_load_options(self, load_options): - DLISImport.set_setting("last-load", qt.QByteArray(pickle.dumps(load_options))) - - def _on_reload_last_button_clicked(self): - last_filename, last_selection, well_diameter = self._get_last_load_options() - self._load_curves(last_filename, last_selection, well_diameter) diff --git a/src/modules/DLISImport/DLISImportLib/__init__.py b/src/modules/DLISImport/DLISImportLib/__init__.py deleted file mode 100644 index cf93d17..0000000 --- a/src/modules/DLISImport/DLISImportLib/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .DLISImportWidget import * diff --git a/src/modules/DLISImport/Resources/Icons/DLISImport.png b/src/modules/DLISImport/Resources/Icons/DLISImport.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/DLISImport/Resources/Icons/DLISImport.png and /dev/null differ diff --git a/src/modules/ExpandSegmentsEffect/ExpandSegmentsEffectLib/SegmentEditorEffect.py b/src/modules/ExpandSegmentsEffect/ExpandSegmentsEffectLib/SegmentEditorEffect.py index 9e38309..be3e411 100644 --- a/src/modules/ExpandSegmentsEffect/ExpandSegmentsEffectLib/SegmentEditorEffect.py +++ b/src/modules/ExpandSegmentsEffect/ExpandSegmentsEffectLib/SegmentEditorEffect.py @@ -89,7 +89,7 @@ def onSourceVolumeNodeChanged(self, node): def createCursor(self, widget): # Turn off effect-specific cursor for this effect - return slicer.util.mainWindow().cursor + return slicer.modules.AppContextInstance.mainWindow.cursor def onApply(self): if self.scriptedEffect.parameterSetNode() is None: diff --git a/src/modules/Export/Export.py b/src/modules/Export/Export.py index 50b617a..a74eec6 100644 --- a/src/modules/Export/Export.py +++ b/src/modules/Export/Export.py @@ -12,6 +12,7 @@ import slicer.util import vtk +from ltrace.slicer import export from ltrace.slicer.helpers import ( extent2size, getSourceVolume, @@ -21,19 +22,13 @@ removeTemporaryNodes, safe_convert_array, getCurrentEnvironment, + checkUniqueNames, ) from ltrace.slicer.node_attributes import TableDataOrientation, NodeEnvironment from ltrace.slicer_utils import * from ltrace.transforms import getRoundedInteger from ltrace.units import global_unit_registry as ureg, SLICER_LENGTH_UNIT - - -def checkUniqueNames(nodes): - nodeNames = set() - for node in nodes: - if node.GetName() in nodeNames: - node.SetName(slicer.mrmlScene.GenerateUniqueName(node.GetName())) - nodeNames.add(node.GetName()) +from ltrace.utils.callback import Callback class Export(LTracePlugin): @@ -45,8 +40,9 @@ class Export(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Export (Legacy)" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools"] self.parent.dependencies = [] + self.parent.hidden = True self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = Export.help() @@ -191,6 +187,7 @@ def setup_standard_tab(self): self.formLayout_standard.addRow(" ", None) self.exportDirectoryButton = ctk.ctkDirectoryButton() + self.exportDirectoryButton.setMaximumWidth(374) self.exportDirectoryButton.caption = "Export directory" self.exportDirectoryButton.directory = self.getExportDirectory() self.formLayout_standard.addRow("Export directory:", self.exportDirectoryButton) @@ -384,11 +381,6 @@ def updateStatus(self, message, progress=None, processEvents=True): slicer.app.processEvents() -class Callback(object): - def __init__(self, on_update=None): - self.on_update = on_update or (lambda *args, **kwargs: None) - - class ExportLogic(LTracePluginLogic): DataType = namedtuple("ExportParameters", ["name", "dimension"]) @@ -570,7 +562,7 @@ def exportLabelMap(self, node, rootPath, nodePath, format, name=None, imageType= path.mkdir(parents=True, exist_ok=True) rawPath = path / ExportLogic.rawPath(node, name, imageType) array.astype(imageDtype).tofile(str(rawPath)) - colorCSV = self.getLabelMapLabelsCSV(node) + colorCSV = export.getLabelMapLabelsCSV(node) csvPath = rawPath.with_suffix(".csv") with open(str(csvPath), mode="w", newline="") as csvFile: writer = csv.writer(csvFile, delimiter="\n") @@ -597,7 +589,7 @@ def exportSegmentation(self, node, rootPath, nodePath, format, name=None, imageT path.mkdir(parents=True, exist_ok=True) rawPath = path / ExportLogic.rawPath(node, name, imageType) array.astype(np.uint8).tofile(str(rawPath)) - colorCSV = self.getLabelMapLabelsCSV(labelMapVolumeNode) + colorCSV = export.getLabelMapLabelsCSV(labelMapVolumeNode) csvPath = rawPath.with_suffix(".csv") with open(str(csvPath), mode="w", newline="") as csvFile: writer = csv.writer(csvFile, delimiter="\n") @@ -696,19 +688,6 @@ def createImageArrayForLabelMapAndSegmentation(self, labelMapNode): return imageArray, colorCSV - @staticmethod - def getLabelMapLabelsCSV(labelMapNode, withColor=False): - colorNode = labelMapNode.GetDisplayNode().GetColorNode() - labelsCSV = [] - for i in range(1, colorNode.GetNumberOfColors()): - label = f"{colorNode.GetColorName(i)},{i}" - if withColor: - color = [0] * 4 - colorNode.GetColor(i, color) - label += ",#%02x%02x%02x" % tuple(int(ch * 255) for ch in color[:3]) - labelsCSV.append(label) - return labelsCSV - def exportNodeAsImage(self, nodeName, dataArray, imageFormat, rootPath, nodePath, colorTable=None): path = rootPath / nodePath path.mkdir(parents=True, exist_ok=True) diff --git a/src/modules/FilteringTools/FilteringTools.py b/src/modules/FilteringTools/FilteringTools.py index 4cbf1e2..f72abdd 100644 --- a/src/modules/FilteringTools/FilteringTools.py +++ b/src/modules/FilteringTools/FilteringTools.py @@ -20,18 +20,11 @@ class FilteringTools(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Filtering Tools" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Filter" + self.parent.categories = ["Tools", "MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] - self.parent.helpText = ( - FilteringTools.help() - + CustomizedGradientAnisotropicDiffusion.help() - + CustomizedCurvatureAnisotropicDiffusion.help() - + CustomizedGaussianBlurImageFilter.help() - + CustomizedMedianImageFilter.help() - + ShadingCorrection.help() - ) + self.parent.helpText = "" @classmethod def readme_path(cls): diff --git a/src/modules/FilteringTools/Resources/Icons/FilteringTools.png b/src/modules/FilteringTools/Resources/Icons/FilteringTools.png deleted file mode 100644 index a4541e1..0000000 Binary files a/src/modules/FilteringTools/Resources/Icons/FilteringTools.png and /dev/null differ diff --git a/src/modules/FilteringTools/Resources/Icons/FilteringTools.svg b/src/modules/FilteringTools/Resources/Icons/FilteringTools.svg new file mode 100644 index 0000000..4813780 --- /dev/null +++ b/src/modules/FilteringTools/Resources/Icons/FilteringTools.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/GeoSlicerScriptRepository.md b/src/modules/GeoSlicerScriptRepository.md deleted file mode 100644 index 6bb6ccd..0000000 --- a/src/modules/GeoSlicerScriptRepository.md +++ /dev/null @@ -1,29 +0,0 @@ -# GeoSlicer Script Repository - -Bunch of scripts/snippets that would be useful for development. - -## External Repositories - -[Slicer's Script Repository](https://www.slicer.org/wiki/Documentation/Nightly/ScriptRepository) (when adding a new script/snippet, first consider contributing to this source instead of ours) - -[Transforms Scripts](https://www.slicer.org/wiki/Documentation/Nightly/Modules/Transforms#Examples) - -## Developer mode - -Some controls are only helpful for testing, so we hide them behind Slicer's `developer mode` toggle. -To check for it use `ltrace.slicer_utils.slicer_is_in_developer_mode()` - -## Settings - -Slicer has 2 types of settings: one for the whole application and one only for the current version. -To get them use `slicer.app.userSettings()` and `revision_settings = slicer.app.revisionUserSettings()`. -* Note: The revision setting is only for the **Slicer** version and not **GeoSlicer**. - -## Scripts/snippets - -### Simple way to apply a hardened transformation to a node -```python -translationArray = np.identity(4) -translationArray[:3, 3] = coordinates -node.ApplyTransformMatrix(slicer.util.vtkMatrixFromArray(translationArray)) -``` \ No newline at end of file diff --git a/src/modules/GeologEnv/GeologEnv.py b/src/modules/GeologEnv/GeologEnv.py index 21136bf..07c7bfc 100644 --- a/src/modules/GeologEnv/GeologEnv.py +++ b/src/modules/GeologEnv/GeologEnv.py @@ -18,7 +18,7 @@ class GeologEnv(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Geolog Integration" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "Multiscale"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = GeologEnv.help() diff --git a/src/modules/GeologEnv/GeologLib/GeologConnectWidget.py b/src/modules/GeologEnv/GeologLib/GeologConnectWidget.py index 8a2721e..8f62a3f 100644 --- a/src/modules/GeologEnv/GeologLib/GeologConnectWidget.py +++ b/src/modules/GeologEnv/GeologLib/GeologConnectWidget.py @@ -5,12 +5,14 @@ import sys from pathlib import Path -import qt, ctk, slicer -from Customizer import Customizer +import ctk +import qt +import slicer + from ltrace.slicer import ui +from ltrace.slicer_utils import getResourcePath from ltrace.utils.ProgressBarProc import ProgressBarProc - GEOLOG_SCRIPT_ERRORS = { -1: "Unknown error occurred. Please check if connection parameters are correct", 1: "Could not initialize project", @@ -69,6 +71,7 @@ def setup(self, prefix): projectUsualPath = "'/home/USER/Paradigm/projects/' for Linux systems" self.geologInstalation = ctk.ctkDirectoryButton() + self.geologInstalation.setMaximumWidth(374) self.geologInstalation.caption = "Geolog instalation folder" self.geologInstalation.objectName = f"{prefix} Geolog Directory Browser" self.geologInstalation.directoryChanged.connect(self.checkSearchButtonState) @@ -77,6 +80,7 @@ def setup(self, prefix): ) self.geologProjectsFolder = ctk.ctkDirectoryButton() + self.geologProjectsFolder.setMaximumWidth(374) self.geologProjectsFolder.caption = "Geolog project parent folder" self.geologProjectsFolder.directoryChanged.connect(self.onProjectPathSelected) self.geologProjectsFolder.objectName = f"{prefix} Geolog Projects Directory Browser" @@ -91,7 +95,7 @@ def setup(self, prefix): self.refreshButton = qt.QPushButton() self.refreshButton.clicked.connect(self.onProjectPathSelected) - self.refreshButton.setIcon(qt.QIcon(str(Customizer.RESET_ICON_PATH))) + self.refreshButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Reset.png")) self.refreshButton.setFixedWidth(30) self.refreshButton.setToolTip("Refresh the directory to check for newly created projects") diff --git a/src/modules/GeologEnv/GeologLib/GeologImportWidget.py b/src/modules/GeologEnv/GeologLib/GeologImportWidget.py index f56425e..5d4ba87 100644 --- a/src/modules/GeologEnv/GeologLib/GeologImportWidget.py +++ b/src/modules/GeologEnv/GeologLib/GeologImportWidget.py @@ -1,13 +1,15 @@ import logging import shutil import subprocess -from pathlib import Path import qt, slicer import numpy as np import pandas as pd -import DLISImportLib -from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUE, handle_null_values + +from ltrace.slicer.image_log.import_logic import ChannelMetadata +from ltrace.slicer.image_log.table_viewer_widget import ImageLogTableViewer +from pathlib import Path +from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUES, handle_null_values from ltrace.slicer import ui from ltrace.slicer.helpers import highlight_error, remove_highlight from ltrace.slicer.node_attributes import ( @@ -52,13 +54,13 @@ def setup(self): ) self.nullValuesListText = qt.QLineEdit() - self.nullValuesListText.text = str(DEFAULT_NULL_VALUE)[1:-1] + self.nullValuesListText.text = str(DEFAULT_NULL_VALUES)[1:-1] self.nullValuesListText.objectName = "Import Null Values LineEdit" self.nullValuesListText.setToolTip( "Values that represent null values. They will be changed to nan values during import." ) - self.tableView = DLISImportLib.DLISTableViewer() + self.tableView = ImageLogTableViewer() self.tableView.setMinimumHeight(500) self.tableView.loadClicked = self._onLoadClicked self.tableView.objectName = "dataTableView" @@ -161,7 +163,7 @@ def _updateTable(self): if logs: for logName, attributes in logs.items(): tableData.append( - DLISImportLib.DLISImportLogic.ChannelMetadata( + ChannelMetadata( logName, attributes["comment"], attributes["unit"], diff --git a/src/modules/HeterogeneityIndex/HeterogeneityIndex.py b/src/modules/HeterogeneityIndex/HeterogeneityIndex.py index 0acf5fa..684b1b6 100644 --- a/src/modules/HeterogeneityIndex/HeterogeneityIndex.py +++ b/src/modules/HeterogeneityIndex/HeterogeneityIndex.py @@ -19,7 +19,7 @@ class HeterogeneityIndex(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Heterogeneity Index" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "ImageLog"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = HeterogeneityIndex.help() diff --git a/src/modules/HeterogeneityIndex/Resources/Icons/HeterogeneityIndex.png b/src/modules/HeterogeneityIndex/Resources/Icons/HeterogeneityIndex.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/HeterogeneityIndex/Resources/Icons/HeterogeneityIndex.png and /dev/null differ diff --git a/src/modules/HeterogeneityIndex/Resources/Icons/HeterogeneityIndex.svg b/src/modules/HeterogeneityIndex/Resources/Icons/HeterogeneityIndex.svg new file mode 100644 index 0000000..e371412 --- /dev/null +++ b/src/modules/HeterogeneityIndex/Resources/Icons/HeterogeneityIndex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/HistogramSegmenter/HistogramSegmenter.py b/src/modules/HistogramSegmenter/HistogramSegmenter.py index 5369cb8..afa3d0b 100644 --- a/src/modules/HistogramSegmenter/HistogramSegmenter.py +++ b/src/modules/HistogramSegmenter/HistogramSegmenter.py @@ -10,7 +10,7 @@ import numpy as np import qt import slicer -from ltrace.image.optimized_transforms import binset, DEFAULT_NULL_VALUE +from ltrace.image.optimized_transforms import binset, DEFAULT_NULL_VALUES from ltrace.slicer.helpers import setDimensionFrom, getVolumeNullValue from ltrace.slicer.slicer_matplotlib import MatplotlibCanvasWidget from ltrace.slicer.ui import hierarchyVolumeInput, volumeInput @@ -179,9 +179,8 @@ def setup(self): # inputFrame = qt.QHBoxLayout() - self.inputSelector = hierarchyVolumeInput(onChange=self.onSelect) - self.inputSelector.setNodeTypes(["vtkMRMLScalarVolumeNode"]) - self.inputSelector.setHideChildNodeTypes( + self.inputSelector = hierarchyVolumeInput(onChange=self.onSelect, nodeTypes=["vtkMRMLScalarVolumeNode"]) + self.inputSelector.selectorWidget.setHideChildNodeTypes( ["vtkMRMLVectorVolumeNode", "vtkMRMLTensorVolumeNode", "vtkMRMLStreamingVolumeNode"] ) @@ -464,7 +463,7 @@ def __init__(self, view: HistogramSegmenterWidget): self.segments: List[Segment] = [] self.defaults = {} - self.nullValue = lambda: self.defaults.get("nullableValue", DEFAULT_NULL_VALUE) + self.nullValue = lambda: self.defaults.get("nullableValue", DEFAULT_NULL_VALUES) @staticmethod def hasImageData(volumeNode): diff --git a/src/modules/ImageLogCropVolume/ImageLogCropVolume.py b/src/modules/ImageLogCropVolume/ImageLogCropVolume.py index cdf5f27..507a691 100644 --- a/src/modules/ImageLogCropVolume/ImageLogCropVolume.py +++ b/src/modules/ImageLogCropVolume/ImageLogCropVolume.py @@ -23,8 +23,8 @@ class ImageLogCropVolume(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Image Log Crop Volume" - self.parent.categories = ["Image Log"] + self.parent.title = "Image Log Crop" + self.parent.categories = ["ImageLog", "Multiscale"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = ImageLogCropVolume.help() @@ -57,9 +57,8 @@ def setup(self): "vtkMRMLVectorVolumeNode", "vtkMRMLLabelMapVolumeNode", ], + tooltip="Pick a volume node to be cropped", ) - self.InputSelector.setMRMLScene(slicer.mrmlScene) - self.InputSelector.setToolTip("Pick a volume node to be cropped") self.InputSelector.objectName = "Input combobox" self.volumeResolution = qt.QLabel("") @@ -205,7 +204,6 @@ def __calculateDepthFromIndex(self, index): return self.origins[2] + self.spacing[2] * index def __calculateIndexFromDepth(self, depth, isFloor): - print("floor:", math.floor((depth - self.origins[2]) / self.spacing[2])) if isFloor: return math.floor((depth - self.origins[2]) / self.spacing[2]) else: diff --git a/src/modules/ImageLogCropVolume/Resources/Icons/ImageLogCropVolume.png b/src/modules/ImageLogCropVolume/Resources/Icons/ImageLogCropVolume.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/ImageLogCropVolume/Resources/Icons/ImageLogCropVolume.png and /dev/null differ diff --git a/src/modules/ImageLogCropVolume/Resources/Icons/ImageLogCropVolume.svg b/src/modules/ImageLogCropVolume/Resources/Icons/ImageLogCropVolume.svg new file mode 100644 index 0000000..ee34d04 --- /dev/null +++ b/src/modules/ImageLogCropVolume/Resources/Icons/ImageLogCropVolume.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ImageLogCustomSegmenter/ImageLogCustomSegmenter.py b/src/modules/ImageLogCustomSegmenter/ImageLogCustomSegmenter.py index 7a989db..b95f378 100644 --- a/src/modules/ImageLogCustomSegmenter/ImageLogCustomSegmenter.py +++ b/src/modules/ImageLogCustomSegmenter/ImageLogCustomSegmenter.py @@ -12,6 +12,7 @@ from ltrace.slicer_utils import * +from ltrace.slicer.node_attributes import NodeEnvironment from ltrace.slicer.widget.msrfnet_frontend import ComboBox, FormSection from ltrace.slicer import helpers from ltrace.slicer.ui import hierarchyVolumeInput @@ -185,7 +186,7 @@ def updateProgress(self, value: int, message: str = None): self.progress.close() self.progress = None - if slicer.util.selectedModule() != "ImageLogEnv": + if helpers.getCurrentEnvironment() != NodeEnvironment.IMAGE_LOG: if slicer.util.confirmYesNoDisplay( "Your data has already been fetched. Do you want to change to ImageLogs viewer?", "Job Completed" ): @@ -197,7 +198,7 @@ def updateProgress(self, value: int, message: str = None): slicer.app.processEvents() def centerProgress(self): - mainWindow = slicer.util.mainWindow() + mainWindow = slicer.modules.AppContextInstance.mainWindow screenMainPos = mainWindow.pos x = screenMainPos.x() + int((mainWindow.width - self.progress.width) / 2) y = screenMainPos.y() + int((mainWindow.height - self.progress.height) / 2) diff --git a/src/modules/ImageLogData/ImageLogData.py b/src/modules/ImageLogData/ImageLogData.py index 8309f2f..0d93b86 100644 --- a/src/modules/ImageLogData/ImageLogData.py +++ b/src/modules/ImageLogData/ImageLogData.py @@ -9,10 +9,14 @@ import math import numpy as np import os +import pyqtgraph as pg import traceback +from ltrace.slicer.application_observables import ApplicationObservables +from ltrace.slicer.debounce_caller import DebounceCaller from ltrace.slicer.widget.depth_overview_axis_item import DepthOverviewAxisItem from ltrace.slicer_utils import * +from ltrace.slicer_utils import getResourcePath from ltrace.constants import ImageLogConst from pathlib import Path from slicer.util import VTKObservationMixin @@ -28,6 +32,7 @@ from ImageLogDataLib.viewcontroller import * from ImageLogDataLib.viewdata import * from ImageLogDataLib.treeview.SubjectHierarchyTreeViewFilter import SubjectHierarchyTreeViewFilter +from ImageLogDataLib.mouse_event_filter import MouseEventFilter # Checks if closed source code is available try: @@ -44,8 +49,8 @@ class ImageLogData(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Image Log Data" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Explorer" + self.parent.categories = ["Tools", "ImageLog", "Thin Section", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = ImageLogData.help() @@ -64,10 +69,14 @@ def __init__(self, parent): def setup(self): super().setup() - self.isFirstInstance = False - if not ImageLogDataWidget.logic: - ImageLogDataWidget.logic = ImageLogDataLogic(self.parent) - self.isFirstInstance = True + if hasattr(slicer.modules.AppContextInstance, "imageLogDataLogic"): + self.logic = ImageLogDataWidget.logic = slicer.modules.AppContextInstance.imageLogDataLogic + else: + self.logic = ( + ImageLogDataWidget.logic + ) = slicer.modules.AppContextInstance.imageLogDataLogic = ImageLogDataLogic( + slicer.modules.AppContextInstance.mainWindow + ) ## Add custom context menu actions self.subjectHierarchyTreeView.setMRMLScene(slicer.app.mrmlScene()) @@ -134,8 +143,8 @@ def setup(self): settingsFormLayout.addRow(viewsGroupBox) - if self.isFirstInstance: - self.logic.addViewClicked.connect(self.addView) + self.logic.addViewClicked.disconnect() + self.logic.addViewClicked.connect(self.addView) self.filter = SubjectHierarchyTreeViewFilter(dataWidget=self) self.subjectHierarchyTreeView.installEventFilter(self.filter) @@ -146,11 +155,43 @@ def translationSpeedChanged(self, value): def scalingSpeedChanged(self, value): self.logic.scalingSpeedChanged(value) + def onReload(self) -> None: + logic = ImageLogDataWidget.logic + oldDockedWidget = None + if hasattr(slicer.modules, "ImageLogDataDockedWidget") and slicer.modules.ImageLogDataWidget is not self: + oldWidget = slicer.modules.ImageLogDataWidget + oldDockedWidget = slicer.modules.ImageLogDataDockedWidget + + self.cleanup() + super().onReload() + + self.logic.onSlicerLayoutChanged(slicer.app.layoutManager().layout) + + if oldDockedWidget is not None: + newDockedWidget = slicer.util.getModuleWidget("ImageLogData") + slicer.modules.ImageLogDataWidget = oldWidget + slicer.modules.ImageLogDataDockedWidget = newDockedWidget + + ImageLogDataWidget.logic = logic + ImageLogDataWidget.logic.setParent(slicer.util.getModuleWidget("ImageLogData").parent) + def addView(self): - try: + def nodeFromSubjectItemId(itemId): + return self.subjectHierarchyTreeView.subjectHierarchyNode().GetItemDataNode(itemId) + + if not hasattr(slicer.modules, "ImageLogDataDockedWidget"): selectedNodeIdInTree = self.subjectHierarchyTreeView.currentItem() - self.logic.addView(selectedNodeIdInTree) + selectedNode = nodeFromSubjectItemId(selectedNodeIdInTree) self.subjectHierarchyTreeView.setCurrentItems(vtk.vtkIdList()) + else: # Handling both explorers. The left panel is preffered. + leftPanelSelectedNode = slicer.modules.ImageLogDataWidget.subjectHierarchyTreeView.currentItem() + rightPanelSelectedNode = slicer.modules.ImageLogDataDockedWidget.subjectHierarchyTreeView.currentItem() + selectedNode = nodeFromSubjectItemId(leftPanelSelectedNode) or nodeFromSubjectItemId(rightPanelSelectedNode) + slicer.modules.ImageLogDataWidget.subjectHierarchyTreeView.setCurrentItems(vtk.vtkIdList()) + slicer.modules.ImageLogDataDockedWidget.subjectHierarchyTreeView.setCurrentItems(vtk.vtkIdList()) + + try: + self.logic.addView(selectedNode) except ImageLogDataInfo as e: slicer.util.infoDisplay(str(e)) @@ -187,10 +228,30 @@ def exit(self): def cleanup(self): super().cleanup() - self.logic.layoutViewOpened.disconnect() - self.logic.layoutViewClosed.disconnect() - self.logic.viewsRefreshed.disconnect() - self.logic.addViewClicked.disconnect() + self.subjectHierarchyTreeView.removeEventFilter(self.filter) + self.logic.addViewClicked.disconnect(self.addView) + self.logic.imageLogViewList.clear() + self.logic.refreshViews() + + def getVisibleViews(self): + identifiers = self.logic.getViewDataListIdentifiers() + views = {} + for id in identifiers: + viewData = self.logic.imageLogViewList[id].viewData + viewControllerWidget = self.logic.viewControllerWidgets[id] + viewLabel = viewControllerWidget.findChild(qt.QLabel, "viewLabel" + str(id)) + name = viewLabel.text + views[id] = {"type": type(viewData).__name__, "name": name} + + return views + + def getGraphicViewPlotItem(self, viewIdentifier: int) -> pg.PlotItem: + if not isinstance(viewIdentifier, int): + return + + if isinstance(self.logic.imageLogViewList[viewIdentifier].viewData, GraphicViewData): + curvePlot = self.logic.imageLogViewList[viewIdentifier].widget.getPlot() + return curvePlot.get_plot_item() class ViewDataEncoder(json.JSONEncoder): @@ -207,15 +268,13 @@ def default(self, obj): class ImageLogDataLogic(LTracePluginLogic, VTKObservationMixin): CONFIGURATION_SINGLETON_TAG = "ImageLogConfiguration" MAXIMUM_NUMBER_OF_VIEWS = 5 + ALLOWED_ENVIRONMENTS_FOR_LAYOUT = "ImageLogEnv", "MultiscaleEnv", "ThinSectionEnv" """ Do not lower this time unless it is all tested (a lower delay time results in a faster interface response but tends to cause many interface problems, some subtle, like the incorrect synchronization of the range in each of the views). """ REFRESH_DELAY = 50 - DEFAULT_LAYOUT_ID_START_VALUE = ( - ImageLogConst.DEFAULT_LAYOUT_ID_START_VALUE - ) # Initial layout id. It will be incremented as views configurations changes. layoutViewOpened = qt.Signal() layoutViewClosed = qt.Signal() @@ -236,7 +295,6 @@ def __init__(self, parent): self.observedDisplayNodes = [] self.observedPrimaryNodes = [] self.observedInteractors = [] - self.layoutId = self.DEFAULT_LAYOUT_ID_START_VALUE self.currentRange = ( None # This is the current range of the tracks. [bottom depth, top depth] where bottom depth > top depth ) @@ -245,15 +303,21 @@ def __init__(self, parent): self.segmentationOpacity = 0.5 # Maintains opacity value between all segmentation nodes self.nodeAboutToBeRemoved = False # To avoid calling primaryNodeChanged function when a node is removed self.debug = False # Set true to track some function calls origin - self.__createRefreshTimer() - self.__createAdjustViewsVisibleRegionTimer() + self.delayedAdjustViewsVisibleRegion = DebounceCaller( + self, intervalMs=self.REFRESH_DELAY, callback=self.adjustViewsVisibleRegion + ) + self.__delayedRefreshViews = DebounceCaller(self, intervalMs=self.REFRESH_DELAY, callback=self.__refreshViews) + slicer.app.layoutManager().layoutChanged.connect(self.onSlicerLayoutChanged) self.__layoutViewOpen = False self.__observerHandlers = [] self.layoutManagerViewPort = slicer.app.layoutManager().viewport() # Central widget - self.mouseDetectorFilter = None + self.mouseEventFilter = MouseEventFilter(self) self.saveObserver = None self.configurationsNode = None + self.imageLogLayoutViewAction = None + self.__addImageLogViewOption() + ApplicationObservables().environmentChanged.connect(self.__updateImageLogLayoutActionVisibility) def loadConfiguration(self): slicer.app.processEvents() @@ -277,10 +341,13 @@ def delayedLoadConfiguration(self): if viewsJson: viewsList = json.loads(viewsJson) if viewsList: - self.handleViewList(viewsList) + self.__loadViewFromList(viewsList) - def handleViewList(self, viewList): - for identifier, viewData in viewList: + def __loadViewFromList(self, viewList): + self.imageLogViewList.clear() + self.cleanUp() + + for identifier, viewData in viewList[: self.MAXIMUM_NUMBER_OF_VIEWS - 1]: if "primaryNodeId" in viewData: if viewData["primaryNodeId"] is None: # Add EmptyViewData @@ -289,7 +356,8 @@ def handleViewList(self, viewList): self.addSliceViewData(viewData, identifier) if "primaryTableNodeColumnList" in viewData: self.addGraphicViewData(viewData) - self.refreshViews() + + self.refreshViews("loadViewFromList") def addSliceViewData(self, viewData, identifier): primaryNode = tryGetNode(viewData["primaryNodeId"]) @@ -329,118 +397,20 @@ def changeToLayout(self): return # If layout view is not initialized yet, then create it - if self.layoutId == self.DEFAULT_LAYOUT_ID_START_VALUE: + if slicer.modules.AppContextInstance.imageLogLayoutId == ImageLogConst.DEFAULT_LAYOUT_ID_START_VALUE: self.refreshViews("changeToLayout") else: - slicer.app.layoutManager().setLayout(self.layoutId) + slicer.app.layoutManager().setLayout(slicer.modules.AppContextInstance.imageLogLayoutId) def onSlicerLayoutChanged(self, layoutId): - if self.DEFAULT_LAYOUT_ID_START_VALUE <= layoutId < 16000: + self.__updateImageLogLayoutActionVisibility() + if ImageLogConst.DEFAULT_LAYOUT_ID_START_VALUE <= layoutId < 16000: if self.__layoutViewOpen is True: return self.updateViewsAxis() - if not self.mouseDetectorFilter: - - class MouseDetectorFilter(qt.QObject): - dataLogic = self - - def __init__(self, parent=None): - super().__init__(parent) - - def getGeometry(self, viewWidget): - if self.dataLogic is None or not hasattr(self.dataLogic, "axisItem"): - return qt.QRect(0, 0, 0, 0) - - logGeometry = viewWidget.geometry - - oldWidth = logGeometry.width() - oldHeight = logGeometry.height() - axisGeometry = self.dataLogic.axisItem.geometry() - - logGeometryXY = viewWidget.mapToGlobal(qt.QPoint(0, axisGeometry.y())) - - logGeometry = qt.QRect(logGeometryXY.x(), logGeometryXY.y(), oldWidth, oldHeight) - - return logGeometry - - def getCursorPhysicalPosition(self, viewWidget, imageLogView, x, y): - width = viewWidget.geometry.width() - - if imageLogView.widget == None: - # This avoids raising unnecessary errors, - # but might also avoid unknown necessary ones, too - return -1, -1 - - xDepth = imageLogView.widget.getGraphX(x, width) - - axisItem = self.dataLogic.axisItem - height = axisItem.geometry().height() - - vDif = axisItem.range[0] - axisItem.range[1] - if vDif == 0: - yScale = 1 - yOffset = 0 - else: - yScale = height / vDif - yOffset = axisItem.range[1] * yScale - - yDepth = (y + yOffset) / yScale - - return xDepth, yDepth - - def getValue(self, imageLogView, x, y): - value = None - if imageLogView.widget: - value = imageLogView.widget.getValue(x, y) - return value - - def writeCoordinates(self, identifier, x, y, value): - text = f"View #{identifier} ({x:.2f}, {y:.2f})" - text = text + f" {value:.2f}" if isinstance(value, float) else text + f" {value}" - toolBarWidget = self.dataLogic.containerWidgets["toolBarWidget"] - mousePhysicalCoordinates = toolBarWidget.findChild(qt.QLabel, "MousePhysicalCoordinates") - mousePhysicalCoordinates.setText(text) - - def eventFilter(self, widget, event): - if not (isinstance(event, qt.QHoverEvent) or isinstance(event, qt.QWheelEvent)): - return - for identifier in self.dataLogic.getViewDataListIdentifiers(): - imageLogView = self.dataLogic.imageLogViewList[identifier] - if imageLogView is None: - continue - - try: - viewWidget = self.dataLogic.viewWidgets[identifier] - except IndexError as error: - logging.debug(error) - continue - viewData = imageLogView.viewData - if viewData.primaryNodeId == None: - continue - if ( - type(viewData) is SliceViewData - or type(viewData) is GraphicViewData - and (viewData.primaryTableNodeColumn != "" or viewData.secondaryTableNodeColumn != "") - ): - posMouse = qt.QCursor().pos() - logGeometry = self.getGeometry(viewWidget) - if not logGeometry.contains(posMouse): - continue - relativePosMouseY = posMouse.y() - logGeometry.y() - relativePosMouseX = posMouse.x() - logGeometry.x() - physicalPos = self.getCursorPhysicalPosition( - viewWidget, imageLogView, relativePosMouseX, relativePosMouseY - ) - value = self.getValue(imageLogView, *physicalPos) - self.writeCoordinates(identifier, *physicalPos, value) - break - - self.mouseDetectorFilter = MouseDetectorFilter() - - slicer.util.mainWindow().installEventFilter(self.mouseDetectorFilter) - + slicer.modules.AppContextInstance.mainWindow.installEventFilter(self.mouseEventFilter) self.layoutViewOpened.emit() self.__layoutViewOpen = True self.refreshViews("EnterEvent") @@ -450,8 +420,7 @@ def eventFilter(self, widget, event): if self.__layoutViewOpen is False: return - if self.mouseDetectorFilter: - slicer.util.mainWindow().removeEventFilter(self.mouseDetectorFilter) + slicer.modules.AppContextInstance.mainWindow.removeEventFilter(self.mouseEventFilter) self.layoutViewClosed.emit() self.__layoutViewOpen = False @@ -513,7 +482,7 @@ def registerLayout(self, layout): """ Register the layout on Slicer's layout manager and saves the views widgets for later access. """ - self.layoutId += 1 + slicer.modules.AppContextInstance.imageLogLayoutId += 1 viewDataListIdentifiers = self.getViewDataListIdentifiers() @@ -548,8 +517,10 @@ def registerLayout(self, layout): # Registering the layout and activating it layoutManager = slicer.app.layoutManager() - layoutManager.layoutLogic().GetLayoutNode().AddLayoutDescription(self.layoutId, layout) - slicer.app.layoutManager().setLayout(self.layoutId) + layoutManager.layoutLogic().GetLayoutNode().AddLayoutDescription( + slicer.modules.AppContextInstance.imageLogLayoutId, layout + ) + slicer.app.layoutManager().setLayout(slicer.modules.AppContextInstance.imageLogLayoutId) # And now we can save the slice views references for identifier in viewDataListIdentifiers: @@ -561,7 +532,9 @@ def registerLayout(self, layout): self.viewWidgets[identifier] = viewWidget # Stretch factors - centralWidgetLayoutFrame = slicer.util.mainWindow().findChild(qt.QFrame, "CentralWidgetLayoutFrame") + centralWidgetLayoutFrame = slicer.modules.AppContextInstance.mainWindow.findChild( + qt.QFrame, "CentralWidgetLayoutFrame" + ) centralWidgetLayoutFrameLayout = centralWidgetLayoutFrame.layout() if centralWidgetLayoutFrameLayout and centralWidgetLayoutFrameLayout.count() >= 2: layout = centralWidgetLayoutFrameLayout.itemAt(1).layout() @@ -642,16 +615,16 @@ def setupViewWidgets(self): ): self.setupGraphicViewWidget(identifier) - def setupViewSpacerWidgets(self): - for identifier in self.getViewDataListIdentifiers(): - viewData = self.imageLogViewList[identifier].viewData - if type(viewData) is SliceViewData: - viewSpacerWidget = self.viewSpacerWidgets[identifier] - viewSpacerWidgetLayout = qt.QVBoxLayout(viewSpacerWidget) - viewSpacerWidgetLayout.setContentsMargins(0, 0, 0, 0) - viewSpacerWidgetLayout.addSpacerItem(qt.QSpacerItem(0, 20)) - # else: - # self.viewSpacerWidgets[identifier].deleteLater() # Not used in other views + # def setupViewSpacerWidgets(self): + # for identifier in self.getViewDataListIdentifiers(): + # viewData = self.imageLogViewList[identifier].viewData + # if type(viewData) is SliceViewData: + # viewSpacerWidget = self.viewSpacerWidgets[identifier] + # viewSpacerWidgetLayout = qt.QVBoxLayout(viewSpacerWidget) + # viewSpacerWidgetLayout.setContentsMargins(0, 0, 0, 0) + # viewSpacerWidgetLayout.addSpacerItem(qt.QSpacerItem(0, 20)) + # # else: + # # self.viewSpacerWidgets[identifier].deleteLater() # Not used in other views def setupGraphicViewWidget(self, identifier): viewWidget = self.viewWidgets[identifier] @@ -721,7 +694,7 @@ def setupAxisWidget(self): axisWidget = self.containerWidgets["axisWidget"] axisWidgetVerticalLayout = qt.QVBoxLayout(axisWidget) - axisWidgetVerticalLayout.setContentsMargins(0, 37, 0, 0) + axisWidgetVerticalLayout.setContentsMargins(0, 40, 0, 0) axisWidgetVerticalLayout.setSpacing(0) scaleFrame = qt.QFrame() @@ -749,7 +722,7 @@ def setupAxisWidget(self): axisWidgetVerticalLayout.addWidget(axisWidgetFrame) pysideQHBoxLayout = shiboken2.wrapInstance(hash(axisWidgetLayout), PySide2.QtWidgets.QHBoxLayout) - pysideQHBoxLayout.setContentsMargins(0, 0, 0, 21) + pysideQHBoxLayout.setContentsMargins(0, 10, 0, 0) self.depthOverview = pg.GraphicsLayoutWidget() self.depthOverview.setBackground("#FFFFFF") @@ -788,21 +761,26 @@ def setupViewColorBarWidgets(self): viewColorBarWidgetLayout = qt.QHBoxLayout(viewColorBarWidget) viewColorBarWidgetLayout.setContentsMargins(0, 0, 0, 0) - if primaryNode is not None and type(primaryNode) is slicer.vtkMRMLScalarVolumeNode: - colorBarWidget = ColorBarWidget() - colorBarWidget.setObjectName("colorBarWidget" + str(identifier)) - viewColorBarWidgetLayout.addWidget(colorBarWidget) - if primaryNode.GetDisplayNode() is None: - primaryNode.CreateDefaultDisplayNodes() - displayNode = primaryNode.GetDisplayNode() - observerID = displayNode.AddObserver( - "ModifiedEvent", lambda display, event, identifier_=identifier: self.updateColorBar(identifier_) - ) - self.observedDisplayNodes.append([displayNode, observerID]) - colorBarWidget.setColorTableNode(displayNode.GetColorNode()) - displayNode.Modified() # To update the color bar - else: - viewColorBarWidgetLayout.addSpacerItem(qt.QSpacerItem(0, 20)) + if primaryNode is not None: + if type(primaryNode) is slicer.vtkMRMLScalarVolumeNode: + colorBarWidget = ColorBarWidget() + colorBarWidget.setObjectName("colorBarWidget" + str(identifier)) + viewColorBarWidgetLayout.addWidget(colorBarWidget) + if primaryNode.GetDisplayNode() is None: + primaryNode.CreateDefaultDisplayNodes() + displayNode = primaryNode.GetDisplayNode() + observerID = displayNode.AddObserver( + "ModifiedEvent", lambda display, event, identifier_=identifier: self.updateColorBar(identifier_) + ) + self.observedDisplayNodes.append([displayNode, observerID]) + colorBarWidget.setColorTableNode(displayNode.GetColorNode()) + displayNode.Modified() # To update the color bar + + elif type(primaryNode) is slicer.vtkMRMLLabelMapVolumeNode: + viewColorBarWidgetLayout.addSpacerItem(qt.QSpacerItem(0, 41)) + + elif viewData.secondaryTableNodeColumn == "" or viewData.primaryTableHistogram: + viewColorBarWidgetLayout.addSpacerItem(qt.QSpacerItem(0, 20)) ######################################################################################################################################## # Widgets populate @@ -826,7 +804,12 @@ def populateViewControllerWidgets(self): viewLabel = viewControllerWidget.findChild(qt.QLabel, "viewLabel" + str(identifier)) primaryNode = self.getNodeById(viewData.primaryNodeId) if primaryNode is not None: - viewLabel.setText(primaryNode.GetName()) + if type(viewData) is GraphicViewData and self.getNodeById(viewData.secondaryTableNodeId): + viewLabel.setText( + f"{primaryNode.GetName()} / {self.getNodeById(viewData.secondaryTableNodeId).GetName()}" + ) + else: + viewLabel.setText(primaryNode.GetName()) settingsToolButton = viewControllerWidget.findChild(qt.QToolButton, "settingsToolButton" + str(identifier)) settingsToolButton.setChecked(viewData.viewControllerSettingsToolButtonToggled) @@ -882,9 +865,9 @@ def populatePrimaryNodeInterfaceItems(self): showHidePrimaryNodeButton.blockSignals(True) if viewData.primaryNodeHidden: showHidePrimaryNodeButton.setChecked(True) - showHidePrimaryNodeButton.setIcon(qt.QIcon(str(Customizer.CLOSED_EYE_ICON_PATH))) + showHidePrimaryNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeClosed.png")) else: - showHidePrimaryNodeButton.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + showHidePrimaryNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) showHidePrimaryNodeButton.blockSignals(False) primaryTableNodeColumnComboBox = viewControllerWidget.findChild( @@ -986,9 +969,9 @@ def populateSegmentationNodeInterfaceItems(self): showHideSegmentationNodeButton.blockSignals(True) if viewData.segmentationNodeHidden: showHideSegmentationNodeButton.setChecked(True) - showHideSegmentationNodeButton.setIcon(qt.QIcon(str(Customizer.CLOSED_EYE_ICON_PATH))) + showHideSegmentationNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeClosed.png")) else: - showHideSegmentationNodeButton.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + showHideSegmentationNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) showHideSegmentationNodeButton.blockSignals(False) def populateProportionsNodeInterfaceItems(self): @@ -1014,9 +997,9 @@ def populateProportionsNodeInterfaceItems(self): showHideProportionsNodeButton.blockSignals(True) if viewData.proportionsNodeHidden: showHideProportionsNodeButton.setChecked(True) - showHideProportionsNodeButton.setIcon(qt.QIcon(str(Customizer.CLOSED_EYE_ICON_PATH))) + showHideProportionsNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeClosed.png")) else: - showHideProportionsNodeButton.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + showHideProportionsNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) showHideProportionsNodeButton.blockSignals(False) ######################################################################################################################################## @@ -1027,35 +1010,37 @@ def refreshViews(self, source=None, interval_ms=50): """ Handles timer to refresh views. """ + env = slicer.modules.AppContextInstance.modules.currentWorkingDataType if self.debug: - print("refreshViews:", source) - - if self.__refreshViewsTimer.isActive(): - self.__refreshViewsTimer.stop() + print("refreshViews:", source, env) + if not env or env[1] not in self.ALLOWED_ENVIRONMENTS_FOR_LAYOUT: + return - self.__refreshViewsTimer.setInterval(interval_ms) - self.__refreshViewsTimer.start() + self.__delayedRefreshViews() def __refreshViews(self, source=None): """ Refreshes all the views with the current data. + Don't call this method directly. Use the 'refreshViews' method instead. """ self.cleanUp() self.registerLayout(self.generateLayout()) self.setupToolBar() self.setupViewControllerWidgets() self.setupViewWidgets() - self.setupViewSpacerWidgets() + # self.setupViewSpacerWidgets() self.populateViewsInterface() self.setupAxisWidget() self.setupViewColorBarWidgets() self.setupSpacerWidget() self.delayedAdjustViewsVisibleRegion() - self.delayedReloadImageLogSegmenterEffect() + self.delayedReloadImageLogSegmentEditorEffect() self.delayedAddGraphicViewsConnections() self.delayedAddPrimaryNodeObservers() - self.viewsRefreshed.emit() self.updateDepthOverviewScale() + self.__updateToolBarVisibility() + self.imageLogLayoutViewAction.setData(slicer.modules.AppContextInstance.imageLogLayoutId) + self.viewsRefreshed.emit() def setupToolBar(self): toolBarWidget = self.containerWidgets["toolBarWidget"] @@ -1064,21 +1049,22 @@ def setupToolBar(self): # Fit button fitButton = qt.QPushButton() - fitButton.setIcon(qt.QIcon(str(Customizer.FIT_ICON_PATH))) - fitButton.clicked.connect(lambda state: self.fit()) + fitButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Fit.png")) + fitButton.clicked.connect(self.fit) fitButton.setFixedWidth(25) fitButton.setToolTip("Reset the views to fit all data.") # Adjust to real aspect ratio button fitRealAspectRatio = qt.QPushButton() - fitRealAspectRatio.setIcon(qt.QIcon(str(Customizer.FIT_REAL_ASPECT_RATIO_ICON_PATH))) - fitRealAspectRatio.clicked.connect(lambda state: self.fitToAspectRatio()) + fitRealAspectRatio.setIcon(qt.QIcon(getResourcePath("Icons") / "FitRealAspectRatio.png")) + fitRealAspectRatio.clicked.connect(self.fitToAspectRatio) fitRealAspectRatio.setFixedWidth(25) fitRealAspectRatio.setToolTip("Adjust the views to their real aspect ratio.") # Add view button addViewButton = qt.QPushButton("Add view") - addViewButton.setIcon(qt.QIcon(str(Customizer.ADD_ICON_PATH))) + addViewButton.setObjectName("Add view button") + addViewButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Add.png")) addViewButton.clicked.connect(self.addViewClicked) # Mouse physical coordinates on Slice/Graphic view @@ -1147,20 +1133,20 @@ def addGraphicViewsConnections(self): if curvePlotWidget is not None: curvePlotWidget.blockSignals(False) - def delayedReloadImageLogSegmenterEffect(self): - qt.QTimer.singleShot(self.REFRESH_DELAY, self.reloadImageLogSegmenterEffect) + def delayedReloadImageLogSegmentEditorEffect(self): + qt.QTimer.singleShot(self.REFRESH_DELAY, self.reloadImageLogSegmentEditorEffect) - def reloadImageLogSegmenterEffect(self): + def reloadImageLogSegmentEditorEffect(self): """ Reloads the effect to avoid a bug related to the effect activation on the new segmentation and/or master volume. """ - if not hasattr(self, "imageLogSegmenterWidget"): + if not hasattr(self, "ImageLogSegmentEditorWidget"): return try: - segmentEditorWidget = self.imageLogSegmenterWidget.self().segmentEditorWidget + segmentEditorWidget = slicer.util.getModuleWidget("ImageLogSegmentEditor").segmentEditorWidget except ValueError: - # imageLogSegmenterWidget was deleted + # ImageLogSegmentEditorWidget was deleted return activeEffect = segmentEditorWidget.activeEffect() segmentEditorWidget.setActiveEffectByName("None") @@ -1208,25 +1194,24 @@ def updateColorBar(self, identifier): colorBarWidget.updateInformation(displayNode.GetWindow(), displayNode.GetLevel()) colorBarWidget.setColorTableNode(displayNode.GetColorNode()) - def addView(self, selectedNodeIdInTree=None): - if len(self.imageLogViewList) == self.MAXIMUM_NUMBER_OF_VIEWS: - slicer.app.layoutManager().setLayout(self.layoutId) # In case there is other layout selected + def addView(self, selectedNode=None): + nodeName = selectedNode.GetName() if selectedNode is not None else "None" + print(f"ImageLogDataLogic.addView from logic {self}: {nodeName}") + if len(self.imageLogViewList) >= self.MAXIMUM_NUMBER_OF_VIEWS: + slicer.app.layoutManager().setLayout( + slicer.modules.AppContextInstance.imageLogLayoutId + ) # In case there is other layout selected raise ImageLogDataInfo("The maximum number of views was reached.") - subjectHierarchyNode = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) - # defaults - selectedNode = None isVolumeNode = False isSegmentationNode = False isTableNode = False - if selectedNodeIdInTree is not None: - selectedNode = subjectHierarchyNode.GetItemDataNode(selectedNodeIdInTree) - if selectedNode is not None: - isVolumeNode = isinstance(selectedNode, slicer.vtkMRMLVolumeNode) - isSegmentationNode = isinstance(selectedNode, slicer.vtkMRMLSegmentationNode) - isTableNode = isinstance(selectedNode, slicer.vtkMRMLTableNode) + if selectedNode is not None: + isVolumeNode = isinstance(selectedNode, slicer.vtkMRMLVolumeNode) + isSegmentationNode = isinstance(selectedNode, slicer.vtkMRMLSegmentationNode) + isTableNode = isinstance(selectedNode, slicer.vtkMRMLTableNode) self.imageLogViewList.append(None) identifier = len(self.imageLogViewList) - 1 @@ -1244,7 +1229,7 @@ def addView(self, selectedNodeIdInTree=None): if self.imageLogViewList[identifier] is None: self.imageLogViewList[identifier] = ImageLogView(None) - self.__refreshViews("AddView") + self.refreshViews("AddView") def removeViewFromPrimaryNode(self, nodeId: str) -> None: if nodeId is None: @@ -1269,71 +1254,82 @@ def removeViewFromPrimaryNode(self, nodeId: str) -> None: self.refreshViews("removeViewFromPrimaryNode") def removeView(self, identifier, refresh=True): - del self.imageLogViewList[identifier] + if identifier < len(self.imageLogViewList): + del self.imageLogViewList[identifier] + if refresh: self.refreshViews("removeView") def primaryNodeChanged(self, identifier, node): """The first node combo box of a view determines the primary node. The primary node type determines the type of the view.""" if not self.nodeAboutToBeRemoved: - self.imageLogViewList[identifier] = ImageLogView(node) + if identifier < len(self.imageLogViewList): + self.imageLogViewList[identifier] = ImageLogView(node) self.refreshViews("primaryNodeChanged") def secondaryTableNodeChanged(self, identifier, node): if not self.nodeAboutToBeRemoved: - self.imageLogViewList[identifier].set_new_secondary_node(node) + if identifier < len(self.imageLogViewList): + self.imageLogViewList[identifier].set_new_secondary_node(node) self.refreshViews("secondaryTableNodeChanged") def segmentationNodeChanged(self, identifier, segmentationNode): if not self.nodeAboutToBeRemoved: - self.imageLogViewList[identifier].set_new_segmentation_node(segmentationNode) + if identifier < len(self.imageLogViewList): + self.imageLogViewList[identifier].set_new_segmentation_node(segmentationNode) self.refreshViews("segmentationNodeChanged") def primaryTableNodeColumnChanged(self, identifier): - viewData = self.imageLogViewList[identifier].viewData - viewControllerWidget = self.viewControllerWidgets[identifier] - primaryTableNodeColumnComboBox = viewControllerWidget.findChild( - qt.QComboBox, "primaryTableNodeColumnComboBox" + str(identifier) - ) - viewData.primaryTableNodeColumn = primaryTableNodeColumnComboBox.currentText + if identifier < len(self.imageLogViewList): + viewData = self.imageLogViewList[identifier].viewData + viewControllerWidget = self.viewControllerWidgets[identifier] + primaryTableNodeColumnComboBox = viewControllerWidget.findChild( + qt.QComboBox, "primaryTableNodeColumnComboBox" + str(identifier) + ) + viewData.primaryTableNodeColumn = primaryTableNodeColumnComboBox.currentText self.refreshViews("primaryTableNodeColumnChanged") def primaryTableNodePlotColorChanged(self, identifier, color, scaleHistogram=None): - viewData = self.imageLogViewList[identifier].viewData - viewData.primaryTableNodePlotColor = color - viewData.primaryTableScaleHistogram = scaleHistogram + if identifier < len(self.imageLogViewList): + viewData = self.imageLogViewList[identifier].viewData + viewData.primaryTableNodePlotColor = color + viewData.primaryTableScaleHistogram = scaleHistogram self.refreshViews("primaryTableNodePlotColorChanged") def primaryTableNodePlotTypeChanged(self, identifier): - viewData = self.imageLogViewList[identifier].viewData - viewControllerWidget = self.viewControllerWidgets[identifier] - primaryTableNodePlotTypeComboBox = viewControllerWidget.findChild( - qt.QComboBox, "primaryTableNodePlotTypeComboBox" + str(identifier) - ) - viewData.primaryTableNodePlotType = primaryTableNodePlotTypeComboBox.currentData + if identifier < len(self.imageLogViewList): + viewData = self.imageLogViewList[identifier].viewData + viewControllerWidget = self.viewControllerWidgets[identifier] + primaryTableNodePlotTypeComboBox = viewControllerWidget.findChild( + qt.QComboBox, "primaryTableNodePlotTypeComboBox" + str(identifier) + ) + viewData.primaryTableNodePlotType = primaryTableNodePlotTypeComboBox.currentData self.refreshViews("primaryTableNodePlotTypeChanged") def secondaryTableNodeColumnChanged(self, identifier): - viewData = self.imageLogViewList[identifier].viewData - viewControllerWidget = self.viewControllerWidgets[identifier] - secondaryTableNodeColumnComboBox = viewControllerWidget.findChild( - qt.QComboBox, "secondaryTableNodeColumnComboBox" + str(identifier) - ) - viewData.secondaryTableNodeColumn = secondaryTableNodeColumnComboBox.currentText + if identifier < len(self.imageLogViewList): + viewData = self.imageLogViewList[identifier].viewData + viewControllerWidget = self.viewControllerWidgets[identifier] + secondaryTableNodeColumnComboBox = viewControllerWidget.findChild( + qt.QComboBox, "secondaryTableNodeColumnComboBox" + str(identifier) + ) + viewData.secondaryTableNodeColumn = secondaryTableNodeColumnComboBox.currentText self.refreshViews("secondaryTableNodeColumnChanged") def secondaryTableNodePlotColorChanged(self, identifier, color): - viewData = self.imageLogViewList[identifier].viewData - viewData.secondaryTableNodePlotColor = color + if identifier < len(self.imageLogViewList): + viewData = self.imageLogViewList[identifier].viewData + viewData.secondaryTableNodePlotColor = color self.refreshViews("secondaryTableNodePlotColorChanged") def secondaryTableNodePlotTypeChanged(self, identifier): - viewData = self.imageLogViewList[identifier].viewData - viewControllerWidget = self.viewControllerWidgets[identifier] - secondaryTableNodePlotTypeComboBox = viewControllerWidget.findChild( - qt.QComboBox, "secondaryTableNodePlotTypeComboBox" + str(identifier) - ) - viewData.secondaryTableNodePlotType = secondaryTableNodePlotTypeComboBox.currentData + if identifier < len(self.imageLogViewList): + viewData = self.imageLogViewList[identifier].viewData + viewControllerWidget = self.viewControllerWidgets[identifier] + secondaryTableNodePlotTypeComboBox = viewControllerWidget.findChild( + qt.QComboBox, "secondaryTableNodePlotTypeComboBox" + str(identifier) + ) + viewData.secondaryTableNodePlotType = secondaryTableNodePlotTypeComboBox.currentData self.refreshViews("secondaryTableNodePlotTypeChanged") def configureSliceViewsAllowedSegmentationNodes(self): @@ -1366,7 +1362,7 @@ def segmentationNodeOrSourceVolumeNodeChanged(self, segmentationNode=None, sourc prepareBasicViews = True if not prepareBasicViews: - msg_box = qt.QMessageBox(slicer.util.mainWindow()) + msg_box = qt.QMessageBox(slicer.modules.AppContextInstance.mainWindow) msg_box.setIcon(qt.QMessageBox.Question) msg_box.setStandardButtons(qt.QMessageBox.Yes | qt.QMessageBox.No) msg_box.setDefaultButton(qt.QMessageBox.Yes) @@ -1440,19 +1436,6 @@ def getViewDataListIdentifiers(self): def getViewPrimaryNode(self, identifier): return self.getNodeById(self.imageLogViewList[identifier].viewData.primaryNodeId) - def delayedAdjustViewsVisibleRegion(self): - """ - Handles timer to adjust view region. - """ - if self.debug: - print("adjustViewsVisibleRegion delayed") - - if self.__adjustViewsVisibleRegionTimer.isActive(): - self.__adjustViewsVisibleRegionTimer.stop() - - self.__adjustViewsVisibleRegionTimer.setInterval(self.REFRESH_DELAY) - self.__adjustViewsVisibleRegionTimer.start() - def adjustViewsVisibleRegion(self): """ Iterates through the viewDataList and sets currentRange to the full data if it is None, or set the corresponding view range to @@ -1511,6 +1494,9 @@ def fitToAspectRatio(self, aspectRatio=1): sliceNode = sliceLogic.GetSliceNode() fieldOfView = sliceNode.GetFieldOfView() windowSizeFactor = sliceNode.GetDimensions()[0] / sliceNode.GetDimensions()[1] + if windowSizeFactor == 0 or aspectRatio == 0: + continue + sliceNode.SetFieldOfView(fieldOfView[0], (fieldOfView[0] / windowSizeFactor) / aspectRatio, 1) xyToRAS = sliceNode.GetXYToRAS() dimensions = sliceNode.GetDimensions() @@ -1628,11 +1614,11 @@ def showHidePrimaryNode(self, identifier): qt.QPushButton, "showHidePrimaryNodeButton" + str(identifier) ) if showHidePrimaryNodeButton.checked: - showHidePrimaryNodeButton.setIcon(qt.QIcon(str(Customizer.CLOSED_EYE_ICON_PATH))) + showHidePrimaryNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeClosed.png")) sliceCompositeNode.SetBackgroundOpacity(0) viewData.primaryNodeHidden = True else: - showHidePrimaryNodeButton.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + showHidePrimaryNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) sliceCompositeNode.SetBackgroundOpacity(1) viewData.primaryNodeHidden = False @@ -1653,7 +1639,7 @@ def showHideSegmentationNode(self, identifier): qt.QToolButton, "showHideSegmentationNodeButton" + str(identifier) ) if showHideSegmentationNodeButton.checked: - showHideSegmentationNodeButton.setIcon(qt.QIcon(str(Customizer.CLOSED_EYE_ICON_PATH))) + showHideSegmentationNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeClosed.png")) if segmentationNode is not None: if type(segmentationNode) is slicer.vtkMRMLSegmentationNode: segmentationNode.GetDisplayNode().RemoveViewNodeID(viewId) @@ -1661,7 +1647,7 @@ def showHideSegmentationNode(self, identifier): sliceCompositeNode.SetLabelVolumeID(None) viewData.segmentationNodeHidden = True else: - showHideSegmentationNodeButton.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + showHideSegmentationNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) if segmentationNode is not None: if type(segmentationNode) is slicer.vtkMRMLSegmentationNode: segmentationNode.GetDisplayNode().AddViewNodeID(viewId) @@ -1677,7 +1663,7 @@ def showHideSegmentationNode(self, identifier): showHideProportionsNodeButton.click() # "Reloads" the active effect to enable it in the views when the user change the view segmentation - segmentEditorWidget = self.imageLogSegmenterWidget.self().segmentEditorWidget + segmentEditorWidget = slicer.util.getModuleWidget("ImageLogSegmentEditor").segmentEditorWidget activeEffect = segmentEditorWidget.activeEffect() segmentEditorWidget.setActiveEffectByName("None") segmentEditorWidget.setActiveEffect(activeEffect) @@ -1716,12 +1702,12 @@ def showHideProportionsNode(self, identifier): qt.QPushButton, "showHideProportionsNodeButton" + str(identifier) ) if showHideProportionsNodeButton.checked: - showHideProportionsNodeButton.setIcon(qt.QIcon(str(Customizer.CLOSED_EYE_ICON_PATH))) + showHideProportionsNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeClosed.png")) sliceCompositeNode.SetLabelVolumeID(None) viewData.proportionsNodeHidden = True else: proportionsNodeId = self.imageLogViewList[identifier].viewData.proportionsNodeId - showHideProportionsNodeButton.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + showHideProportionsNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) if proportionsNodeId is not None: sliceCompositeNode.SetLabelVolumeID(proportionsNodeId) viewData.proportionsNodeHidden = False @@ -1738,12 +1724,6 @@ def showHideProportionsNode(self, identifier): if not showHideSegmentationNodeButton.checked: showHideSegmentationNodeButton.click() - def setImageLogSegmenterWidget(self, imageLogSegmenterWidget): - """ - Allows Image Log Data to perform some interface on Image Log Segmenter widget while the user sets up the views. - """ - self.imageLogSegmenterWidget = imageLogSegmenterWidget - def customResizeWidgetCallback(self, width): self.delayedAdjustViewsVisibleRegion() self.updateViewColorBarWidgetsWidth(width) @@ -1832,7 +1812,11 @@ def uninstallObservers(self): self.__observerHandlers.clear() - def onNodeAdded(self, caller, event): + @vtk.calldata_type(vtk.VTK_OBJECT) + def onNodeAdded(self, caller, event, node): + if node.GetHideFromEditors(): + # Non-data nodes such as segment editors should not trigger a refresh + return self.blockAllViewControllerSignals(True) self.refreshViews("onNodeAdded") @@ -1855,7 +1839,11 @@ def onSceneEndImport(self, caller, event): def blockAllViewControllerSignals(self, mode=True): for viewControllerWidget in self.viewControllerWidgets: - comboBoxes = viewControllerWidget.findChildren(slicer.qMRMLNodeComboBox) + try: + comboBoxes = viewControllerWidget.findChildren(slicer.qMRMLNodeComboBox) + except ValueError: # Widget has been deleted + continue + for comboBox in comboBoxes: comboBox.blockSignals(mode) @@ -1874,28 +1862,6 @@ def logMode(self, identifier, activated): self.imageLogViewList[identifier].viewData.logMode = activated self.refreshViews("logMode") - def __createRefreshTimer(self): - """Initialize timer object that process plots refresh""" - if hasattr(self, "__refreshViewsTimer") and self.__refreshViewsTimer is not None: - self.__refreshViewsTimer.delete() - self.__refreshViewsTimer = None - - self.__refreshViewsTimer = qt.QTimer(self) - self.__refreshViewsTimer.setSingleShot(True) - self.__refreshViewsTimer.timeout.connect(lambda: self.__refreshViews("Timer")) - self.__refreshViewsTimer.setInterval(self.REFRESH_DELAY) - - def __createAdjustViewsVisibleRegionTimer(self): - """Initialize timer object that process plots refresh""" - if hasattr(self, "__adjustViewsVisibleRegionTimer") and self.__adjustViewsVisibleRegionTimer is not None: - self.__adjustViewsVisibleRegionTimer.delete() - self.__adjustViewsVisibleRegionTimer = None - - self.__adjustViewsVisibleRegionTimer = qt.QTimer(self) - self.__adjustViewsVisibleRegionTimer.setSingleShot(True) - self.__adjustViewsVisibleRegionTimer.timeout.connect(self.adjustViewsVisibleRegion) - self.__adjustViewsVisibleRegionTimer.setInterval(self.REFRESH_DELAY) - def fit(self): self.findMaximumCurrentRange() self.delayedAdjustViewsVisibleRegion() @@ -1963,6 +1929,56 @@ def getMaximumCurrentRange(self): else: return None, None + def __updateToolBarVisibility(self) -> None: + widget = self.containerWidgets.get("toolBarWidget") + if widget is None: + return + + for wid in widget.children(): + if hasattr(wid, "setVisible"): + wid.setVisible(True) + + wid.update() + + def __addImageLogViewOption(self): + if self.imageLogLayoutViewAction is not None: + return + + viewToolBar = slicer.util.mainWindow().findChild("QToolBar", "ViewToolBar") + layoutMenu = viewToolBar.widgetForAction(viewToolBar.actions()[0]).menu() + + imageLogActionText = "ImageLog View" + layoutActions = {action.text: action for action in layoutMenu.actions()} + imageLogActionInMenu = imageLogActionText in layoutActions.keys() + + if not imageLogActionInMenu: + self.imageLogLayoutViewAction = qt.QAction("ImageLog View") + self.imageLogLayoutViewAction.setIcon(qt.QIcon(getResourcePath("Icons") / "ImageLog.png")) + self.imageLogLayoutViewAction.triggered.connect(self.__onImagelogLayoutViewActionClicked) + + after3DOnlyActionIndex = next( + (i for i, action in enumerate(layoutMenu.actions()) if action.text == "3D only"), None + ) + layoutMenu.insertAction( + layoutMenu.actions()[after3DOnlyActionIndex + 1], self.imageLogLayoutViewAction + ) # insert new action before reference + else: + self.imageLogLayoutViewAction = layoutActions["ImageLog View"] + + def __onImagelogLayoutViewActionClicked(self): + slicer.util.getModuleLogic("ImageLogData").changeToLayout() + self.imageLogLayoutViewAction.setData(slicer.modules.AppContextInstance.imageLogLayoutId) + + def __updateImageLogLayoutActionVisibility(self): + currentEnvironment = slicer.modules.AppContextInstance.modules.currentWorkingDataType[1] + isEnvironmentValid = currentEnvironment in self.ALLOWED_ENVIRONMENTS_FOR_LAYOUT + if not isEnvironmentValid and slicer.app.layoutManager().layout >= ImageLogConst.DEFAULT_LAYOUT_ID_START_VALUE: + viewToolBar = slicer.util.mainWindow().findChild("QToolBar", "ViewToolBar") + layoutMenu = viewToolBar.widgetForAction(viewToolBar.actions()[0]).menu() + layoutMenu.actions()[0].triggered() # Force triggering action to update menu icon + + self.imageLogLayoutViewAction.setVisible(isEnvironmentValid) + class ImageLogDataInfo(RuntimeError): pass diff --git a/src/modules/ImageLogData/ImageLogDataLib/__init__.py b/src/modules/ImageLogData/ImageLogDataLib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/ImageLogData/ImageLogDataLib/mouse_event_filter.py b/src/modules/ImageLogData/ImageLogDataLib/mouse_event_filter.py new file mode 100644 index 0000000..4418982 --- /dev/null +++ b/src/modules/ImageLogData/ImageLogDataLib/mouse_event_filter.py @@ -0,0 +1,102 @@ +import qt +import logging + +from .viewdata.ViewData import SliceViewData, GraphicViewData + + +class MouseEventFilter(qt.QObject): + def __init__(self, logic): + self.dataLogic = logic + super().__init__(logic) + + def getGeometry(self, viewWidget): + if self.dataLogic is None or not hasattr(self.dataLogic, "axisItem"): + return qt.QRect(0, 0, 0, 0) + + logGeometry = viewWidget.geometry + + oldWidth = logGeometry.width() + oldHeight = logGeometry.height() + axisGeometry = self.dataLogic.axisItem.geometry() + + logGeometryXY = viewWidget.mapToGlobal(qt.QPoint(0, axisGeometry.y())) + + logGeometry = qt.QRect(logGeometryXY.x(), logGeometryXY.y(), oldWidth, oldHeight) + + return logGeometry + + def getCursorPhysicalPosition(self, viewWidget, imageLogView, x, y): + width = viewWidget.geometry.width() + + if imageLogView.widget == None: + # This avoids raising unnecessary errors, + # but might also avoid unknown necessary ones, too + return -1, -1 + + xDepth = imageLogView.widget.getGraphX(x, width) + + axisItem = self.dataLogic.axisItem + height = axisItem.geometry().height() + + vDif = axisItem.range[0] - axisItem.range[1] + if vDif == 0: + yScale = 1 + yOffset = 0 + else: + yScale = height / vDif + yOffset = axisItem.range[1] * yScale + + yDepth = (y + yOffset) / yScale + + return xDepth, yDepth + + def getValue(self, imageLogView, x, y): + value = None + if imageLogView.widget: + value = imageLogView.widget.getValue(x, y) + return value + + def writeCoordinates(self, identifier, x, y, value): + text = f"View #{identifier} ({x:.2f}, {y:.2f})" + text = text + f" {value:.2f}" if isinstance(value, float) else text + f" {value}" + toolBarWidget = self.dataLogic.containerWidgets["toolBarWidget"] + mousePhysicalCoordinates = toolBarWidget.findChild(qt.QLabel, "MousePhysicalCoordinates") + mousePhysicalCoordinates.setText(text) + + def eventFilter(self, widget, event): + if not (isinstance(event, qt.QHoverEvent) or isinstance(event, qt.QWheelEvent)): + return + for identifier in self.dataLogic.getViewDataListIdentifiers(): + imageLogView = self.dataLogic.imageLogViewList[identifier] + if imageLogView is None: + continue + + try: + viewWidget = self.dataLogic.viewWidgets[identifier] + except IndexError as error: + logging.debug(error) + continue + + if viewWidget is None: + continue + + viewData = imageLogView.viewData + if viewData.primaryNodeId == None: + continue + if ( + type(viewData) is SliceViewData + or type(viewData) is GraphicViewData + and (viewData.primaryTableNodeColumn != "" or viewData.secondaryTableNodeColumn != "") + ): + posMouse = qt.QCursor().pos() + logGeometry = self.getGeometry(viewWidget) + if not logGeometry.contains(posMouse): + continue + relativePosMouseY = posMouse.y() - logGeometry.y() + relativePosMouseX = posMouse.x() - logGeometry.x() + physicalPos = self.getCursorPhysicalPosition( + viewWidget, imageLogView, relativePosMouseX, relativePosMouseY + ) + value = self.getValue(imageLogView, *physicalPos) + self.writeCoordinates(identifier, *physicalPos, value) + break diff --git a/src/modules/ImageLogData/ImageLogDataLib/treeview/SubjectHierarchyTreeViewFilter.py b/src/modules/ImageLogData/ImageLogDataLib/treeview/SubjectHierarchyTreeViewFilter.py index 930d89e..4c1b421 100644 --- a/src/modules/ImageLogData/ImageLogDataLib/treeview/SubjectHierarchyTreeViewFilter.py +++ b/src/modules/ImageLogData/ImageLogDataLib/treeview/SubjectHierarchyTreeViewFilter.py @@ -18,7 +18,7 @@ def anyUnderMouse(self, widgetGeometries): return False def viewPortRectWithOffset(self): - mw = slicer.util.mainWindow() + mw = slicer.modules.AppContextInstance.mainWindow vp = self.dataWidget.logic.layoutManagerViewPort d = mw.findChild(qt.QDockWidget, "PanelDockWidget") if d.isVisible() and not d.isFloating(): @@ -34,7 +34,7 @@ def viewPortRectWithOffset(self): def widgetRectsWithOffset(self): rects = [] - mw = slicer.util.mainWindow() + mw = slicer.modules.AppContextInstance.mainWindow d = mw.findChild(qt.QDockWidget, "PanelDockWidget") for w in self.dataWidget.logic.viewWidgets: if d.isVisible() and not d.isFloating(): diff --git a/src/modules/ImageLogData/ImageLogDataLib/view/View.py b/src/modules/ImageLogData/ImageLogDataLib/view/View.py index 81eef00..9841699 100644 --- a/src/modules/ImageLogData/ImageLogDataLib/view/View.py +++ b/src/modules/ImageLogData/ImageLogDataLib/view/View.py @@ -25,7 +25,7 @@ def __init__(self, *args, **kwargs): def setup(self): layout = qt.QGridLayout(self) - layout.setContentsMargins(0, 0, 0, 0) + layout.setContentsMargins(0, 21, 0, 0) self.pixmapLabel = qt.QLabel() self.pixmapLabel.setSizePolicy(qt.QSizePolicy.Ignored, qt.QSizePolicy.Preferred) layout.addWidget(self.pixmapLabel, 0, 0, 1, 3) @@ -55,6 +55,12 @@ def updateWidth(self, width): self.pixmapLabel.setPixmap(self.pixmap.scaled(width, 20)) def updateInformation(self, window, level): + if np.isnan(window): + window = 0 + + if np.isnan(level): + level = 0 + self.minLabel.setText(" " + str(int(np.around(level - window / 2)))) self.maxLabel.setText(str(int(np.around(level + window / 2))) + " ") @@ -95,9 +101,11 @@ def resizeEvent(self, evt=None): self.callback() -PlotInformation = namedtuple("PlotInformation", ["graph_data", "x_parameter", "y_parameter"]) +PlotInformation = namedtuple("PlotInformation", ["graph_data", "x_parameter", "y_parameter", "view_box"]) PLOT_UPDATE_TIMER_INTERVAL_MS = 50 PLOT_MINIMUM_HEIGHT = 200 +PRIMARY_VIEW_BOX = "primary" +SECONDARY_VIEW_BOX = "secondary" class CurvePlot(QtWidgets.QWidget): @@ -115,6 +123,7 @@ def __init__(self, *args, **kwargs): self.__y_max_value_range = 0 self.__create_plot_update_timer() self.__setup_widget() + self.__create_secondary_plot() def __del__(self): if self.plot_update_timer.isActive(): @@ -149,18 +158,43 @@ def __setup_widget(self): layout.addWidget(self._graphics_layout_widget, 10) self.setLayout(layout) - def logMode(self, activated): + def __create_secondary_plot(self): + self.secondary_view_box = pg.ViewBox() + self.secondary_view_box.invertY() + self.secondary_view_box.setYLink(self._plot_item) + self.secondary_view_box.enableAutoRange() + + self.secondary_axis = pg.AxisItem("top") + self.secondary_axis.linkToView(self.secondary_view_box) + self.secondary_axis.setZValue(-10000) + + log_x_action = QtGui.QAction("Log X", self.secondary_view_box.menu) + log_x_action.setCheckable(True) + log_x_action.triggered.connect(self.__set_secondary_log_mode) + + self.secondary_view_box.menu.addAction(log_x_action) + + def __set_secondary_log_mode(self, state): + self.secondary_axis.setLogMode(state) for plot_info in self.__plots: - data = plot_info.graph_data.data - for key, value in data.items(): - if list(data.df).index(key) > 0: # Do not change the first column - if activated: - with np.errstate(divide="ignore"): - data.df[key] = np.log10(value) - else: - data.df[key] = 10**value + if plot_info.view_box == SECONDARY_VIEW_BOX: + self.__logMode(plot_info.graph_data.data, state) + + def __logMode(self, data, activated): + for key, value in data.items(): + if list(data.df).index(key) > 0: # Do not change the first column + if activated: + with np.errstate(divide="ignore"): + data.df[key] = np.log10(value) + else: + data.df[key] = 10**value self.update_plot() + def set_primary_logMode(self, activated): + for plot_info in self.__plots: + if plot_info.view_box == PRIMARY_VIEW_BOX: + self.__logMode(plot_info.graph_data.data, activated) + def add_data( self, data_node: slicer.vtkMRMLNode, @@ -170,6 +204,7 @@ def add_data( color=None, symbol=None, size=None, + view_box=PRIMARY_VIEW_BOX, ): """Store and parse node's data. Each data will be available at the table's widget as well. @@ -190,7 +225,9 @@ def add_data( graph_data.signalModified.connect(self.update_plot) for parameter in x_parameter if type(x_parameter) is list else [x_parameter]: - plot_info = PlotInformation(graph_data=graph_data, x_parameter=parameter, y_parameter=y_parameter) + plot_info = PlotInformation( + graph_data=graph_data, x_parameter=parameter, y_parameter=y_parameter, view_box=view_box + ) graph_data.signalRemoved.connect(lambda: self.remove_plot(plot_info)) self.__plots.append(plot_info) self.update_plot() @@ -200,6 +237,7 @@ def add_data( def __clear_plot(self): """Handles plot clearing""" self._plot_item.clear() + self.secondary_view_box.clear() def __create_plot_update_timer(self): """Initialize timer object that process data to plot""" @@ -279,14 +317,29 @@ def __handle_update_plot(self): brush=brush, ) - self._plot_item.addItem(plot) - y_min = self.__update_min_limit(y_min, y_data) y_max = self.__update_max_limit(y_max, y_data) x_min = self.__update_min_limit(x_min, x_data) x_max = self.__update_max_limit(x_max, x_data) - self._plot_item.invertY() + if plot_info.view_box == PRIMARY_VIEW_BOX: + self._plot_item.addItem(plot) + self._plot_item.invertY() + self._plot_item.getAxis("top").setPen(color=color, width=2) + + else: + self.secondary_axis.setPen(color=color, width=2) + + self._plot_item.layout.removeItem(self._plot_item.layout.itemAt(0, 1)) + self._plot_item.layout.addItem(self.secondary_axis, 0, 1) + self._plot_item.scene().addItem(self.secondary_view_box) + + def update_views(): + self.secondary_view_box.setGeometry(self._plot_item.vb.sceneBoundingRect()) + + update_views() + self._plot_item.vb.sigResized.connect(update_views) + self.secondary_view_box.addItem(plot) self._plot_item.getViewBox().enableAutoRange() @@ -407,6 +460,9 @@ def get_data_range(self): return ((x_min, x_max), (y_min, y_max)) + def get_plot_item(self): + return self._plot_item + @staticmethod def __update_max_limit(value, array): if value is None: diff --git a/src/modules/ImageLogData/ImageLogDataLib/view/image_log_view.py b/src/modules/ImageLogData/ImageLogDataLib/view/image_log_view.py index 06944aa..89760c4 100644 --- a/src/modules/ImageLogData/ImageLogDataLib/view/image_log_view.py +++ b/src/modules/ImageLogData/ImageLogDataLib/view/image_log_view.py @@ -1,3 +1,5 @@ +import logging + import slicer from ImageLogDataLib.viewwidgets.graphic_view_widget import GraphicViewWidget @@ -111,7 +113,7 @@ def set_new_secondary_node(self, node): if node is not None: self.viewData.secondaryTableNodeId = node.GetID() table_type = self.__get_table_type(node) - if table_type == self.TABLE_TYPE_IMAGE_LOG: + if table_type in [self.TABLE_TYPE_IMAGE_LOG, self.TABLE_TYPE_POROSITY_PER_REALIZATION]: columns = self.__getParametersForGraphicViewData(node) elif table_type == self.TABLE_TYPE_HISTOGRAM_IN_DEPTH: columns = self.__getParametersHistogramInDepth(node) @@ -138,26 +140,24 @@ def __new_view_data_for_node(self, node): sliceViewData.primaryNodeId = node.GetID() return sliceViewData, None elif type(node) is slicer.vtkMRMLTableNode: - table_type = self.__get_table_type(node) - graphicViewData = GraphicViewData() - graphicViewData.primaryNodeId = node.GetID() - if table_type == self.TABLE_TYPE_IMAGE_LOG: - columns = self.__getParametersForGraphicViewData(node) - graphicViewData.primaryTableNodeColumn = columns[0] - graphicViewData.primaryTableNodeColumnList = columns - return graphicViewData, table_type - elif table_type == self.TABLE_TYPE_HISTOGRAM_IN_DEPTH: - columns = self.__getParametersHistogramInDepth(node) - graphicViewData.primaryTableNodeColumn = columns[0] - graphicViewData.primaryTableNodeColumnList = columns - graphicViewData.primaryTableHistogram = True - graphicViewData.primaryTableScaleHistogram = 1.0 - return graphicViewData, table_type - elif table_type == self.TABLE_TYPE_POROSITY_PER_REALIZATION: - columns = self.__getParametersForGraphicViewData(node) - graphicViewData.primaryTableNodeColumn = columns[-1] - graphicViewData.primaryTableNodeColumnList = columns - return graphicViewData, table_type + if node.GetTable().GetColumn(0) is not None: + table_type = self.__get_table_type(node) + graphicViewData = GraphicViewData() + graphicViewData.primaryNodeId = node.GetID() + if table_type in [self.TABLE_TYPE_IMAGE_LOG, self.TABLE_TYPE_POROSITY_PER_REALIZATION]: + columns = self.__getParametersForGraphicViewData(node) + graphicViewData.primaryTableNodeColumn = columns[0] + graphicViewData.primaryTableNodeColumnList = columns + return graphicViewData, table_type + elif table_type == self.TABLE_TYPE_HISTOGRAM_IN_DEPTH: + columns = self.__getParametersHistogramInDepth(node) + graphicViewData.primaryTableNodeColumn = columns[0] + graphicViewData.primaryTableNodeColumnList = columns + graphicViewData.primaryTableHistogram = True + graphicViewData.primaryTableScaleHistogram = 1.0 + return graphicViewData, table_type + else: + logging.warning(f"Table node: {node.GetName()} is empty and a GraphicViewData could not be created") return EmptyViewData(), None diff --git a/src/modules/ImageLogData/ImageLogDataLib/viewcontroller/ViewController.py b/src/modules/ImageLogData/ImageLogDataLib/viewcontroller/ViewController.py index 009cc50..fec5dc6 100644 --- a/src/modules/ImageLogData/ImageLogDataLib/viewcontroller/ViewController.py +++ b/src/modules/ImageLogData/ImageLogDataLib/viewcontroller/ViewController.py @@ -5,13 +5,13 @@ import ctk import qt import slicer -from Customizer import Customizer from ltrace.slicer.helpers import themeIsDark from ltrace.slicer.node_attributes import TableType, ImageLogDataSelectable, DataOrigin from ltrace.slicer.ui import filteredNodeComboBox -from ltrace.slicer.widget.help_button import HelpButton from ltrace.slicer.widget.elided_label import ElidedLabel +from ltrace.slicer.widget.help_button import HelpButton +from ltrace.slicer_utils import getResourcePath class ViewControllerWidget(qt.QWidget): @@ -38,9 +38,11 @@ def setupControllerBar(self): settingsToolButton.setObjectName("settingsToolButton" + str(self.identifier)) settingsToolButton.setCheckable(True) settingsToolButton.setIconSize(qt.QSize(16, 16)) + + iconsRes = getResourcePath("Icons") settingsButtonIcon = qt.QIcon() - settingsButtonIcon.addFile(str(Customizer.PUSH_PIN_IN_ICON_PATH), qt.QSize(), qt.QIcon.Normal, qt.QIcon.On) - settingsButtonIcon.addFile(str(Customizer.PUSH_PIN_OUT_ICON_PATH), qt.QSize(), qt.QIcon.Normal, qt.QIcon.Off) + settingsButtonIcon.addFile(iconsRes / "PushPinIn.png", qt.QSize(), qt.QIcon.Normal, qt.QIcon.On) + settingsButtonIcon.addFile(iconsRes / "PushPinOut.png", qt.QSize(), qt.QIcon.Normal, qt.QIcon.Off) settingsToolButton.setIcon(settingsButtonIcon) controllerBarLayout.addWidget(settingsToolButton) @@ -74,7 +76,7 @@ def setupControllerBar(self): removeViewButton = qt.QPushButton() removeViewButton.setToolTip("Remove this view.") - removeViewButton.setIcon(qt.QIcon(str(Customizer.CANCEL_ICON_PATH))) + removeViewButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Cancel.png")) removeViewButton.setIconSize(qt.QSize(12, 14)) removeViewButton.clicked.connect(lambda arg, identifier=self.identifier: self.logic.removeView(identifier)) controllerBarLayout.addWidget(removeViewButton) @@ -141,7 +143,7 @@ def setupSettingsPopup(self): showHidePrimaryNodeButton = qt.QPushButton() showHidePrimaryNodeButton.setCheckable(True) showHidePrimaryNodeButton.setObjectName("showHidePrimaryNodeButton" + str(self.identifier)) - showHidePrimaryNodeButton.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + showHidePrimaryNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) showHidePrimaryNodeButton.setIconSize(qt.QSize(14, 14)) showHidePrimaryNodeButton.setFixedWidth(30) showHidePrimaryNodeButton.clicked.connect( @@ -183,7 +185,7 @@ def setupSettingsPopup(self): showHideSegmentationNodeButton.setCheckable(True) showHideSegmentationNodeButton.setPopupMode(qt.QToolButton.MenuButtonPopup) showHideSegmentationNodeButton.setObjectName("showHideSegmentationNodeButton" + str(self.identifier)) - showHideSegmentationNodeButton.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + showHideSegmentationNodeButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) showHideSegmentationNodeButton.setIconSize(qt.QSize(14, 14)) showHideSegmentationNodeButton.setFixedWidth(30) showHideSegmentationNodeButton.clicked.connect( @@ -263,7 +265,9 @@ def setupSettingsPopup(self): ) self.primaryNodeLayout.addWidget(primaryTableNodePlotTypeComboBox, 0) # Primary plot color - primaryTableNodePlotColorPicker = ColorPickerCell(self.identifier, self.logic.primaryTableNodePlotColorChanged) + primaryTableNodePlotColorPicker = ColorPickerCell( + self, self.identifier, self.logic.primaryTableNodePlotColorChanged + ) primaryTableNodePlotColorPicker.setObjectName("primaryTableNodePlotColorPicker" + str(self.identifier)) self.primaryNodeLayout.addWidget(primaryTableNodePlotColorPicker, 0) @@ -279,6 +283,7 @@ def setupSettingsPopup(self): secondaryTableNodeComboBox.addAttributeFilter(TableType.name(), TableType.BASIC_PETROPHYSICS.value) secondaryTableNodeComboBox.addAttributeFilter(TableType.name(), TableType.IMAGE_LOG.value) secondaryTableNodeComboBox.addAttributeFilter(DataOrigin.name(), DataOrigin.IMAGE_LOG.value) + secondaryTableNodeComboBox.addAttributeFilter(ImageLogDataSelectable.name(), ImageLogDataSelectable.TRUE.value) secondaryTableNodeComboBox.setObjectName("secondaryTableNodeComboBox" + str(self.identifier)) secondaryTableNodeComboBox.view().setMinimumWidth(250) secondaryTableNodeComboBox.nodeAboutToBeRemoved.connect( @@ -313,7 +318,7 @@ def setupSettingsPopup(self): # Secondary plot color secondaryTableNodePlotColorPicker = ColorPickerCell( - self.identifier, self.logic.secondaryTableNodePlotColorChanged + self, self.identifier, self.logic.secondaryTableNodePlotColorChanged ) secondaryTableNodePlotColorPicker.setObjectName("secondaryTableNodePlotColorPicker" + str(self.identifier)) self.secondaryTableNodeLayout.addWidget(secondaryTableNodePlotColorPicker, 0) @@ -322,8 +327,8 @@ def setupSettingsPopup(self): class ColorPickerCell(qt.QWidget): - def __init__(self, identifier, callback, *args, color="#FFFFFF", **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, parent, identifier, callback, *args, color="#FFFFFF", **kwargs): + super().__init__(parent, *args, **kwargs) self.identifier = identifier self.callback = callback self.setLayout(qt.QVBoxLayout()) @@ -347,26 +352,25 @@ def __init__(self, identifier, callback, *args, color="#FFFFFF", **kwargs): def onClicked(): if self.histogramMode: - self.color_widget = qt.QWidget() - self.color_widget.setFixedSize(512, 430) - self.color_widget.setWindowTitle("Select Color") - layoutvert = qt.QVBoxLayout() - layoutvertgroup = qt.QVBoxLayout() - group_box = qt.QGroupBox("Histogram Scale") - layoutvert.addWidget(group_box) - self.spin_box = qt.QDoubleSpinBox() # set SpinBox - self.spin_box.setDecimals(2) # set 2 digits after comma - self.spin_box.setRange(0.01, 10000.0) # set min max spinbox - self.spin_box.setValue(self.currentValue) # set initial spinbox - layoutvertgroup.addWidget(self.spin_box) - group_box.setLayout(layoutvertgroup) - self.colordialog = qt.QColorDialog(qt.QColor(self.currentColor)) # set ColorDialog + self.colorWidget = qt.QWidget() + self.colorWidget.setWindowTitle("Select Color") + layoutVert = qt.QVBoxLayout() + layoutVertGroup = qt.QVBoxLayout() + groupBox = qt.QGroupBox("Histogram Scale") + layoutVert.addWidget(groupBox) + self.spinBox = qt.QDoubleSpinBox() + self.spinBox.setDecimals(2) + self.spinBox.setRange(0.01, 10000.0) + self.spinBox.setValue(self.currentValue) + layoutVertGroup.addWidget(self.spinBox) + groupBox.setLayout(layoutVertGroup) + self.colordialog = qt.QColorDialog(qt.QColor(self.currentColor)) self.colordialog.setOptions(qt.QColorDialog.DontUseNativeDialog) - layoutvert.addWidget(self.colordialog) - self.color_widget.setLayout(layoutvert) - self.color_widget.show() - self.colordialog.accepted.connect(self.okWindow) # Conect ColorDialog OK buttom - self.colordialog.rejected.connect(self.cancelWindow) # Conect ColorDialog OK buttom + layoutVert.addWidget(self.colordialog) + self.colorWidget.setLayout(layoutVert) + self.colorWidget.show() + self.colordialog.accepted.connect(self.okWindow) + self.colordialog.rejected.connect(self.cancelWindow) else: color = qt.QColorDialog.getColor(qt.QColor(self.currentColor)) if color.isValid(): @@ -382,9 +386,9 @@ def onClicked(): self.button.clicked.connect(onClicked) def okWindow(self): - self.color_widget.close() # close widget + self.colorWidget.close() # close widget color = self.colordialog.selectedColor() - self.currentValue = self.spin_box.value + self.currentValue = self.spinBox.value self.button.setStyleSheet( "QPushButton {" "font-size:11px;" @@ -395,7 +399,7 @@ def okWindow(self): self.callback(self.identifier, color.name(), self.currentValue) def cancelWindow(self): - self.color_widget.close() # close widget + self.colorWidget.close() # close widget def setColor(self, color): self.button.setStyleSheet( diff --git a/src/modules/ImageLogData/ImageLogDataLib/viewdata/ViewData.py b/src/modules/ImageLogData/ImageLogDataLib/viewdata/ViewData.py index dea8e54..0b46c6f 100644 --- a/src/modules/ImageLogData/ImageLogDataLib/viewdata/ViewData.py +++ b/src/modules/ImageLogData/ImageLogDataLib/viewdata/ViewData.py @@ -18,6 +18,9 @@ def __init__(self, primaryNodeId=None): self.primaryNodeId = primaryNodeId self.viewControllerSettingsToolButtonToggled = True + def to_json(self): + return {} + class SliceViewData(ViewData): VIEW_NAME_PREFIX = "ImageLogSliceView" @@ -55,18 +58,19 @@ class GraphicViewData(ViewData): def __init__(self): super().__init__() - color = "#000000" + primaryColor = "#000000" + secondaryColor = "#FF0000" self.primaryTableNodeColumnList = [] self.primaryTableNodeColumn = "" self.primaryTableNodePlotType = LINE_PLOT_TYPE - self.primaryTableNodePlotColor = color + self.primaryTableNodePlotColor = primaryColor self.primaryTableHistogram = False self.primaryTableScaleHistogram = 1 self.secondaryTableNodeId = None self.secondaryTableNodeColumnList = [] self.secondaryTableNodeColumn = "" self.secondaryTableNodePlotType = LINE_PLOT_TYPE - self.secondaryTableNodePlotColor = color + self.secondaryTableNodePlotColor = secondaryColor self.secondaryTableHistogram = False self.logMode = False @@ -93,6 +97,3 @@ class EmptyViewData(ViewData): def __init__(self): super().__init__() - - def to_json(self): - return super().to_json() diff --git a/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/graphic_view_widget.py b/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/graphic_view_widget.py index eaff90f..b7eb248 100644 --- a/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/graphic_view_widget.py +++ b/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/graphic_view_widget.py @@ -5,7 +5,7 @@ import slicer from .base_view_widget import BaseViewWidget -from ImageLogDataLib.view.View import CurvePlot +from ImageLogDataLib.view.View import CurvePlot, SECONDARY_VIEW_BOX from ltrace.slicer.graph_data import LINE_PLOT_TYPE, SCATTER_PLOT_TYPE from ltrace.slicer_utils import tableNodeToDict @@ -22,9 +22,11 @@ def __init__(self, parent, view_data, primary_node): self.curve_plot._plot_item.setContentsMargins(-7, -7, -6, -6) self.curve_plot._plot_item.hideButtons() self.curve_plot._plot_item.hideAxis("left") - self.curve_plot._plot_item.getAxis("bottom").setPen(color=(0, 0, 0)) - self.curve_plot._plot_item.getAxis("bottom").setTextPen(color=(0, 0, 0)) - self.curve_plot._plot_item.signalLogMode.connect(self.curve_plot.logMode) + self.curve_plot._plot_item.hideAxis("bottom") + self.curve_plot._plot_item.showAxis("top") + self.curve_plot._plot_item.getAxis("top").setPen(color=(0, 0, 0)) + self.curve_plot._plot_item.getAxis("top").setTextPen(color=(0, 0, 0)) + self.curve_plot._plot_item.signalLogMode.connect(self.curve_plot.set_primary_logMode) self.__primary_table_dict = tableNodeToDict(primary_node) # Primary table node @@ -61,6 +63,7 @@ def __init__(self, parent, view_data, primary_node): plot_type=secondary_plot_type, color=view_data.secondaryTableNodePlotColor, symbol=secondary_plot_symbol, + view_box=SECONDARY_VIEW_BOX, ) if view_data.logMode: diff --git a/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/histogram_in_depth_view_widget.py b/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/histogram_in_depth_view_widget.py index 0c5117b..71f6245 100644 --- a/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/histogram_in_depth_view_widget.py +++ b/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/histogram_in_depth_view_widget.py @@ -29,6 +29,8 @@ def __init__(self, parent, view_data, primary_node): self.curve_plot._plotItem.setContentsMargins(-7, -7, -6, -6) self.curve_plot._plotItem.hideButtons() self.curve_plot._plotItem.hideAxis("left") + self.curve_plot._plotItem.hideAxis("bottom") + self.curve_plot._plotItem.showAxis("top") # Primary table node if view_data.primaryTableNodeColumn != "": @@ -423,6 +425,9 @@ def get_scale(self, graph_data): scale = 0.001 return scale + def get_plot_item(self): + return self._plotItem + def build_plot_generator(self, plot_type: str = LINE_PLOT_TYPE): function = lambda: None if plot_type == LINE_PLOT_TYPE: diff --git a/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/porosity_per_realization_widget.py b/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/porosity_per_realization_widget.py index 5f93ba2..cf09b9f 100644 --- a/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/porosity_per_realization_widget.py +++ b/src/modules/ImageLogData/ImageLogDataLib/viewwidgets/porosity_per_realization_widget.py @@ -5,7 +5,7 @@ import slicer from .base_view_widget import BaseViewWidget -from ImageLogDataLib.view.View import CurvePlot +from ImageLogDataLib.view.View import CurvePlot, SECONDARY_VIEW_BOX from ltrace.slicer.graph_data import LINE_PLOT_TYPE, SCATTER_PLOT_TYPE from ltrace.slicer_utils import tableNodeToDict @@ -22,9 +22,11 @@ def __init__(self, parent, view_data, primary_node): self.curve_plot._plot_item.setContentsMargins(-7, -7, -6, -6) self.curve_plot._plot_item.hideButtons() self.curve_plot._plot_item.hideAxis("left") - self.curve_plot._plot_item.getAxis("bottom").setPen(color=(0, 0, 0)) - self.curve_plot._plot_item.getAxis("bottom").setTextPen(color=(0, 0, 0)) - # self.curve_plot._plot_item.signalLogMode.connect(self.curve_plot.logMode) + self.curve_plot._plot_item.hideAxis("bottom") + self.curve_plot._plot_item.showAxis("top") + self.curve_plot._plot_item.getAxis("top").setPen(color=(0, 0, 0)) + self.curve_plot._plot_item.getAxis("top").setTextPen(color=(0, 0, 0)) + self.curve_plot._plot_item.ctrlMenu.actions()[0].setVisible(False) self.__primary_table_dict = tableNodeToDict(primary_node) if view_data.primaryTableNodeColumn != "": @@ -35,25 +37,40 @@ def __init__(self, parent, view_data, primary_node): primary_plot_type = SCATTER_PLOT_TYPE primary_plot_symbol = view_data.primaryTableNodePlotType + column_List = view_data.primaryTableNodeColumnList.copy() + self.curve_plot.add_data( data_node=primary_node, - x_parameter=view_data.primaryTableNodeColumnList, + x_parameter=view_data.primaryTableNodeColumn, y_parameter="DEPTH", plot_type=primary_plot_type, color=view_data.primaryTableNodePlotColor, symbol=primary_plot_symbol, + size=10, ) + column_List.remove(view_data.primaryTableNodeColumn) - if view_data.primaryTableNodeColumn == "TI": + if "TI" in column_List: self.curve_plot.add_data( data_node=primary_node, - x_parameter=view_data.primaryTableNodeColumn, + x_parameter="TI", y_parameter="DEPTH", plot_type=primary_plot_type, - color="#ff0000", + color="#0000FF", symbol=primary_plot_symbol, size=10, ) + column_List.remove("TI") + + if column_List: + self.curve_plot.add_data( + data_node=primary_node, + x_parameter=column_List, + y_parameter="DEPTH", + plot_type=primary_plot_type, + color="#000000", + symbol=primary_plot_symbol, + ) # Secondary table node secondary_table_node = self.__get_node_by_id(view_data.secondaryTableNodeId) @@ -72,13 +89,9 @@ def __init__(self, parent, view_data, primary_node): plot_type=secondary_plot_type, color=view_data.secondaryTableNodePlotColor, symbol=secondary_plot_symbol, + view_box=SECONDARY_VIEW_BOX, ) - if view_data.logMode: - self.curve_plot._plot_item.ctrl.logXCheck.setCheckState(PySide2.QtCore.Qt.Checked) - - self.curve_plot._plot_item.signalLogMode.connect(self.__on_logmode_changed) - pyside_qvbox_layout = shiboken2.wrapInstance(hash(view_widget_layout), PySide2.QtWidgets.QVBoxLayout) graphics_layout_widget = self.curve_plot._graphics_layout_widget pyside_qvbox_layout.addWidget(graphics_layout_widget) diff --git a/src/modules/ImageLogData/Resources/Icons/ImageLogData.png b/src/modules/ImageLogData/Resources/Icons/ImageLogData.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/ImageLogData/Resources/Icons/ImageLogData.png and /dev/null differ diff --git a/src/modules/ImageLogData/Resources/Icons/ImageLogData.svg b/src/modules/ImageLogData/Resources/Icons/ImageLogData.svg new file mode 100644 index 0000000..bc5899b --- /dev/null +++ b/src/modules/ImageLogData/Resources/Icons/ImageLogData.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ImageLogEnv/ImageLogEnv.py b/src/modules/ImageLogEnv/ImageLogEnv.py index 5766c80..31fb095 100644 --- a/src/modules/ImageLogEnv/ImageLogEnv.py +++ b/src/modules/ImageLogEnv/ImageLogEnv.py @@ -3,34 +3,11 @@ import qt import slicer -from Customizer import Customizer -from ltrace.slicer_utils import * - -import DLISImportLib -from CustomizedSegmentEditor import CustomizedSegmentEditor -from ImageLogInstanceSegmenter import ImageLogInstanceSegmenter -from ImageLogsLib.PermeabilityModeling import PermeabilityModelingWidget -from InstanceSegmenterEditor import InstanceSegmenterEditor -from SegmentInspector import SegmentInspector -from UnwrapRegistration import UnwrapRegistration -from AzimuthShiftTool import AzimuthShiftTool -from ImageLogInpaint import ImageLogInpaint - -# Checks if closed source code is available -try: - from ImageLogsLib.Eccentricity import EccentricityWidget -except: - EccentricityWidget = None - -from QualityIndicator import QualityIndicator -from SpiralFilter import SpiralFilter -from ImageLogExport import ImageLogExport -from ImageLogCropVolume import ImageLogCropVolume - -try: - from Test.PermeabilityModelingTest import PermeabilityModelingTest -except ImportError: - pass + +from ltrace.slicer.helpers import svgToQIcon +from ltrace.slicer.widget.custom_toolbar_buttons import addAction, addMenu +from ltrace.slicer_utils import LTracePlugin, LTracePluginLogic, getResourcePath, LTraceEnvironmentMixin +from ltrace.constants import ImageLogConst class ImageLogEnv(LTracePlugin): @@ -41,244 +18,116 @@ class ImageLogEnv(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Image Log Environment" - self.parent.categories = ["Environments"] + self.parent.categories = ["Environment", "ImageLog"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] + self.parent.helpText = "" - eccentricityHelp = EccentricityWidget.help() if EccentricityWidget else "" - - self.parent.helpText = ( - ImageLogEnv.help() - + ImageLogExport.help() - + ImageLogCropVolume.help() - + eccentricityHelp - + SpiralFilter.help() - + QualityIndicator.help() - + CustomizedSegmentEditor.help() - + ImageLogInstanceSegmenter.help() - + InstanceSegmenterEditor.help() - + SegmentInspector.help() - + UnwrapRegistration.help() - + PermeabilityModelingWidget.help() - + AzimuthShiftTool.help() - + ImageLogInpaint.help() - ) + self.environment = ImageLogEnvLogic() @classmethod def readme_path(cls): return str(cls.MODULE_DIR / "README.md") -class ImageLogEnvWidget(LTracePluginWidget): - def setup(self): - LTracePluginWidget.setup(self) - self.logic = ImageLogEnvLogic() - - self.mainTab = qt.QTabWidget() - self.mainTab.setObjectName("Image Log Main Tab") - - dataTab = qt.QTabWidget() - dataTab.setObjectName("Image Log Data Tab") - processingTab = qt.QTabWidget() - self.segmentationTab = qt.QTabWidget() - registrationTab = qt.QTabWidget() - self.inpaintTab = qt.QTabWidget() - self.imageLogDataWidget = slicer.modules.imagelogdata.createNewWidgetRepresentation() - self.imageLogCropVolume = slicer.modules.imagelogcropvolume.createNewWidgetRepresentation() - self.segment_inspector_env = slicer.modules.segmentinspector.createNewWidgetRepresentation() - self.imageLogSegmenterWidget = slicer.modules.imagelogsegmenter.createNewWidgetRepresentation() - self.instanceSegmenterWidget = slicer.modules.imageloginstancesegmenter.createNewWidgetRepresentation() - self.instanceSegmenterEditorWidget = slicer.modules.instancesegmentereditor.createNewWidgetRepresentation() - self.imageLogUnwrapImportWidget = slicer.modules.imagelogunwrapimport.createNewWidgetRepresentation() - self.imageLogExportWidget = slicer.modules.imagelogexport.createNewWidgetRepresentation() - self.spiralFiterWidget = slicer.modules.spiralfilter.createNewWidgetRepresentation() - self.qualityIndicatorWidget = slicer.modules.qualityindicator.createNewWidgetRepresentation() - self.heterogeneityIndexWidget = slicer.modules.heterogeneityindex.createNewWidgetRepresentation() - self.unwrapRegistrationWidget = slicer.modules.unwrapregistration.createNewWidgetRepresentation() - self.AzimuthShiftToolWidget = slicer.modules.azimuthshifttool.createNewWidgetRepresentation() - self.imageLogInpaint = slicer.modules.imageloginpaint.createNewWidgetRepresentation() - self.coreInpaint = slicer.modules.coreinpaint.createNewWidgetRepresentation() - - self.segment_inspector_env.self().blockVisibilityChanges = True - - imageDataLogic = self.imageLogDataWidget.self().logic - imageDataLogic.layoutViewOpened.connect(self.onImageLogViewOpened) - imageDataLogic.layoutViewClosed.connect(self.onImageLogViewClosed) - - self.imageLogSegmenterWidget.self().logic.setImageLogDataLogic(imageDataLogic) - self.instanceSegmenterEditorWidget.self().logic.setImageLogDataLogic(imageDataLogic) - self.imageLogDataWidget.self().logic.setImageLogSegmenterWidget(self.imageLogSegmenterWidget) - self.imageLogInpaint.self().logic.setImageLogDataLogic(imageDataLogic) - - logImportWidget = DLISImportLib.WellLogImportWidget() - logImportWidget.setObjectName("Well Log Import Widget") - logImportWidget.setAppFolder("Well Logs") - - self.eccentricityWidget = EccentricityWidget() if EccentricityWidget else None - - self.permeabilityModelingWidget = PermeabilityModelingWidget() - - cornerButtonsFrame = qt.QFrame() - cornerButtonsLayout = qt.QHBoxLayout(cornerButtonsFrame) - cornerButtonsLayout.setContentsMargins(0, 0, 0, 0) - - # Show all button - self.fitButton = qt.QPushButton() - self.fitButton.setIcon(qt.QIcon(str(Customizer.FIT_ICON_PATH))) - self.fitButton.setFixedWidth(25) - self.fitButton.setToolTip("Reset the views to fit all data.") - cornerButtonsLayout.addWidget(self.fitButton) - self.fitButton.clicked.connect(self.fit) - - # Adjust to real aspect ratio button - self.fitRealAspectRatio = qt.QPushButton() - self.fitRealAspectRatio.setIcon(qt.QIcon(str(Customizer.FIT_REAL_ASPECT_RATIO_ICON_PATH))) - self.fitRealAspectRatio.clicked.connect(self.imageLogDataWidget.self().logic.fitToAspectRatio) - self.fitRealAspectRatio.setFixedWidth(25) - self.fitRealAspectRatio.setToolTip("Adjust the views to their real aspect ratio.") - cornerButtonsLayout.addWidget(self.fitRealAspectRatio) - - # Add view button - self.addViewButton = qt.QPushButton("Add view") - self.addViewButton.setIcon(qt.QIcon(str(Customizer.ADD_ICON_PATH))) - cornerButtonsLayout.addWidget(self.addViewButton) - self.addViewButton.clicked.connect(self.addView) - self.mainTab.setCornerWidget(cornerButtonsFrame) - - # Data tab - dataTab.addTab(self.imageLogDataWidget, "Explorer") - dataTab.addTab(logImportWidget, "Import") - dataTab.addTab(self.imageLogUnwrapImportWidget, "Unwrap import") - dataTab.addTab(self.imageLogExportWidget, "Export") - - # Processing tab - if self.eccentricityWidget: - processingTab.addTab(self.eccentricityWidget, "Eccentricity") - processingTab.addTab(self.spiralFiterWidget, "Spiral Filter") - processingTab.addTab(self.qualityIndicatorWidget, "Quality Indicator") - processingTab.addTab(self.heterogeneityIndexWidget, "Heterogeneity Index") - processingTab.addTab(self.AzimuthShiftToolWidget, "Azimuth Shift Tool") - - # Segmentation tab - smartSegWidget = slicer.modules.imagelogsmartsegmenter.createNewWidgetRepresentation() - self.segmentationTab.addTab(self.imageLogSegmenterWidget, "Manual") - self.segmentationTab.addTab(smartSegWidget, "Smart") - self.segmentationTab.addTab(self.instanceSegmenterWidget, "Instance") - self.segmentationTab.addTab(self.instanceSegmenterEditorWidget, "Instance Editor") - self.segmentationTab.addTab(self.segment_inspector_env, "Inspector") +class ImageLogEnvLogic(LTracePluginLogic, LTraceEnvironmentMixin): + def __init__(self): + super().__init__() + self.__modulesToolbar = None + self.previousSliceAnnotationsProperties = {} + + def setupEnvironment(self): + relatedModules = self.getModuleManager().fetchByCategory([self.category]) + + addAction(relatedModules["ImageLogData"], self.modulesToolbar) + + # Imports and Export + addAction(relatedModules["ImageLogImport"], self.modulesToolbar) + addAction(relatedModules["ImageLogUnwrapImport"], self.modulesToolbar) + addAction(relatedModules["ImageLogExport"], self.modulesToolbar) + + # crop + addAction(relatedModules["ImageLogCropVolume"], self.modulesToolbar) + + # Processing + processingModules = [ + relatedModules["SpiralFilter"], + relatedModules["QualityIndicator"], + relatedModules["HeterogeneityIndex"], + relatedModules["AzimuthShiftTool"], + ] + + if hasattr(slicer.modules, "eccentricity"): + processingModules.insert(0, relatedModules["Eccentricity"]) + + addMenu( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Spiral.svg"), + "Processing", + processingModules, + self.modulesToolbar, + ) # Registration tab - registrationTab.addTab(self.unwrapRegistrationWidget, "Unwrap Registration") + addAction(relatedModules["UnwrapRegistration"], self.modulesToolbar) + + # Modeling + addAction(relatedModules["PermeabilityModeling"], self.modulesToolbar) # Inpaint tab - self.inpaintTab.addTab(self.imageLogInpaint, "Interactive") - self.inpaintTab.addTab(self.coreInpaint, "Automatic") - - self.mainTab.addTab(dataTab, "Data") - self.mainTab.addTab(self.imageLogCropVolume, "Crop") - self.mainTab.addTab(processingTab, "Processing") - self.mainTab.addTab(self.segmentationTab, "Segmentation") - self.mainTab.addTab(registrationTab, "Registration") - - self.mainTab.addTab(self.permeabilityModelingWidget, "Modeling") - self.mainTab.addTab(self.inpaintTab, "Inpaint") - - self.lastAccessedWidget = dataTab.widget(0) - - self.mainTab.tabBarClicked.connect(self.onMainTabClicked) - self.segmentationTab.tabBarClicked.connect(self.onSegmentationTabClicked) - self.inpaintTab.tabBarClicked.connect(self.onInpaintTabClicked) - self.layout.addWidget(self.mainTab) - - def onMainTabClicked(self, index): - if self.lastAccessedWidget != self.mainTab.widget( - index - ): # To avoid calling exit by clicking over the active tab - self.lastAccessedWidgetExit() - self.lastAccessedWidget = self.mainTab.widget(index) - if type(self.lastAccessedWidget) is qt.QTabWidget: - self.lastAccessedWidget = self.lastAccessedWidget.currentWidget() - self.lastAccessedWidgetEnter() - - def onSegmentationTabClicked(self, index): - if self.lastAccessedWidget != self.segmentationTab.widget( - index - ): # To avoid calling exit by clicking over the active tab - self.lastAccessedWidgetExit() - self.lastAccessedWidget = self.segmentationTab.widget(index) - self.lastAccessedWidgetEnter() + inpaintModules = [ + relatedModules[ + "ImageLogInpaint" + ], # ImageLogInpaint is broken until segmentation is working correctly for ImageLog + relatedModules["CoreInpaint"], + ] + addMenu( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "PaintBrush.svg"), + "Inpainting", + inpaintModules, + self.modulesToolbar, + ) - def onImageLogViewOpened(self): - self.segment_inspector_env.self().logic.inspector_process_finished.connect(self._on_external_process_finished) - if self.eccentricityWidget: - self.eccentricityWidget.logic.process_finished.connect(self._on_external_process_finished) + self.setupSegmentation() + self.setupTools() + # self.setupLoaders() + self.setupSliceViewAnnotations() + + self.getModuleManager().setEnvironment(("ImageLog", "ImageLogEnv")) + self.refreshViews() + slicer.mrmlScene.AddObserver(slicer.mrmlScene.EndImportEvent, self.__onEndLoadScene) + + def setupSegmentation(self): + modules = self.getModuleManager().fetchByCategory(("ImageLog",), intersectWith="Segmentation") + + addMenu( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Layers.svg"), + "Segmentation", + [ + modules["ImageLogSegmentEditor"], + modules["ImageLogInstanceSegmenter"], + modules["InstanceSegmenterEditor"], + ], + self.modulesToolbar, + ) - def onImageLogViewClosed(self): - self.segment_inspector_env.self().logic.inspector_process_finished.disconnect( + def onImageLogViewOpened(self): + slicer.util.getModuleLogic("SegmentInspector").inspector_process_finished.connect( self._on_external_process_finished ) - if self.eccentricityWidget: - self.eccentricityWidget.logic.process_finished.disconnect(self._on_external_process_finished) - - def onInpaintTabClicked(self, index): - if self.lastAccessedWidget != self.inpaintTab.widget( - index - ): # To avoid calling exit by clicking over the active tab - self.lastAccessedWidgetExit() - self.lastAccessedWidget = self.inpaintTab.widget(index) - self.lastAccessedWidgetEnter() - - def enter(self) -> None: - super().enter() - self.logic.setupSliceViewAnnotations() - self.lastAccessedWidgetEnter() - self.imageLogDataWidget.self().logic.changeToLayout() - self.imageLogDataWidget.self().logic.loadConfiguration() - self.imageLogDataWidget.self().logic.refreshViews() - - def exit(self): - self.lastAccessedWidgetExit() - self.logic.restoreSliceViewAnnotationsPreviousValues() - - def lastAccessedWidgetEnter(self): try: - self.lastAccessedWidget.enter() + slicer.util.getModuleLogic("Eccentricity").process_finished.connect(self._on_external_process_finished) except: - pass # In case the widget does not have an enter function + pass - def lastAccessedWidgetExit(self): + def onImageLogViewClosed(self): + slicer.util.getModuleLogic("SegmentInspector").inspector_process_finished.disconnect(self.refreshViews) try: - self.lastAccessedWidget.exit() + slicer.util.getModuleLogic("Eccentricity").process_finished.disconnect(self.refreshViews) except: - pass # In case the widget does not have an exit function - - def _on_external_process_finished(self): - self.imageLogDataWidget.self().logic.refreshViews() - - def addView(self): - self.addViewButton.setEnabled(False) - self.imageLogDataWidget.self().addView() - qt.QTimer.singleShot(self.imageLogDataWidget.self().logic.REFRESH_DELAY + 100, self.enableAddView) - - def enableAddView(self): - self.addViewButton.setEnabled(True) - - def fit(self): - self.imageLogDataWidget.self().logic.fit() - - def cleanup(self): - super().cleanup() - self.imageLogDataWidget.self().cleanup() - self.imageLogSegmenterWidget.self().cleanup() - self.segment_inspector_env.self().cleanup() - self.imageLogInpaint.self().cleanup() - self.eccentricityWidget.logic.process_finished.disconnect() + pass - -class ImageLogEnvLogic(LTracePluginLogic): - def __init__(self): - LTracePluginLogic.__init__(self) - self.previousSliceAnnotationsProperties = {} + def refreshViews(self): + slicer.util.getModuleLogic("ImageLogData").refreshViews("ImageLogEnv.setupEnvironment") def setupSliceViewAnnotations(self): sliceAnnotations = slicer.modules.DataProbeInstance.infoWidget.sliceAnnotations @@ -294,3 +143,20 @@ def restoreSliceViewAnnotationsPreviousValues(self): "sliceViewAnnotationsEnabled" ) sliceAnnotations.updateSliceViewFromGUI() + + def __onEndLoadScene(self, *args, **kwargs): + """Handle slicer' end load scene event.""" + + # Without this the Image Log segmenter doesn't correctly restore the selected segmentation node + try: + slicer.util.getModuleWidget("ImageLogSegmentEditor").initializeSavedNodes() + + # Image Log number of views restoration + if slicer.app.layoutManager().layout >= ImageLogConst.DEFAULT_LAYOUT_ID_START_VALUE: + imageLogDataLogic = slicer.util.getModuleLogic("ImageLogData") + imageLogDataLogic.configurationsNode = None + imageLogDataLogic.loadConfiguration() + + except ValueError: + # Widget has been deleted after test + pass diff --git a/src/modules/ImageLogEnv/QualityIndicatorCLI/QualityIndicatorCLI.py b/src/modules/ImageLogEnv/QualityIndicatorCLI/QualityIndicatorCLI.py index cbdaae6..2d43e4a 100644 --- a/src/modules/ImageLogEnv/QualityIndicatorCLI/QualityIndicatorCLI.py +++ b/src/modules/ImageLogEnv/QualityIndicatorCLI/QualityIndicatorCLI.py @@ -6,27 +6,12 @@ from __future__ import print_function import vtk, slicer, slicer.util, mrml - -import logging -import os, sys -import traceback - -from xarray import where - -from ltrace.slicer.helpers import getVolumeNullValue - -import json - -from pathlib import Path import numpy as np -import csv - -from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUE as DEFAULT_NULL_VALUES +from ltrace.slicer.helpers import getVolumeNullValue from ltrace.slicer.cli_utils import readFrom, writeDataInto, progressUpdate from ltrace.algorithms.spiral_filter import filter_spiral - -DEFAULT_NULL_VALUE = list(DEFAULT_NULL_VALUES)[0] +from ltrace.image.optimized_transforms import ANP_880_2022_DEFAULT_NULL_VALUE as DEFAULT_NULL_VALUE def calculateSpiralIndicator(data, windowSizeIndex): diff --git a/src/modules/ImageLogEnv/SpiralFilteringCLI/SpiralFilteringCLI.py b/src/modules/ImageLogEnv/SpiralFilteringCLI/SpiralFilteringCLI.py index fe35db7..9037747 100644 --- a/src/modules/ImageLogEnv/SpiralFilteringCLI/SpiralFilteringCLI.py +++ b/src/modules/ImageLogEnv/SpiralFilteringCLI/SpiralFilteringCLI.py @@ -8,33 +8,11 @@ # These imports should go first to guarantee the transversing of wrapped classes by instantiation time # Refer to github.com/Slicer/Slicer/issues/6484 import vtk, slicer, slicer.util, mrml - -import logging -import os, sys -import traceback - -from xarray import where - -from ltrace.slicer.helpers import getVolumeNullValue - - -import json - -from pathlib import Path import numpy as np -import csv - -from scipy.optimize import minimize -from scipy.stats import kurtosis -from scipy.stats import skew -from scipy.special import comb -from scipy.signal import convolve2d -from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUE as DEFAULT_NULL_VALUES + from ltrace.algorithms.spiral_filter import filter_spiral from ltrace.slicer.cli_utils import progressUpdate, readFrom, writeDataInto - -DEFAULT_NULL_VALUE = list(DEFAULT_NULL_VALUES)[0] - +from ltrace.image.optimized_transforms import ANP_880_2022_DEFAULT_NULL_VALUE as DEFAULT_NULL_VALUE if __name__ == "__main__": import argparse @@ -59,34 +37,35 @@ # Read as slicer node (copy) amplitude = readFrom(args.inputVolume1, mrml.vtkMRMLScalarVolumeNode) - T_depth = amplitude.GetSpacing()[-1] / 1000.0 + TDepth = amplitude.GetSpacing()[-1] / 1000.0 amplitudeNodeNullValue = args.nullable - amplitude_array = slicer.util.arrayFromVolume(amplitude) - if issubclass(amplitude_array.dtype.type, np.integer): - amplitude_array = amplitude_array.astype(np.double) - original_amp_shape = amplitude_array.shape - amplitude_array = amplitude_array.squeeze() + amplitudeArray = slicer.util.arrayFromVolume(amplitude) + if issubclass(amplitudeArray.dtype.type, np.integer): + amplitudeArray = amplitudeArray.astype(np.double) + originalAmpShape = amplitudeArray.shape + amplitudeArray = amplitudeArray.squeeze() - invalid_index_Null_image = amplitude_array == amplitudeNodeNullValue # amp outside range + invalidIndexNullImage = amplitudeArray == amplitudeNodeNullValue # amp outside range - amplitude_array[invalid_index_Null_image] = amplitude_array.mean() - amplitude_array[(amplitude_array < amplitude_array.mean() - 3 * amplitude_array.std())] = ( - amplitude_array.mean() - 3 * amplitude_array.std() + amplitudeArray[invalidIndexNullImage] = amplitudeArray.mean() + amplitudeArray[(amplitudeArray < amplitudeArray.mean() - 3 * amplitudeArray.std())] = ( + amplitudeArray.mean() - 3 * amplitudeArray.std() ) - amplitude_array[(amplitude_array > amplitude_array.mean() + 3 * amplitude_array.std())] = ( - amplitude_array.mean() + 3 * amplitude_array.std() + amplitudeArray[(amplitudeArray > amplitudeArray.mean() + 3 * amplitudeArray.std())] = ( + amplitudeArray.mean() + 3 * amplitudeArray.std() ) progressUpdate(value=0.3) - outputtau = args.wlength_min - output, lixo = filter_spiral( - amplitude_array, T_depth, args.wlength_min, args.wlength_max, args.multip_factor, args.smoothstep_factor + outputTau = args.wlength_min + output, noise = filter_spiral( + amplitudeArray, TDepth, args.wlength_min, args.wlength_max, args.multip_factor, args.smoothstep_factor ) - output[invalid_index_Null_image] = args.nullable - output = np.nan_to_num(output, nan=args.nullable, copy=False).reshape(original_amp_shape) + filteredDiff = (noise**2 / amplitudeArray**2).mean() + output[invalidIndexNullImage] = args.nullable + output = np.nan_to_num(output, nan=args.nullable, copy=False).reshape(originalAmpShape) # Get output node ID outputNodeID_std = args.outputVolume_std @@ -95,6 +74,9 @@ writeDataInto(outputNodeID_std, output, mrml.vtkMRMLScalarVolumeNode, reference=amplitude) + with open(args.returnparameterfile, "w") as f: + f.write(f"filtered_diff={str(filteredDiff)}\n") + progressUpdate(value=1.0) print("Done") diff --git a/src/modules/ImageLogEnv/SpiralFilteringCLI/SpiralFilteringCLI.xml b/src/modules/ImageLogEnv/SpiralFilteringCLI/SpiralFilteringCLI.xml index e3709bd..e020ade 100644 --- a/src/modules/ImageLogEnv/SpiralFilteringCLI/SpiralFilteringCLI.xml +++ b/src/modules/ImageLogEnv/SpiralFilteringCLI/SpiralFilteringCLI.xml @@ -80,7 +80,14 @@ input 0.02 - + + filtered_diff + + + output + + + diff --git a/src/modules/ImageLogExport/ImageLogExport.py b/src/modules/ImageLogExport/ImageLogExport.py index ad4194f..e59b185 100644 --- a/src/modules/ImageLogExport/ImageLogExport.py +++ b/src/modules/ImageLogExport/ImageLogExport.py @@ -1,7 +1,8 @@ import os +import qt from pathlib import Path -from ltrace.slicer_utils import LTracePlugin +from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, slicer_is_in_developer_mode # Checks if closed source code is available @@ -20,7 +21,7 @@ class ImageLogExport(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Image Log Export" - self.parent.categories = ["Image Log"] + self.parent.categories = ["ImageLog", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = ImageLogExport.help() @@ -30,17 +31,40 @@ def readme_path(cls): return str(cls.MODULE_DIR / "README.md") -try: - from ImageLogExportLib.widgets.ImageLogExportClosedSourceWidget import ( - ImageLogExportClosedSourceWidget as PluginWidget, - ) +if slicer_is_in_developer_mode(): + try: + from ImageLogExportLib.widgets.ImageLogExportClosedSourceWidget import ImageLogExportClosedSourceWidget + except ImportError: + ImageLogExportClosedSourceWidget = lambda: None + finally: + from ImageLogExportLib.widgets.ImageLogExportOpenSourceWidget import ImageLogExportOpenSourceWidget -except ImportError: - from ImageLogExportLib.widgets.ImageLogExportOpenSourceWidget import ( - ImageLogExportOpenSourceWidget as PluginWidget, - ) + class ImageLogExportWidget(LTracePluginWidget): + def setup(self): + super().setup() + mainTab = qt.QTabWidget() + self.versions = {} + self.versions["open"] = (ImageLogExportOpenSourceWidget(), "Open") + self.versions["closed"] = (ImageLogExportClosedSourceWidget(), "Closed") + + for _, (widget, name) in self.versions.items(): + mainTab.addTab(widget, name) + + self.layout.addWidget(qt.QLabel("Developer mode is enabled. Two versions of this module are shown:")) + self.layout.addWidget(mainTab) + +else: + try: + from ImageLogExportLib.widgets.ImageLogExportClosedSourceWidget import ( + ImageLogExportClosedSourceWidget as PluginWidget, + ) + except ImportError: + from ImageLogExportLib.widgets.ImageLogExportOpenSourceWidget import ( + ImageLogExportOpenSourceWidget as PluginWidget, + ) -class ImageLogExportWidget(PluginWidget): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + class ImageLogExportWidget(LTracePluginWidget): + def setup(self): + super().setup() + self.layout.addWidget(PluginWidget()) diff --git a/src/modules/ImageLogExport/ImageLogExportLib/ImageLogCSV.py b/src/modules/ImageLogExport/ImageLogExportLib/ImageLogCSV.py index a44a7c5..870fafe 100644 --- a/src/modules/ImageLogExport/ImageLogExportLib/ImageLogCSV.py +++ b/src/modules/ImageLogExport/ImageLogExportLib/ImageLogCSV.py @@ -3,10 +3,10 @@ import numpy as np import pandas as pd import slicer - +import ltrace.slicer_utils as slicer_utils from pathlib import Path from typing import Iterator, Union -from Export import ExportLogic +from ltrace.slicer import export def _units(node: slicer.vtkMRMLNode) -> str: @@ -76,7 +76,7 @@ def exportCSV(node: slicer.vtkMRMLNode, directory: Path, isTechlog: bool = False ) # Write color table - colorTable = ExportLogic().getLabelMapLabelsCSV(labelMap) + colorTable = export.getLabelMapLabelsCSV(labelMap) colorFilename = directory / f"{node.GetName()} Colors.csv" with open(colorFilename, mode="w", newline="") as csvFile: writer = csv.writer(csvFile, delimiter="\n") diff --git a/src/modules/ImageLogExport/ImageLogExportLib/widgets/ImageLogExportOpenSourceWidget.py b/src/modules/ImageLogExport/ImageLogExportLib/widgets/ImageLogExportOpenSourceWidget.py index 173ef7d..36b0738 100644 --- a/src/modules/ImageLogExport/ImageLogExportLib/widgets/ImageLogExportOpenSourceWidget.py +++ b/src/modules/ImageLogExport/ImageLogExportLib/widgets/ImageLogExportOpenSourceWidget.py @@ -1,21 +1,30 @@ import logging -import vtk, qt, ctk, slicer -import json import os import re -from .output_name_dialog import OutputNameDialog from pathlib import Path -from Export import ExportLogic, checkUniqueNames + +import ctk +import qt +import slicer +import vtk from ImageLogExportLib import ImageLogCSV -from ltrace.slicer.helpers import getNodeDataPath -from ltrace.slicer_utils import LTracePluginWidget -from ltrace.utils.recursive_progress import RecursiveProgress + import ltrace.image.las as imglas -from ltrace.slicer.helpers import createTemporaryNode, getNodeDataPath, getSourceVolume, removeTemporaryNodes +from ltrace.slicer import export +from ltrace.slicer.helpers import ( + createTemporaryNode, + getNodeDataPath, + getSourceVolume, + removeTemporaryNodes, + checkUniqueNames, +) from ltrace.slicer.node_attributes import NodeEnvironment, TableType +from ltrace.utils.ProgressBarProc import ProgressBarProc +from ltrace.utils.recursive_progress import RecursiveProgress +from .output_name_dialog import OutputNameDialog -class ImageLogExportOpenSourceWidget(LTracePluginWidget): +class ImageLogExportOpenSourceWidget(qt.QWidget): EXPORT_DIR = "ImageLogExport/exportDir" IGNORE_DIR_STRUCTURE = "ImageLogExport/ignoreDirStructure" FORMAT_MATRIX_CSV = "CSV (matrix format)" @@ -31,16 +40,16 @@ class ImageLogExportOpenSourceWidget(LTracePluginWidget): slicer.vtkMRMLLabelMapVolumeNode, ) - def __init__(self, parent) -> None: + def __init__(self, parent=None) -> None: super().__init__(parent) self.cancel = False self.cliCompleted = False self.auxNode = None self.moduleName = "ImageLogExport" - def setup(self): - LTracePluginWidget.setup(self) + self.setup() + def setup(self): self.subjectHierarchyTreeView = slicer.qMRMLSubjectHierarchyTreeView() self.subjectHierarchyTreeView.setMRMLScene(slicer.app.mrmlScene()) self.subjectHierarchyTreeView.header().setVisible(False) @@ -71,6 +80,7 @@ def setup(self): self.directorySelector.directory = slicer.app.settings().value( self.EXPORT_DIR, Path(slicer.mrmlScene.GetRootDirectory()).parent ) + self.directorySelector.setMaximumWidth(374) self.progressBar = qt.QProgressBar() self.progressBar.setValue(0) @@ -112,9 +122,11 @@ def setup(self): statusHBoxLayout.addWidget(self.currentStatusLabel) formLayout.addRow(statusHBoxLayout) - self.layout.addLayout(formLayout) + layout = qt.QVBoxLayout() + layout.addLayout(formLayout) + layout.addStretch(1) - self.layout.addStretch(1) + self.setLayout(layout) def _startExport(self): self.progressBar.setValue(0) @@ -131,7 +143,7 @@ def _stopExport(self): def _updateNodesAndExportButton(self): items = vtk.vtkIdList() self.subjectHierarchyTreeView.currentItems(items) - self.nodes = ExportLogic().getDataNodes(items, self.EXPORTABLE_TYPES) + self.nodes = export.getDataNodes(items, self.EXPORTABLE_TYPES) self.exportButton.enabled = self.nodes def onExportClicked(self): @@ -152,8 +164,8 @@ def onExportClicked(self): for node in self.nodes: if ( type(node) is slicer.vtkMRMLTableNode - or self.logFormatBox.currentText == ImageLogExportClosedSourceWidget.FORMAT_MATRIX_CSV - or self.logFormatBox.currentText == ImageLogExportClosedSourceWidget.FORMAT_TECHLOG_CSV + or self.logFormatBox.currentText == ImageLogExportOpenSourceWidget.FORMAT_MATRIX_CSV + or self.logFormatBox.currentText == ImageLogExportOpenSourceWidget.FORMAT_TECHLOG_CSV ): nodeToExportList.append(node) elif ( @@ -194,7 +206,7 @@ def progressCallback(progressValue): ) else: if self.tableFormatBox.currentText == ImageLogExportOpenSourceWidget.FORMAT_CSV: - ExportLogic().exportTable(node, outputDir, nodeDir, ExportLogic.TABLE_FORMAT_CSV) + export.exportTable(node, outputDir, nodeDir, export.TABLE_FORMAT_CSV) progress.set_progress(1) else: if self.logFormatBox.currentText == ImageLogExportOpenSourceWidget.FORMAT_TECHLOG_CSV: @@ -211,14 +223,19 @@ def progressCallback(progressValue): self.logFormatBox.currentText == ImageLogExportOpenSourceWidget.FORMAT_LAS or self.logFormatBox.currentText == ImageLogExportOpenSourceWidget.FORMAT_LAS_GEOLOG ) and len(nodeToLASList): - try: - self.startLasExport(nodeToLASList, outputDir, lasProgress) - except RuntimeError as e: - logging.error(e) - self._stopExport() - self.progressBar.setValue(0) - self.currentStatusLabel.text = "Export failed!" - return + + with ProgressBarProc() as progressBarProc: + progressBarProc.nextStep(5, "Starting to export LAS...") + try: + self.startLasExport(nodeToLASList, outputDir, lasProgress) + except RuntimeError as e: + logging.error(e) + self._stopExport() + self.progressBar.setValue(0) + self.currentStatusLabel.text = "Export failed!" + progressBarProc.nextStep(0, "Error exporting LAS data.") + return + progressBarProc.nextStep(100, "LAS export completed.") self._stopExport() self.progressBar.setValue(100) diff --git a/src/modules/ImageLogExport/Resources/Icons/ImageLogExport.png b/src/modules/ImageLogExport/Resources/Icons/ImageLogExport.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/ImageLogExport/Resources/Icons/ImageLogExport.png and /dev/null differ diff --git a/src/modules/ImageLogExport/Resources/Icons/ImageLogExport.svg b/src/modules/ImageLogExport/Resources/Icons/ImageLogExport.svg new file mode 100644 index 0000000..b8b6d68 --- /dev/null +++ b/src/modules/ImageLogExport/Resources/Icons/ImageLogExport.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ImageLogImport/ImageLogImport.py b/src/modules/ImageLogImport/ImageLogImport.py new file mode 100644 index 0000000..be14a49 --- /dev/null +++ b/src/modules/ImageLogImport/ImageLogImport.py @@ -0,0 +1,74 @@ +import pickle +import re +import time +import numpy as np +import qt +import slicer + +from pathlib import Path +from queue import Queue +from threading import Thread +from dlisio import dlis as dlisio +from ltrace.slicer_utils import * +from ltrace.slicer.image_log.import_widget import WellLogImportWidget + +try: + from Test.ImageLogImportTest import ImageLogImportTest +except ImportError: + ImageLogImportTest = None # tests not deployed to final version or closed source + + +class ImageLogImport(LTracePlugin): + + SETTING_KEY = "ImageLogImport" + + def __init__(self, parent): + super().__init__(parent) + self.parent.title = "Image Log Import" + self.parent.categories = ["ImageLog", "Data Importer", "Multiscale"] + self.parent.dependencies = [] + self.parent.contributors = ["LTrace Geophysical Solutions"] + self.parent.helpText = """This module loads volumes from DLIS, LAS, CSV or PDF files into slicer as volumes.""" + self.parent.acknowledgementText = """""" + + +class ImageLogImportWidget(LTracePluginWidget): + def __init__(self, parent) -> None: + super().__init__(parent) + + def setup(self): + super().setup() + + self.widget = WellLogImportWidget() + + frame = qt.QFrame() + self.layout.addWidget(frame) + loadFormLayout = qt.QFormLayout(frame) + loadFormLayout.setLabelAlignment(qt.Qt.AlignRight) + loadFormLayout.setContentsMargins(0, 0, 0, 0) + + loadFormLayout.addRow(self.widget) + + if slicer_is_in_developer_mode(): + self.reload_last_button = qt.QPushButton("Reload last configuration") + self.reload_last_button.clicked.connect(self._on_reload_last_button_clicked) + + self.reload_last_button.setEnabled(self._get_last_load_options() is not None) + self.layout.addWidget(self.reload_last_button) + + def _get_last_load_options(self): + load_options = ImageLogImport.get_setting("last-load") + if load_options is not None: + try: + load_options = pickle.loads(load_options.data()) + except RuntimeError: + pass + + return load_options + + def _set_last_load_options(self, load_options): + ImageLogImport.set_setting("last-load", qt.QByteArray(pickle.dumps(load_options))) + + def _on_reload_last_button_clicked(self): + last_filename, last_selection, well_diameter = self._get_last_load_options() + self._load_curves(last_filename, last_selection, well_diameter) diff --git a/src/modules/ImageLogImport/Resources/Icons/ImageLogImport.svg b/src/modules/ImageLogImport/Resources/Icons/ImageLogImport.svg new file mode 100644 index 0000000..3339f5a --- /dev/null +++ b/src/modules/ImageLogImport/Resources/Icons/ImageLogImport.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ImageLogInpaint/CustomizedWidget/RenameDialog.py b/src/modules/ImageLogInpaint/CustomizedWidget/RenameDialog.py index 8061b87..89da3ea 100644 --- a/src/modules/ImageLogInpaint/CustomizedWidget/RenameDialog.py +++ b/src/modules/ImageLogInpaint/CustomizedWidget/RenameDialog.py @@ -1,5 +1,6 @@ import qt -from Customizer import Customizer + +from ltrace.slicer_utils import getResourcePath class RenameDialog(qt.QDialog): @@ -11,11 +12,11 @@ def __init__(self, parent): self.newPlotWidgetLineEdit = qt.QLineEdit() okButton = qt.QPushButton("OK") - okButton.setIcon(qt.QIcon(str(Customizer.APPLY_ICON_PATH))) + okButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Apply.png")) okButton.setIconSize(qt.QSize(12, 14)) cancelButton = qt.QPushButton("Cancel") - cancelButton.setIcon(qt.QIcon(str(Customizer.CANCEL_ICON_PATH))) + cancelButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Cancel.png")) cancelButton.setIconSize(qt.QSize(12, 14)) okButton.clicked.connect(lambda checked: self.okButtonClicked()) diff --git a/src/modules/ImageLogInpaint/ImageLogInpaint.py b/src/modules/ImageLogInpaint/ImageLogInpaint.py index 7163bbf..03447b1 100644 --- a/src/modules/ImageLogInpaint/ImageLogInpaint.py +++ b/src/modules/ImageLogInpaint/ImageLogInpaint.py @@ -37,7 +37,7 @@ class ImageLogInpaint(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Image Log Inpaint" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "ImageLog", "Multiscale"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = ImageLogInpaint.help() @@ -78,10 +78,10 @@ def __init__(self, parent): def setup(self): LTracePluginWidget.setup(self) - self.customizedSegmentEditorWidget = slicer.modules.customizedsegmenteditor.createNewWidgetRepresentation() - self.customizedSegmentEditorWidget.self().selectParameterNodeByTag(ImageLogInpaint.SETTING_KEY) + self.customizedSegmentEditorWidget = slicer.util.getNewModuleWidget("CustomizedSegmentEditor") + self.customizedSegmentEditorWidget.selectParameterNodeByTag(ImageLogInpaint.SETTING_KEY) - self.segmentEditorWidget = self.customizedSegmentEditorWidget.self().editor + self.segmentEditorWidget = self.customizedSegmentEditorWidget.editor self.segmentEditorWidget.setEffectNameOrder(["Scissors"]) self.segmentEditorWidget.unorderedEffectsVisible = False self.segmentEditorWidget.setAutoShowSourceVolumeNode(False) @@ -191,8 +191,10 @@ def onImportSceneEndConfig(self, caller, event): def onSaveSceneStart(self, caller, event): # Clear the image log view config to not save the current state of the view. # Also prevents errors related to the temporary segmentation node. - if self.logic.imageLogDataLogic: - self.logic.imageLogDataLogic.configurationsNode.SetParameter("ImagLogViews", json.dumps([])) + if self.logic.imageLogDataLogic is not None: + if self.logic.imageLogDataLogic.configurationsNode is not None: + self.logic.imageLogDataLogic.configurationsNode.SetParameter("ImagLogViews", json.dumps([])) + self.logic.imageLogDataLogic.cleanUp() self.clearViews() @@ -536,7 +538,7 @@ def exit(self): def cleanup(self): super().cleanup() - self.customizedSegmentEditorWidget.self().cleanup() + self.customizedSegmentEditorWidget.cleanup() slicer.mrmlScene.RemoveObserver(self.saveConfigObserver) slicer.mrmlScene.RemoveObserver(self.importConfigObserver) @@ -547,12 +549,13 @@ def __init__(self): self.imageLogDataLogic = None self.isSingletonParameterNode = True self.moduleName = ImageLogInpaint.SETTING_KEY + self.setImageLogDataLogic() def getParameterNode(self): return ImageLogInpaintParameterNode(super().getParameterNode()) - def setImageLogDataLogic(self, imageLogDataLogic): + def setImageLogDataLogic(self): """ Allows Image Log Inpaint to perform changes in the Image Log Data views. """ - self.imageLogDataLogic = imageLogDataLogic + self.imageLogDataLogic = slicer.util.getModuleLogic("ImageLogData") diff --git a/src/modules/ImageLogInpaint/Resources/Icons/ImageLogInpaint.png b/src/modules/ImageLogInpaint/Resources/Icons/ImageLogInpaint.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/ImageLogInpaint/Resources/Icons/ImageLogInpaint.png and /dev/null differ diff --git a/src/modules/ImageLogInpaint/Resources/Icons/ImageLogInpaint.svg b/src/modules/ImageLogInpaint/Resources/Icons/ImageLogInpaint.svg new file mode 100644 index 0000000..63e4493 --- /dev/null +++ b/src/modules/ImageLogInpaint/Resources/Icons/ImageLogInpaint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ImageLogInstanceSegmenter/ImageLogInstanceSegmenter.py b/src/modules/ImageLogInstanceSegmenter/ImageLogInstanceSegmenter.py index 3064a2f..1ce533c 100644 --- a/src/modules/ImageLogInstanceSegmenter/ImageLogInstanceSegmenter.py +++ b/src/modules/ImageLogInstanceSegmenter/ImageLogInstanceSegmenter.py @@ -11,6 +11,11 @@ LTracePluginLogic, ) +from Models.SidewallSample import SidewallSampleWidget +from Models.Stops import StopsWidget +from Models.Islands import IslandsWidget +from Models.Snow import SnowWidget + try: from Test.ImageLogInstanceSegmenterTest import ImageLogInstanceSegmenterTest except ImportError: @@ -42,8 +47,8 @@ class ImageLogInstanceSegmenter(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Image Log Instance segmenter" - self.parent.categories = ["Segmentation"] + self.parent.title = "Instance Segmenter" + self.parent.categories = ["Segmentation", "ImageLog", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = ImageLogInstanceSegmenter.help() @@ -58,13 +63,9 @@ def __init__(self, parent): LTracePluginWidget.__init__(self, parent) def setup(self): - from Models.SidewallSample import SidewallSampleWidget - from Models.Stops import StopsWidget - from Models.Islands import IslandsWidget - from Models.Snow import SnowWidget LTracePluginWidget.setup(self) - self.logic = InstanceSegmenterLogic() + self.logic = InstanceSegmenterLogic(self.parent) form = qt.QFormLayout() form.setLabelAlignment(qt.Qt.AlignRight) @@ -110,7 +111,13 @@ def setup(self): def updateFormFromModel(self): self.stackedWidgets.setCurrentIndex(self.modelComboBox.currentIndex) + def cleanup(self) -> None: + LTracePluginWidget.cleanup(self) + for idx in range(self.stackedWidgets.count): + widget = self.stackedWidgets.widget(idx) + widget.cleanup() + class InstanceSegmenterLogic(LTracePluginLogic): - def __init__(self): - LTracePluginLogic.__init__(self) + def __init__(self, parent): + LTracePluginLogic.__init__(self, parent) diff --git a/src/modules/ImageLogInstanceSegmenter/Models/Islands.py b/src/modules/ImageLogInstanceSegmenter/Models/Islands.py index d1e24f1..ba47eb9 100644 --- a/src/modules/ImageLogInstanceSegmenter/Models/Islands.py +++ b/src/modules/ImageLogInstanceSegmenter/Models/Islands.py @@ -9,8 +9,7 @@ import slicer import traceback -from ImageLogInstanceSegmenter import ImageLogInstanceSegmenter -from ltrace.algorithms.measurements import instancesPropertiesDataFrame +from ltrace.algorithms.measurements import instancesPropertiesDataFrame, GENERIC_PROPERTIES from ltrace.slicer.helpers import ( makeNodeTemporary, triggerNodeModified, @@ -24,19 +23,15 @@ from ltrace.slicer.widget.global_progress_bar import LocalProgressBar from ltrace.slicer.widgets import PixelLabel, SingleShotInputWidget from ltrace.slicer_utils import dataFrameToTableNode +from .model import ModelLogic, ModelWidget CLONED_COLUMNS = 40 -class IslandsWidget(qt.QWidget): +class IslandsWidget(ModelWidget): SegmentParameters = namedtuple( "SegmentParameters", - [ - "model", - "segmentationNode", - "sizeMinThreshold", - "outputPrefix", - ], + ["model", "segmentationNode", "sizeMinThreshold", "outputPrefix", "selectedMeasurements"], ) def __init__(self, instanceSegmenterClass, instanceSegmenterWidget, *args, **kwargs): @@ -45,8 +40,11 @@ def __init__(self, instanceSegmenterClass, instanceSegmenterWidget, *args, **kwa self.instanceSegmenterWidget = instanceSegmenterWidget self.setup() + def cleanup(self): + self.instanceSegmenterWidget = None + def getSizeMinThreshold(self): - return ImageLogInstanceSegmenter.get_setting("sizeMinThreshold", default=0.0) + return slicer.app.settings().value(f"ImageLogInstanceSegmenter/sizeMinThreshold", 0.0) def setup(self): self.progressBar = LocalProgressBar() @@ -82,7 +80,7 @@ def setup(self): parametersCollapsibleButton.setText("Parameters") formLayout.addRow(parametersCollapsibleButton) parametersFormLayout = qt.QFormLayout(parametersCollapsibleButton) - parametersFormLayout.setLabelAlignment(qt.Qt.AlignRight) + parametersFormLayout.setLabelAlignment(qt.Qt.AlignLeft) self.sizeMinThreshold = qt.QDoubleSpinBox() self.sizeMinThreshold.setRange(0, 10) @@ -97,7 +95,26 @@ def setup(self): pixel_label.setSizePolicy(qt.QSizePolicy.Maximum, qt.QSizePolicy.Fixed) thresholdBoxLayout.addWidget(pixel_label) parametersFormLayout.addRow("Size minimum threshold (mm):", thresholdBoxLayout) - parametersFormLayout.addRow(" ", None) + + self.measurementsList = qt.QListWidget() + self.measurementsList.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum) + self.measurementsList.objectName = "Islands measurement list widget" + for measurement in GENERIC_PROPERTIES: + item = qt.QListWidgetItem(measurement) + item.setFlags(item.flags() | qt.Qt.ItemIsUserCheckable) + item.setCheckState(qt.Qt.Checked) + self.measurementsList.addItem(item) + + selectAllButton = qt.QPushButton("Select all") + selectAllButton.clicked.connect(lambda: self.changeMeasurementsSelection(True)) + unselectAllButton = qt.QPushButton("Unselect all") + unselectAllButton.clicked.connect(lambda: self.changeMeasurementsSelection(False)) + selectionButtonsLayout = qt.QHBoxLayout() + selectionButtonsLayout.addWidget(selectAllButton) + selectionButtonsLayout.addWidget(unselectAllButton) + + parametersFormLayout.addRow("Select measurements:", self.measurementsList) + parametersFormLayout.addRow("", selectionButtonsLayout) # Output section outputCollapsibleButton = ctk.ctkCollapsibleButton() @@ -176,6 +193,7 @@ def onApplyButtonClicked(self): segmentationNode=node, sizeMinThreshold=float(self.sizeMinThreshold.value), outputPrefix=self.outputPrefixLineEdit.text, + selectedMeasurements=self.getSelectedMeasurements(), ) self.updateButtonsEnablement(running=True) self.logic.apply(segmentParameters) @@ -186,6 +204,18 @@ def onApplyButtonClicked(self): self.updateButtonsEnablement(running=False) return + def getSelectedMeasurements(self): + selectedMeasurements = [] + for item in range(self.measurementsList.count): + itemSelected = self.measurementsList.item(item).checkState() == qt.Qt.Checked + selectedMeasurements.append(1 if itemSelected else 0) + + return selectedMeasurements + + def changeMeasurementsSelection(self, selected): + for item in range(self.measurementsList.count): + self.measurementsList.item(item).setCheckState(qt.Qt.Checked if selected else qt.Qt.Unchecked) + def onCancelButtonClicked(self): self.logic.cancel() @@ -194,27 +224,27 @@ def updateButtonsEnablement(self, running: bool) -> None: self.cancelButton.setEnabled(running) -class IslandsLogic(qt.QObject): - processFinished = qt.Signal() - +class IslandsLogic(ModelLogic): def __init__(self, parent, progressBar) -> None: super().__init__(parent) self.cliNode = None self.progressBar = progressBar self.outputLabelMapNodeId = None self.segmentationNodeId = None + self.selectedMeasurements = [] def apply(self, p): self.model = p.model segmentationNode = p.segmentationNode self.segmentationNodeId = segmentationNode.GetID() self.sizeMinThreshold = p.sizeMinThreshold + self.selectedMeasurements = p.selectedMeasurements self.outputPrefix = p.outputPrefix shNode = slicer.mrmlScene.GetSubjectHierarchyNode() self.itemParent = shNode.GetItemParent(shNode.GetItemByDataNode(segmentationNode)) outputLabelMapNode = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode") - outputLabelMapNode.SetName(p.outputPrefix + "_Instances") + outputLabelMapNode.SetName(slicer.mrmlScene.GenerateUniqueName(p.outputPrefix + "_Instances")) outputLabelMapNode.SetAttribute("InstanceSegmenter", p.model) outputLabelMapNode.SetAttribute(ImageLogDataSelectable.name(), ImageLogDataSelectable.TRUE.value) outputLabelMapNode.HideFromEditorsOn() @@ -288,7 +318,7 @@ def segmentationCLICallback(self, caller, event): self.decloneColumns(outputLabelMapNode, link_border_segments=True) self.decloneColumns(segmentationNode) - propertiesDataFrame = instancesPropertiesDataFrame(outputLabelMapNode) + propertiesDataFrame = instancesPropertiesDataFrame(outputLabelMapNode, self.selectedMeasurements) if len(propertiesDataFrame.index) == 0: slicer.mrmlScene.RemoveNode(outputLabelMapNode) self.outputLabelMapNodeId = None @@ -300,7 +330,9 @@ def segmentationCLICallback(self, caller, event): shNode = slicer.mrmlScene.GetSubjectHierarchyNode() propertiesTableNode = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLTableNode") - propertiesTableNode.SetName(self.outputPrefix + "_Instances_Report") + propertiesTableNode.SetName( + slicer.mrmlScene.GenerateUniqueName(self.outputPrefix + "_Instances_Report") + ) propertiesTableNode.SetAttribute("InstanceSegmenter", self.model) propertiesTableNode.AddNodeReferenceID("InstanceSegmenterLabelMap", outputLabelMapNode.GetID()) shNode.SetItemParent(shNode.GetItemByDataNode(propertiesTableNode), self.itemParent) @@ -315,6 +347,7 @@ def segmentationCLICallback(self, caller, event): "A problem has occurred during the segmentation. Please check your input files." ) self.outputLabelMapNodeId = None + self.selectedMeasurements = [] elif status == "Cancelled": if outputLabelMapNode: @@ -322,12 +355,14 @@ def segmentationCLICallback(self, caller, event): if propertiesTableNode: slicer.mrmlScene.RemoveNode(propertiesTableNode) self.outputLabelMapNodeId = None + self.selectedMeasurements = [] else: if outputLabelMapNode: slicer.mrmlScene.RemoveNode(outputLabelMapNode) if propertiesTableNode: slicer.mrmlScene.RemoveNode(propertiesTableNode) self.outputLabelMapNodeId = None + self.selectedMeasurements = [] if segmentationNode and segmentationNode.GetAttribute(NodeTemporarity.name()) == NodeTemporarity.TRUE.value: slicer.mrmlScene.RemoveNode(segmentationNode) diff --git a/src/modules/ImageLogInstanceSegmenter/Models/SidewallSample.py b/src/modules/ImageLogInstanceSegmenter/Models/SidewallSample.py index 160627a..5629b81 100644 --- a/src/modules/ImageLogInstanceSegmenter/Models/SidewallSample.py +++ b/src/modules/ImageLogInstanceSegmenter/Models/SidewallSample.py @@ -9,7 +9,6 @@ import qt import slicer -from ImageLogInstanceSegmenter import ImageLogInstanceSegmenter from ltrace.file_utils import read_csv from ltrace.slicer.helpers import ( triggerNodeModified, @@ -27,9 +26,10 @@ is_tensorflow_gpu_enabled, dataFrameToTableNode, ) +from .model import ModelLogic, ModelWidget -class SidewallSampleWidget(qt.QWidget): +class SidewallSampleWidget(ModelWidget): SegmentParameters = namedtuple( "SegmentParameters", [ @@ -51,8 +51,11 @@ def __init__(self, instanceSegmenterClass, instanceSegmenterWidget, identifier, self.identifier = identifier self.setup() + def cleanup(self): + self.instanceSegmenterWidget = None + def getDepthThreshold(self): - return ImageLogInstanceSegmenter.get_setting("depthThreshold", default=0.3) + return slicer.app.settings().value(f"ImageLogInstanceSegmenter/depthThreshold", 0.3) def setup(self): self.progressBar = LocalProgressBar() @@ -221,9 +224,7 @@ def updateButtonsEnablement(self, running: bool) -> None: self.cancelButton.setEnabled(running) -class SidewallSampleLogic(qt.QObject): - processFinished = qt.Signal() - +class SidewallSampleLogic(ModelLogic): def __init__(self, parent, progressBar) -> None: super().__init__(parent) self.cliNode = None @@ -240,13 +241,17 @@ def readNominalDepthsCSV(self, csvFilePath): raise MaskRCNNInfo("Invalid nominal depths file.") # stripping newlines from header - nominalDepthsDataFrame.rename(columns=lambda x: re.sub("\n", "", x), inplace=True) + nominalDepthsDataFrame = nominalDepthsDataFrame.rename(columns=lambda x: re.sub("\n", "", x)) # renaming prof - nominalDepthsDataFrame.rename(columns=lambda x: re.sub("[P|p]rof(\s*\(m\))?", "n depth (m)", x), inplace=True) + nominalDepthsDataFrame = nominalDepthsDataFrame.rename( + columns=lambda x: re.sub("[P|p]rof(\s*\(m\))?", "n depth (m)", x) + ) # renaming descida|corrida to desc - nominalDepthsDataFrame.rename(columns=lambda x: re.sub("([D|d]escida)|([C|c]orrida)", "desc", x), inplace=True) + nominalDepthsDataFrame = nominalDepthsDataFrame.rename( + columns=lambda x: re.sub("([D|d]escida)|([C|c]orrida)", "desc", x) + ) # renaming condicao to cond - nominalDepthsDataFrame.rename(columns=lambda x: re.sub("[C|c]ondicao", "cond", x), inplace=True) + nominalDepthsDataFrame = nominalDepthsDataFrame.rename(columns=lambda x: re.sub("[C|c]ondicao", "cond", x)) if "desc" not in nominalDepthsDataFrame: nominalDepthsDataFrame["desc"] = 0 @@ -407,10 +412,10 @@ def aggregateNominalToRealDepthsInformation(self, nominalDepthsDataFrame, proper df_B[~df_B["depth (m)"].isin(df_C["depth (m)"])], ] ).fillna(0) - df_C["cond"].replace(0, "", inplace=True) + df_C = df_C["cond"].replace(0, "") - df_C.drop(columns=["index_A", "index_B", "difference (m)"], inplace=True) - df_C.reset_index(drop=True, inplace=True) + df_C = df_C.drop(columns=["index_A", "index_B", "difference (m)"]) + df_C = df_C.reset_index(drop=True) df_C["desc"] = df_C["desc"].apply(lambda x: str(int(float(x)))) df_C["label"] = df_C["label"].apply(lambda x: int(float(x))) @@ -427,8 +432,8 @@ def aggregateNominalToRealDepthsInformation(self, nominalDepthsDataFrame, proper ["depth (m)", "n depth (m)", "desc", "cond", "diam (cm)", "circularity", "solidity", "azimuth (°)", "label"] ] - df_C.sort_values(by=["depth (m)", "n depth (m)"], inplace=True, ascending=True) - df_C.reset_index(drop=True, inplace=True) + df_C = df_C.sort_values(by=["depth (m)", "n depth (m)"], ascending=True) + df_C = df_C.reset_index(drop=True) return df_C diff --git a/src/modules/ImageLogInstanceSegmenter/Models/Snow.py b/src/modules/ImageLogInstanceSegmenter/Models/Snow.py index 576c8fd..2b82380 100644 --- a/src/modules/ImageLogInstanceSegmenter/Models/Snow.py +++ b/src/modules/ImageLogInstanceSegmenter/Models/Snow.py @@ -9,8 +9,7 @@ import slicer import traceback -from ImageLogInstanceSegmenter import ImageLogInstanceSegmenter -from ltrace.algorithms.measurements import instancesPropertiesDataFrame +from ltrace.algorithms.measurements import instancesPropertiesDataFrame, GENERIC_PROPERTIES from ltrace.slicer.helpers import ( makeNodeTemporary, triggerNodeModified, @@ -25,11 +24,12 @@ from ltrace.slicer.widget.global_progress_bar import LocalProgressBar from ltrace.slicer.widgets import PixelLabel, SingleShotInputWidget from ltrace.slicer_utils import dataFrameToTableNode +from .model import ModelLogic, ModelWidget CLONED_COLUMNS = 40 -class SnowWidget(qt.QWidget): +class SnowWidget(ModelWidget): SegmentParameters = namedtuple( "SegmentParameters", [ @@ -39,6 +39,7 @@ class SnowWidget(qt.QWidget): "minDistanceFilter", "sizeMinThreshold", "outputPrefix", + "selectedMeasurements", ], ) @@ -49,13 +50,13 @@ def __init__(self, instanceSegmenterClass, instanceSegmenterWidget, *args, **kwa self.setup() def getSigma(self): - return ImageLogInstanceSegmenter.get_setting("sigma", default=0.0) + return slicer.app.settings().value(f"ImageLogInstanceSegmenter/sigma", 0.0) def getMinDistanceFilter(self): - return ImageLogInstanceSegmenter.get_setting("minDistanceFilter", default=5) + return slicer.app.settings().value(f"ImageLogInstanceSegmenter/minDistanceFilter", 5) def getSizeMinThreshold(self): - return ImageLogInstanceSegmenter.get_setting("sizeMinThreshold", default=0.0) + return slicer.app.settings().value(f"ImageLogInstanceSegmenter/sizeMinThreshold", 0.0) def setup(self): self.progressBar = LocalProgressBar() @@ -91,7 +92,7 @@ def setup(self): parametersCollapsibleButton.setText("Parameters") formLayout.addRow(parametersCollapsibleButton) parametersFormLayout = qt.QFormLayout(parametersCollapsibleButton) - parametersFormLayout.setLabelAlignment(qt.Qt.AlignRight) + parametersFormLayout.setLabelAlignment(qt.Qt.AlignLeft) self.smooth_factor = qt.QDoubleSpinBox() self.smooth_factor.setRange(0, 10) @@ -136,7 +137,26 @@ def setup(self): pixel_label.setSizePolicy(qt.QSizePolicy.Maximum, qt.QSizePolicy.Fixed) thresholdBoxLayout.addWidget(pixel_label) parametersFormLayout.addRow("Size minimum threshold (mm):", thresholdBoxLayout) - parametersFormLayout.addRow(" ", None) + + self.measurementsList = qt.QListWidget() + self.measurementsList.setSizePolicy(qt.QSizePolicy.Expanding, qt.QAbstractScrollArea.AdjustToContents) + self.measurementsList.objectName = "Snow measurement list widget" + for measurement in GENERIC_PROPERTIES: + item = qt.QListWidgetItem(measurement) + item.setFlags(item.flags() | qt.Qt.ItemIsUserCheckable) + item.setCheckState(qt.Qt.Checked) + self.measurementsList.addItem(item) + + selectAllButton = qt.QPushButton("Select all") + selectAllButton.clicked.connect(lambda: self.changeMeasurementsSelection(True)) + unselectAllButton = qt.QPushButton("Unselect all") + unselectAllButton.clicked.connect(lambda: self.changeMeasurementsSelection(False)) + selectionButtonsLayout = qt.QHBoxLayout() + selectionButtonsLayout.addWidget(selectAllButton) + selectionButtonsLayout.addWidget(unselectAllButton) + + parametersFormLayout.addRow("Select measurements:", self.measurementsList) + parametersFormLayout.addRow("", selectionButtonsLayout) # Output section outputCollapsibleButton = ctk.ctkCollapsibleButton() @@ -242,6 +262,7 @@ def onApplyButtonClicked(self): minDistanceFilter=int(self.minDistanceFilter.value), sizeMinThreshold=float(self.sizeMinThreshold.value), outputPrefix=self.outputPrefixLineEdit.text, + selectedMeasurements=self.getSelectedMeasurements(), ) self.updateButtonsEnablement(running=True) self.logic.apply(segmentParameters) @@ -260,16 +281,27 @@ def updateButtonsEnablement(self, running: bool) -> None: self.applyButton.setEnabled(not running) self.cancelButton.setEnabled(running) + def getSelectedMeasurements(self): + selectedMeasurements = [] + for item in range(self.measurementsList.count): + itemSelected = self.measurementsList.item(item).checkState() == qt.Qt.Checked + selectedMeasurements.append(1 if itemSelected else 0) + + return selectedMeasurements + + def changeMeasurementsSelection(self, selected): + for item in range(self.measurementsList.count): + self.measurementsList.item(item).setCheckState(qt.Qt.Checked if selected else qt.Qt.Unchecked) -class SnowLogic(qt.QObject): - processFinished = qt.Signal() +class SnowLogic(ModelLogic): def __init__(self, parent, progressBar) -> None: super().__init__(parent) self.cliNode = None self.progressBar = progressBar self.outputLabelMapNode = None self.propertiesTableNode = None + self.selectedMeasurements = [] def apply(self, p): self.model = p.model @@ -277,12 +309,13 @@ def apply(self, p): self.sigma = p.sigma self.minDistanceFilter = p.minDistanceFilter self.sizeMinThreshold = p.sizeMinThreshold + self.selectedMeasurements = p.selectedMeasurements self.outputPrefix = p.outputPrefix shNode = slicer.mrmlScene.GetSubjectHierarchyNode() self.itemParent = shNode.GetItemParent(shNode.GetItemByDataNode(self.segmentationNode)) self.outputLabelMapNode = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode") - self.outputLabelMapNode.SetName(p.outputPrefix + "_Instances") + self.outputLabelMapNode.SetName(slicer.mrmlScene.GenerateUniqueName(p.outputPrefix + "_Instances")) self.outputLabelMapNode.SetAttribute("InstanceSegmenter", p.model) self.outputLabelMapNode.SetAttribute(ImageLogDataSelectable.name(), ImageLogDataSelectable.TRUE.value) self.outputLabelMapNode.HideFromEditorsOn() @@ -360,7 +393,9 @@ def segmentationCLICallback(self, caller, event): self.decloneColumns(self.outputLabelMapNode, link_border_segments=True) self.decloneColumns(self.segmentationNode) - propertiesDataFrame = instancesPropertiesDataFrame(self.outputLabelMapNode) + propertiesDataFrame = instancesPropertiesDataFrame( + self.outputLabelMapNode, self.selectedMeasurements + ) if len(propertiesDataFrame.index) == 0: slicer.mrmlScene.RemoveNode(self.outputLabelMapNode) slicer.util.infoDisplay("No instances were detected.") @@ -371,7 +406,9 @@ def segmentationCLICallback(self, caller, event): shNode = slicer.mrmlScene.GetSubjectHierarchyNode() self.propertiesTableNode = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLTableNode") - self.propertiesTableNode.SetName(self.outputPrefix + "_Instances_Report") + self.propertiesTableNode.SetName( + slicer.mrmlScene.GenerateUniqueName(self.outputPrefix + "_Instances_Report") + ) self.propertiesTableNode.SetAttribute("InstanceSegmenter", self.model) self.propertiesTableNode.AddNodeReferenceID( "InstanceSegmenterLabelMap", self.outputLabelMapNode.GetID() @@ -385,13 +422,16 @@ def segmentationCLICallback(self, caller, event): slicer.util.errorDisplay( "A problem has occurred during the segmentation. Please check your input files." ) + self.selectedMeasurements = [] elif status == "Cancelled": slicer.mrmlScene.RemoveNode(self.outputLabelMapNode) slicer.mrmlScene.RemoveNode(self.propertiesTableNode) + self.selectedMeasurements = [] else: slicer.mrmlScene.RemoveNode(self.outputLabelMapNode) slicer.mrmlScene.RemoveNode(self.propertiesTableNode) + self.selectedMeasurements = [] if self.segmentationNode.GetAttribute(NodeTemporarity.name()) == NodeTemporarity.TRUE.value: slicer.mrmlScene.RemoveNode(self.segmentationNode) diff --git a/src/modules/ImageLogInstanceSegmenter/Models/Stops.py b/src/modules/ImageLogInstanceSegmenter/Models/Stops.py index 52138a0..eb3828f 100644 --- a/src/modules/ImageLogInstanceSegmenter/Models/Stops.py +++ b/src/modules/ImageLogInstanceSegmenter/Models/Stops.py @@ -4,9 +4,10 @@ from ltrace.algorithms import stops from ltrace.slicer.ui import hierarchyVolumeInput +from .model import ModelLogic, ModelWidget -class StopsWidget(qt.QWidget): +class StopsWidget(ModelWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setup() @@ -24,8 +25,9 @@ def setup(self): inputFormLayout = qt.QFormLayout(inputCollapsibleButton) inputFormLayout.setLabelAlignment(qt.Qt.AlignRight) - self.ttStopsBox = hierarchyVolumeInput(nodeTypes=["vtkMRMLScalarVolumeNode"]) - self.ttStopsBox.setToolTip("Select the transit time image.") + self.ttStopsBox = hierarchyVolumeInput( + nodeTypes=["vtkMRMLScalarVolumeNode"], tooltip="Select the transit time image.", hasNone=True + ) inputFormLayout.addRow("Transit time image:", self.ttStopsBox) inputFormLayout.addRow(" ", None) diff --git a/src/modules/ImageLogInstanceSegmenter/Models/model.py b/src/modules/ImageLogInstanceSegmenter/Models/model.py new file mode 100644 index 0000000..a113764 --- /dev/null +++ b/src/modules/ImageLogInstanceSegmenter/Models/model.py @@ -0,0 +1,16 @@ +import qt + + +class ModelWidget(qt.QWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def cleanup(self): + pass + + +class ModelLogic(qt.QObject): + processFinished = qt.Signal() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) diff --git a/src/modules/ImageLogInstanceSegmenter/Resources/Icons/ImageLogInstanceSegmenter.png b/src/modules/ImageLogInstanceSegmenter/Resources/Icons/ImageLogInstanceSegmenter.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/ImageLogInstanceSegmenter/Resources/Icons/ImageLogInstanceSegmenter.png and /dev/null differ diff --git a/src/modules/ImageLogInstanceSegmenter/Resources/Icons/ImageLogInstanceSegmenter.svg b/src/modules/ImageLogInstanceSegmenter/Resources/Icons/ImageLogInstanceSegmenter.svg new file mode 100644 index 0000000..fe3744e --- /dev/null +++ b/src/modules/ImageLogInstanceSegmenter/Resources/Icons/ImageLogInstanceSegmenter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ImageLogSegmenter/ImageLogSegmenter.py b/src/modules/ImageLogSegmentEditor/ImageLogSegmentEditor.py similarity index 69% rename from src/modules/ImageLogSegmenter/ImageLogSegmenter.py rename to src/modules/ImageLogSegmentEditor/ImageLogSegmentEditor.py index 1bd76a2..5f284a9 100644 --- a/src/modules/ImageLogSegmenter/ImageLogSegmenter.py +++ b/src/modules/ImageLogSegmentEditor/ImageLogSegmentEditor.py @@ -7,41 +7,45 @@ from ltrace.slicer_utils import * -class ImageLogSegmenter(LTracePlugin): - SETTING_KEY = "ImageLogSegmenter" +class ImageLogSegmentEditor(LTracePlugin): + SETTING_KEY = "ImageLogSegmentEditor" MODULE_DIR = Path(os.path.dirname(os.path.realpath(__file__))) RES_DIR = MODULE_DIR / "Resources" def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Image Log Segmenter" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Manual Segmentation" + self.parent.categories = ["Tools", "Segmentation", "ImageLog", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] - self.parent.helpText = ImageLogSegmenter.help() + self.parent.helpText = ImageLogSegmentEditor.help() @classmethod def readme_path(cls): return str(cls.MODULE_DIR / "README.md") -class ImageLogSegmenterWidget(LTracePluginWidget): +class ImageLogSegmentEditorWidget(LTracePluginWidget): def __init__(self, parent): LTracePluginWidget.__init__(self, parent) + self.customizedSegmentEditorWidget = None + self.segmentEditorWidget = None def setup(self): LTracePluginWidget.setup(self) - self.logic = ImageLogSegmenterLogic() + self.logic = ImageLogSegmentEditorLogic(self.parent) frame = qt.QFrame() self.layout.addWidget(frame) formLayout = qt.QFormLayout(frame) formLayout.setLabelAlignment(qt.Qt.AlignRight) + formLayout.setContentsMargins(0, 0, 0, 0) - self.customizedSegmentEditorWidget = slicer.modules.customizedsegmenteditor.createNewWidgetRepresentation() - self.customizedSegmentEditorWidget.self().selectParameterNodeByTag(ImageLogSegmenter.SETTING_KEY) - self.segmentEditorWidget = self.customizedSegmentEditorWidget.self().editor + self.customizedSegmentEditorWidget = slicer.util.getNewModuleWidget("CustomizedSegmentEditor") + self.segmentEditorWidget = self.customizedSegmentEditorWidget.editor + + self.customizedSegmentEditorWidget.selectParameterNodeByTag(ImageLogSegmentEditor.SETTING_KEY) self.configureEffects() self.segmentEditorWidget.unorderedEffectsVisible = False self.segmentEditorWidget.setAutoShowSourceVolumeNode(False) @@ -56,7 +60,7 @@ def setup(self): self.lastSegUpdate = None def segmentationNodeOrSourceVolumeNodeChanged(self): - if not slicer.util.selectedModule() == "ImageLogEnv": + if not slicer.util.selectedModule() == "ImageLogSegmentEditor": return segmentationNode = self.segmentEditorWidget.segmentationNode() @@ -75,27 +79,28 @@ def segmentationNodeOrSourceVolumeNodeChanged(self): self.logic.imageLogDataLogic.segmentationNodeOrSourceVolumeNodeChanged(segmentationNode, sourceVolumeNode) def onSourceVolumeNodeChanged(self, node): - color_support = node and node.GetImageData() and node.GetImageData().GetNumberOfScalarComponents() == 3 - self.configureEffects(color_support=color_support) + colorSupport = node and node.GetImageData() and node.GetImageData().GetNumberOfScalarComponents() == 3 + self.configureEffects(colorSupport=colorSupport) - def configureEffects(self, color_support=False): + def configureEffects(self, colorSupport=False): effects = [ "Threshold", "Paint", "Draw", "Erase", - "Depth Segmenter", "Level tracing", "Margin", "Smoothing", "Scissors", "Islands", + "Watershed", "Logical operators", "Mask Image", "Multiple Threshold", - "Watershed", + "Depth Segmenter", + "Boundary removal", ] - if color_support: + if colorSupport: effects.append("Color threshold") self.segmentEditorWidget.setEffectNameOrder(effects) self.segmentEditorWidget.unorderedEffectsVisible = False @@ -108,7 +113,11 @@ def enter(self) -> None: segmentationNodeComboBox.setEnabled(True) def initializeSavedNodes(self): - toLoadSegmentation = self.segmentEditorWidget.mrmlSegmentEditorNode().GetSegmentationNode() + segmentEditorNode = self.segmentEditorWidget.mrmlSegmentEditorNode() + if segmentEditorNode is None: + return + + toLoadSegmentation = segmentEditorNode.GetSegmentationNode() self.segmentEditorWidget.mrmlSegmentEditorNode().SetAndObserveSegmentationNode(None) self.segmentEditorWidget.blockSignals(True) self.segmentEditorWidget.setSegmentationNode(toLoadSegmentation) @@ -120,15 +129,18 @@ def exit(self): def cleanup(self): super().cleanup() - self.customizedSegmentEditorWidget.self().cleanup() + self.customizedSegmentEditorWidget.cleanup() + self.logic.imageLogDataLogic = None + del self.logic -class ImageLogSegmenterLogic(LTracePluginLogic): - def __init__(self): - LTracePluginLogic.__init__(self) +class ImageLogSegmentEditorLogic(LTracePluginLogic): + def __init__(self, parent): + LTracePluginLogic.__init__(self, parent) + self.setImageLogDataLogic() - def setImageLogDataLogic(self, imageLogDataLogic): + def setImageLogDataLogic(self): """ Allows Image Log Segmenter to perform changes in the Image Log Data views. """ - self.imageLogDataLogic = imageLogDataLogic + self.imageLogDataLogic = slicer.util.getModuleLogic("ImageLogData") diff --git a/src/modules/ImageLogSegmenter/README.md b/src/modules/ImageLogSegmentEditor/README.md similarity index 100% rename from src/modules/ImageLogSegmenter/README.md rename to src/modules/ImageLogSegmentEditor/README.md diff --git a/src/modules/ImageLogSegmentEditor/Resources/Icons/ImageLogSegmentEditor.svg b/src/modules/ImageLogSegmentEditor/Resources/Icons/ImageLogSegmentEditor.svg new file mode 100644 index 0000000..7ad384b --- /dev/null +++ b/src/modules/ImageLogSegmentEditor/Resources/Icons/ImageLogSegmentEditor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ImageLogSegmenter/Resources/Icons/ImageLogSegmenter.png b/src/modules/ImageLogSegmenter/Resources/Icons/ImageLogSegmenter.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/ImageLogSegmenter/Resources/Icons/ImageLogSegmenter.png and /dev/null differ diff --git a/src/modules/ImageLogUnwrapImport/ImageLogUnwrapImport.py b/src/modules/ImageLogUnwrapImport/ImageLogUnwrapImport.py index 6005ace..29e08a1 100644 --- a/src/modules/ImageLogUnwrapImport/ImageLogUnwrapImport.py +++ b/src/modules/ImageLogUnwrapImport/ImageLogUnwrapImport.py @@ -7,7 +7,6 @@ from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic from pathlib import Path -import DLISImportLib from ltrace.slicer import ui from ImageLogUnwrapImportLib.TomographicUnwrapLoadWidget import TomographicUnwrapLoadWidget @@ -18,8 +17,8 @@ class ImageLogUnwrapImport(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Image Log Import" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Image Log Unwrap" + self.parent.categories = ["Tools", "ImageLog", "Multiscale"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = ImageLogUnwrapImport.help() diff --git a/src/modules/ImageLogUnwrapImport/Resources/Icons/ImageLogUnwrapImport.png b/src/modules/ImageLogUnwrapImport/Resources/Icons/ImageLogUnwrapImport.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/ImageLogUnwrapImport/Resources/Icons/ImageLogUnwrapImport.png and /dev/null differ diff --git a/src/modules/ImageLogUnwrapImport/Resources/Icons/ImageLogUnwrapImport.svg b/src/modules/ImageLogUnwrapImport/Resources/Icons/ImageLogUnwrapImport.svg new file mode 100644 index 0000000..3339f5a --- /dev/null +++ b/src/modules/ImageLogUnwrapImport/Resources/Icons/ImageLogUnwrapImport.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ImageTools/ImageTools.py b/src/modules/ImageTools/ImageTools.py index 478d0af..6c05dee 100644 --- a/src/modules/ImageTools/ImageTools.py +++ b/src/modules/ImageTools/ImageTools.py @@ -2,10 +2,11 @@ from pathlib import Path import slicer.util -from Customizer import Customizer -from ltrace.slicer_utils import * from ImageToolsLib import * +from ltrace.slicer import ui +from ltrace.slicer_utils import * +from ltrace.slicer_utils import getResourcePath class ImageTools(LTracePlugin): @@ -16,11 +17,13 @@ class ImageTools(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Image Tools" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Filters" + self.parent.categories = ["Tools", "Thin Section"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] - self.parent.helpText = ImageTools.help() + self.parent.helpText = ( + f"file:///{(getResourcePath('manual') / 'Modules/Thin_section/ImageTools.html').as_posix()}" + ) @classmethod def readme_path(cls): @@ -93,7 +96,7 @@ def setup(self): self.hideToolWidgets() self.applyButton = qt.QPushButton("Apply") - self.applyButton.setIcon(qt.QIcon(str(Customizer.APPLY_ICON_PATH))) + self.applyButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Apply.png")) self.applyButton.setToolTip( "Apply the current tool changes. These changes can be undone, unless you click Save." ) @@ -101,13 +104,13 @@ def setup(self): self.applyButton.clicked.connect(self.onApplyButtonClicked) self.cancelButton = qt.QPushButton("Cancel") - self.cancelButton.setIcon(qt.QIcon(str(Customizer.CANCEL_ICON_PATH))) + self.cancelButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Cancel.png")) self.cancelButton.setToolTip("Cancel the current tool changes.") self.cancelButton.enabled = False self.cancelButton.clicked.connect(self.onCancelButtonClicked) self.undoButton = qt.QPushButton("Undo") - self.undoButton.setIcon(qt.QIcon(str(Customizer.UNDO_ICON_PATH))) + self.undoButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Undo.png")) self.undoButton.setToolTip( "Undo applied tool changes. The earliest undo is where the image was loaded or saved." ) @@ -115,7 +118,7 @@ def setup(self): self.undoButton.clicked.connect(self.onUndoButtonClicked) self.redoButton = qt.QPushButton("Redo") - self.redoButton.setIcon(qt.QIcon(str(Customizer.REDO_ICON_PATH))) + self.redoButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Redo.png")) self.redoButton.setToolTip("Redo applied tool changes.") self.redoButton.enabled = False self.redoButton.clicked.connect(self.onRedoButtonClicked) @@ -127,22 +130,18 @@ def setup(self): buttonsHBoxLayout.addWidget(self.redoButton) self.formLayout.addRow(buttonsHBoxLayout) - self.saveButton = qt.QPushButton("Save") - self.saveButton.setIcon(qt.QIcon(str(Customizer.SAVE_ICON_PATH))) - self.saveButton.setToolTip("Save the applied tool changes. This action cannot be undone.") - self.saveButton.enabled = False - self.saveButton.clicked.connect(self.onSaveButtonClicked) - - self.resetButton = qt.QPushButton("Reset") - self.resetButton.setIcon(qt.QIcon(str(Customizer.RESET_ICON_PATH))) - self.resetButton.setToolTip("Reset the applied tool changes to the last saved state.") - self.resetButton.enabled = False - self.resetButton.clicked.connect(self.onResetButtonClicked) - - saveResetButtonsHBoxLayout = qt.QHBoxLayout() - saveResetButtonsHBoxLayout.addWidget(self.saveButton) - saveResetButtonsHBoxLayout.addWidget(self.resetButton) - self.formLayout.addRow(saveResetButtonsHBoxLayout) + self.saveResetButtons = ui.ApplyCancelButtons( + onApplyClick=self.onSaveButtonClicked, + onCancelClick=self.onResetButtonClicked, + applyTooltip="Save the applied tool changes. This action cannot be undone.", + cancelTooltip="Reset the applied tool changes to the last saved state.", + applyText="Save", + cancelText="Reset", + enabled=False, + applyObjectName=None, + cancelObjectName=None, + ) + self.layout.addWidget(self.saveResetButtons) self.layout.addStretch() @@ -316,11 +315,9 @@ def configureButtonsState(self): self.undoButton.enabled = numberOfUndoLevels self.redoButton.enabled = numberOfRedoLevels if numberOfUndoLevels > 0 or numberOfRedoLevels > 0: - self.saveButton.enabled = True - self.resetButton.enabled = True + self.saveResetButtons.setEnabled(True) else: - self.saveButton.enabled = False - self.resetButton.enabled = False + self.saveResetButtons.setEnabled(False) self.applyButton.enabled = False self.cancelButton.enabled = False @@ -408,7 +405,7 @@ def exit(self): if slicer.mrmlScene.GetNumberOfUndoLevels() > 0 or slicer.mrmlScene.GetNumberOfRedoLevels() > 0: messageBox = qt.QMessageBox() saveImageAnswer = messageBox.question( - slicer.util.mainWindow(), + slicer.modules.AppContextInstance.mainWindow, "GeoSlicer confirmation", "Save the changes to the image before exiting?", qt.QMessageBox.Yes | qt.QMessageBox.No, # | qt.QMessageBox.Cancel, @@ -426,7 +423,7 @@ def exit(self): def configureInterfaceForThinSectionRegistrationModule(self): self.formLayout.setContentsMargins(0, 0, 0, 0) self.showImageButton.setVisible(False) # To not mess with the comparison view - self.saveButton.setVisible(False) # It's only to help placing the landmarks + self.saveResetButtons.applyBtn.setVisible(False) # It's only to help placing the landmarks class ImageToolsInfo(RuntimeError): diff --git a/src/modules/ImageTools/Resources/Icons/ImageTools.png b/src/modules/ImageTools/Resources/Icons/ImageTools.png deleted file mode 100644 index 8714f49..0000000 Binary files a/src/modules/ImageTools/Resources/Icons/ImageTools.png and /dev/null differ diff --git a/src/modules/ImageTools/Resources/Icons/ImageTools.svg b/src/modules/ImageTools/Resources/Icons/ImageTools.svg new file mode 100644 index 0000000..1211fff --- /dev/null +++ b/src/modules/ImageTools/Resources/Icons/ImageTools.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/InstanceSegmenterCLI/InstanceSegmenterCLILib/model/imagelog.py b/src/modules/InstanceSegmenterCLI/InstanceSegmenterCLILib/model/imagelog.py index 8314efb..50a6b9f 100644 --- a/src/modules/InstanceSegmenterCLI/InstanceSegmenterCLILib/model/imagelog.py +++ b/src/modules/InstanceSegmenterCLI/InstanceSegmenterCLILib/model/imagelog.py @@ -185,9 +185,9 @@ def segment(self, p): labels = propertiesDataFrame["label"].to_list() depths, problematicLabels = instances_depths(labelMapArray, labels, ijkToRASMatrix) problematicIndexes = propertiesDataFrame[propertiesDataFrame["label"].isin(problematicLabels)].index - propertiesDataFrame.drop(problematicIndexes, inplace=True) + propertiesDataFrame = propertiesDataFrame.drop(problematicIndexes) propertiesDataFrame["depth (m)"] = depths - propertiesDataFrame.sort_values(by=["depth (m)"], ascending=True, inplace=True) + propertiesDataFrame = propertiesDataFrame.sort_values(by=["depth (m)"], ascending=True) propertiesDataFrame = propertiesDataFrame[ ["depth (m)", "diam (cm)", "circularity", "solidity", "azimuth (°)", "label"] diff --git a/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditor.py b/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditor.py index 1fe7c7f..fe5c2c9 100644 --- a/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditor.py +++ b/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditor.py @@ -1,15 +1,14 @@ import os from pathlib import Path +import ImageLogInstanceSegmenter import ctk import cv2 import numpy as np import qt import slicer import vtk -from Customizer import Customizer -import ImageLogInstanceSegmenter from InstanceSegmenterEditorLib.widget.FilterableTableWidgets import ( GenericTableWidget, ) @@ -23,7 +22,7 @@ from ltrace.slicer.helpers import highlight_error, reset_style_on_valid_text from ltrace.slicer.node_attributes import ImageLogDataSelectable from ltrace.slicer.ui import hierarchyVolumeInput -from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic +from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic, getResourcePath from ltrace.slicer_utils import dataFrameToTableNode from ltrace.transforms import transformPoints @@ -36,8 +35,8 @@ class InstanceSegmenterEditor(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Image Log Instance Segmenter Editor" - self.parent.categories = ["Segmentation"] + self.parent.title = "Instance Segmenter Editor" + self.parent.categories = ["Segmentation", "ImageLog", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = InstanceSegmenterEditor.help() @@ -104,25 +103,25 @@ def setup(self): editFormLayout = qt.QFormLayout(self.editCollapsibleButton) self.addSegmentButton = qt.QPushButton("Add") - self.addSegmentButton.setIcon(qt.QIcon(str(Customizer.ADD_ICON_PATH))) + self.addSegmentButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Add.png")) self.addSegmentButton.clicked.connect(self.onAddSegmentButtonClicked) self.editSegmentButton = qt.QPushButton("Edit") - self.editSegmentButton.setIcon(qt.QIcon(str(Customizer.EDIT_ICON_PATH))) + self.editSegmentButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Edit.png")) self.editSegmentButton.clicked.connect(self.onEditSegmentButtonClicked) self.applySegmentButton = qt.QPushButton("Apply") - self.applySegmentButton.setIcon(qt.QIcon(str(Customizer.APPLY_ICON_PATH))) + self.applySegmentButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Apply.png")) self.applySegmentButton.setEnabled(False) self.applySegmentButton.clicked.connect(self.onApplySegmentButtonClicked) self.cancelSegmentButton = qt.QPushButton("Cancel") - self.cancelSegmentButton.setIcon(qt.QIcon(str(Customizer.CANCEL_ICON_PATH))) + self.cancelSegmentButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Cancel.png")) self.cancelSegmentButton.setEnabled(False) self.cancelSegmentButton.clicked.connect(self.onCancelSegmentButtonClicked) self.declineSegmentButton = qt.QPushButton("Decline") - self.declineSegmentButton.setIcon(qt.QIcon(str(Customizer.DELETE_ICON_PATH))) + self.declineSegmentButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Delete.png")) self.declineSegmentButton.clicked.connect(self.onDeclineSegmentButtonClicked) editButtonsHBoxLayout = qt.QHBoxLayout() @@ -441,7 +440,9 @@ def apply(self, dataFrame, outputSuffix): updatedLabelMapNode.SetAttribute(ImageLogDataSelectable.name(), ImageLogDataSelectable.TRUE.value) updatedDataFrame = dataFrame - updatedDataFrame.drop(updatedDataFrame[updatedDataFrame.label.isin(self.declinedLabels)].index, inplace=True) + updatedDataFrame = updatedDataFrame.drop( + updatedDataFrame[updatedDataFrame.label.isin(self.declinedLabels)].index + ) updatedTableNode = dataFrameToTableNode(updatedDataFrame) updatedTableNode.SetName(self.tableNode.GetName() + "_" + outputSuffix) updatedTableNode.SetAttribute("InstanceSegmenter", self.tableNode.GetAttribute("InstanceSegmenter")) @@ -592,7 +593,7 @@ def circle(self, array, point, value): array[k, 0, i] = subArray def setMouseInteractionToViewTransform(self): - mouseModeToolBar = slicer.util.findChild(slicer.util.mainWindow(), "MouseModeToolBar") + mouseModeToolBar = slicer.util.findChild(slicer.modules.AppContextInstance.mainWindow, "MouseModeToolBar") mouseModeToolBar.interactionNode().SetCurrentInteractionMode(slicer.vtkMRMLInteractionNode.ViewTransform) def onMouseButtonClickedOrHeld(self, *args): diff --git a/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditorLib/widget/Base.py b/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditorLib/widget/Base.py index ec6d7a4..d6522c6 100644 --- a/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditorLib/widget/Base.py +++ b/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditorLib/widget/Base.py @@ -1,5 +1,6 @@ import qt -from Customizer import Customizer + +from ltrace.slicer_utils import getResourcePath class TableWidget(qt.QWidget): @@ -26,15 +27,15 @@ def setup(self): self.layout().addRow(self.tableView) self.previousButton = qt.QPushButton("Previous") - self.previousButton.setIcon(qt.QIcon(str(Customizer.UNDO_ICON_PATH))) + self.previousButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Undo.png")) self.previousButton.clicked.connect(self.onPreviousButtonClicked) self.nextButton = qt.QPushButton("Next") - self.nextButton.setIcon(qt.QIcon(str(Customizer.REDO_ICON_PATH))) + self.nextButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Redo.png")) self.nextButton.clicked.connect(self.onNextButtonClicked) self.resetFiltersButton = qt.QPushButton("Reset filters") - self.resetFiltersButton.setIcon(qt.QIcon(str(Customizer.RESET_ICON_PATH))) + self.resetFiltersButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Reset.png")) self.resetFiltersButton.clicked.connect(lambda: self.setAllRangeWidgetValues(resetValues=True)) buttonsLayout = qt.QHBoxLayout() diff --git a/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditorLib/widget/FilterableTableWidgets.py b/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditorLib/widget/FilterableTableWidgets.py index df6bbf2..804c0b6 100644 --- a/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditorLib/widget/FilterableTableWidgets.py +++ b/src/modules/InstanceSegmenterEditor/InstanceSegmenterEditorLib/widget/FilterableTableWidgets.py @@ -121,8 +121,8 @@ def calculateConflictedLabels(self): def removeRows(self, row, count, parent=None): self.beginRemoveRows(parent, row, row + count - 1) - self._data.drop(index=[i for i in range(row, row + count)], inplace=True) - self._data.reset_index(drop=True, inplace=True) + self._data = self._data.drop(index=[i for i in range(row, row + count)]) + self._data = self._data.reset_index(drop=True) self.calculateConflictedLabels() self.endRemoveRows() return True @@ -155,8 +155,8 @@ def getLabelByRow(self, row): return int(label) def sort(self, column, descending): - self._data.sort_values(by=self._data.columns[column], inplace=True, ascending=not descending) - self._data.reset_index(drop=True, inplace=True) + self._data = self._data.sort_values(by=self._data.columns[column], ascending=not descending) + self._data = self._data.reset_index(drop=True) self.sortingColumn = [column, descending] self.dataChanged.emit(qt.QModelIndex(), qt.QModelIndex()) @@ -164,8 +164,8 @@ def sortDefault(self): self.sort(self.sortingColumn[0], self.sortingColumn[1]) def sortByMultipleColumns(self, columnIds, ascending=True): - self._data.sort_values(by=columnIds, inplace=True, ascending=ascending) - self._data.reset_index(drop=True, inplace=True) + self._data = self._data.sort_values(by=columnIds, ascending=ascending) + self._data = self._data.reset_index(drop=True) self.dataChanged.emit(qt.QModelIndex(), qt.QModelIndex()) def setFilteredLabels(self, filteredLabels): diff --git a/src/modules/InstanceSegmenterEditor/Resources/Icons/InstanceSegmenterEditor.png b/src/modules/InstanceSegmenterEditor/Resources/Icons/InstanceSegmenterEditor.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/InstanceSegmenterEditor/Resources/Icons/InstanceSegmenterEditor.png and /dev/null differ diff --git a/src/modules/InstanceSegmenterEditor/Resources/Icons/InstanceSegmenterEditor.svg b/src/modules/InstanceSegmenterEditor/Resources/Icons/InstanceSegmenterEditor.svg new file mode 100644 index 0000000..846797e --- /dev/null +++ b/src/modules/InstanceSegmenterEditor/Resources/Icons/InstanceSegmenterEditor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/JobMonitor/JobMonitor.py b/src/modules/JobMonitor/JobMonitor.py index d4c35ff..6fd3959 100644 --- a/src/modules/JobMonitor/JobMonitor.py +++ b/src/modules/JobMonitor/JobMonitor.py @@ -1,55 +1,22 @@ -from collections import OrderedDict -from dataclasses import dataclass import datetime -from functools import partial import json import logging import os +from collections import OrderedDict +from dataclasses import dataclass +from functools import partial from pathlib import Path -from queue import Queue -from typing import Callable, List -from uuid import uuid4 - -import ctk - import qt import slicer -import vtk - -from ltrace.slicer.widget.elided_label import ElidedLabel -from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic -from ltrace.remote.targets import TargetManager, Host from ltrace.remote.connections import JobExecutor from ltrace.remote.jobs import JobManager - +from ltrace.remote.targets import Host from ltrace.slicer import ui - - -class EventHandler: - def __call__(self, caller, event, *args, **kwargs): - return - - -# class TaskExecutor: - -# def __init__(self, account_callback: Callable, login_callback: Callable) -> None: -# self.request_account = account_callback -# self.request_login = login_callback - -# def run(self, task: Callable, *args, **kwargs): -# host: TargetHost = self.request_account() -# if host is None: -# raise Exception("No host selected") # TODO handle with custom exceptions - -# connection = self.connections.find(host) -# if connection is None: -# connection = connect(host, self.request_login) - - -# RemoteService.instance.cli(ssh.Client).run() +from ltrace.slicer.widget.elided_label import ElidedLabel +from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic def prettydt(dtt: datetime): @@ -270,7 +237,7 @@ class JobMonitor(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Job Monitor" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = JobMonitor.help() @@ -387,7 +354,7 @@ def __init__(self, job: JobExecutor, parent=None) -> None: @staticmethod def show(): - d = DetailsDialog(parent=slicer.util.mainWindow()) + d = DetailsDialog(parent=slicer.modules.AppContextInstance.mainWindow) return d.exec_() == 1 @@ -490,7 +457,7 @@ def removeJob(self, item: qt.QListWidgetItem, job: JobExecutor): self.logic.cancelJob(job) def showJobDetails(self, job: JobExecutor): - d = DetailsDialog(job, parent=slicer.util.mainWindow()) + d = DetailsDialog(job, parent=slicer.modules.AppContextInstance.mainWindow) self.logic.currentDetail = (job.uid, d.view.update) d.exec_() self.logic.currentDetail = None diff --git a/src/modules/LabelMapEditor/LabelMapEditor.py b/src/modules/LabelMapEditor/LabelMapEditor.py index 97ac592..d91d2c8 100644 --- a/src/modules/LabelMapEditor/LabelMapEditor.py +++ b/src/modules/LabelMapEditor/LabelMapEditor.py @@ -64,7 +64,7 @@ class LabelMapEditor(LTracePlugin): def __init__(self, parent): super().__init__(parent) self.parent.title = "Label Map Editor" - self.parent.categories = ["Segmentation"] + self.parent.categories = ["Segmentation", "MicroCT", "Thin Section", "ImageLog", "Core"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = LabelMapEditor.help() @@ -199,25 +199,23 @@ def setup(self): self.layout.addWidget(output_collapsible) - # Save button - self.save_button = qt.QPushButton("Apply") - self.save_button.setObjectName("saveLabelMapButton") - self.save_button.setFixedHeight(40) - self.save_button.clicked.connect(self.on_save_button_clicked) - - self.cancel_button = qt.QPushButton("Cancel") - self.cancel_button.setFixedHeight(40) - self.cancel_button.clicked.connect(self.on_cancel_saving_button_clicked) - - save_buttons_layout = qt.QHBoxLayout() - save_buttons_layout.addWidget(self.save_button) - save_buttons_layout.addWidget(self.cancel_button) + self.applyCancelButtons = ui.ApplyCancelButtons( + onApplyClick=self.on_save_button_clicked, + onCancelClick=self.on_cancel_saving_button_clicked, + applyTooltip="Save", + cancelTooltip="Cancel", + applyText="Save", + cancelText="Cancel", + enabled=True, + applyObjectName="saveLabelMapButton", + cancelObjectName=None, + ) # Progress bar self.progress_bar = LocalProgressBar() self.progress_update = lambda value: None - self.layout.addLayout(save_buttons_layout) + self.layout.addWidget(self.applyCancelButtons) self.layout.addWidget(self.progress_bar) # Add vertical spacer @@ -336,7 +334,7 @@ def on_save_button_clicked(self): checkPercent=False, ) # Avoid multiple clicks at the Save button - self.save_button.setEnabled(False) + self.applyCancelButtons.applyBtn.setEnabled(False) self.progress_bar.setCommandLineModuleNode(self.cliNode) self.cliNode.AddObserver("ModifiedEvent", lambda c, ev, p=resultInfo: self.eventHandler(c, ev, p)) @@ -845,7 +843,7 @@ def eventHandler(self, caller, event, params): if not caller.IsBusy(): removeTemporaryNodes() self.progress_update(1.0) - self.save_button.setEnabled(True) + self.applyCancelButtons.applyBtn.setEnabled(True) def _createRunStats(self, inputNode, labels, params, roiNode=None, prefix="", where=None): """ diff --git a/src/modules/LabelMapEditor/Resources/Icons/LabelMapEditor.png b/src/modules/LabelMapEditor/Resources/Icons/LabelMapEditor.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/LabelMapEditor/Resources/Icons/LabelMapEditor.png and /dev/null differ diff --git a/src/modules/LabelMapEditor/Resources/Icons/LabelMapEditor.svg b/src/modules/LabelMapEditor/Resources/Icons/LabelMapEditor.svg new file mode 100644 index 0000000..191b8a2 --- /dev/null +++ b/src/modules/LabelMapEditor/Resources/Icons/LabelMapEditor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MaskVolumeEffect/MaskVolumeEffectLib/SegmentEditorEffect.py b/src/modules/MaskVolumeEffect/MaskVolumeEffectLib/SegmentEditorEffect.py index 834c2e7..ea8497a 100644 --- a/src/modules/MaskVolumeEffect/MaskVolumeEffectLib/SegmentEditorEffect.py +++ b/src/modules/MaskVolumeEffect/MaskVolumeEffectLib/SegmentEditorEffect.py @@ -186,10 +186,10 @@ def setupOptionsFrame(self): def createCursor(self, widget): # Turn off effect-specific cursor for this effect - return slicer.util.mainWindow().cursor + return slicer.modules.AppContextInstance.mainWindow.cursor def setEnvironment(self, tag): - self.shouldHideInputOutput = "ImageLogSegmenter" in tag + self.shouldHideInputOutput = "ImageLogSegmentEditor" in tag self.inputVisibilityButton.setVisible(not self.shouldHideInputOutput) self.outputVisibilityButton.setVisible(not self.shouldHideInputOutput) diff --git a/src/modules/MicroCTCupsAnalysis/MicroCTCupsAnalysis.py b/src/modules/MicroCTCupsAnalysis/MicroCTCupsAnalysis.py index 1636246..da60ced 100644 --- a/src/modules/MicroCTCupsAnalysis/MicroCTCupsAnalysis.py +++ b/src/modules/MicroCTCupsAnalysis/MicroCTCupsAnalysis.py @@ -137,7 +137,7 @@ class MicroCTCupsAnalysis(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Micro CT Cups Analysis (advanced)" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = MicroCTCupsAnalysis.help() diff --git a/src/modules/MicroCTEnv/MicroCTEnv.py b/src/modules/MicroCTEnv/MicroCTEnv.py index f0fcddc..c08b7ea 100644 --- a/src/modules/MicroCTEnv/MicroCTEnv.py +++ b/src/modules/MicroCTEnv/MicroCTEnv.py @@ -1,124 +1,11 @@ import os from pathlib import Path -import qt import slicer -from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget - -from CTAutoRegistration import CTAutoRegistration -from CustomizedCropVolume import CustomizedCropVolume -from CustomizedData import CustomizedData -from FilteringTools import FilteringTools -from MicroCTLoader import MicroCTLoader -from MicroCTTransforms import MicroCTTransforms -from RawLoader import RawLoader -from SegmentationEnv import SegmentationEnv -from CustomResampleScalarVolume import CustomResampleScalarVolume -from PoreNetworkEnv import PoreNetworkEnv -from ShadingCorrection import ShadingCorrection -from MultiScale import MultiScale -from MultiscalePostProcessing import MultiscalePostProcessing - -# Checks if closed source code is available -try: - from MicrotomRemote import MicrotomRemote -except ImportError: - MicrotomRemote = None - - -class FlowTabWidget(qt.QFrame): - def __init__(self, *args): - super().__init__(*args) - layout = qt.QVBoxLayout(self) - layout.setSpacing(10) - select_module = slicer.util.mainWindow().moduleSelector().selectModule - - helpLabel = qt.QLabel( - "Flows are a way to follow a sequence of work steps in a simpler, streamlined way." - ) - segGroupBox = qt.QGroupBox() - segLayout = qt.QVBoxLayout() - segGroupBox.setLayout(segLayout) - segLabel = qt.QLabel( - """ -

Modelling Flow

-Compute a porosity map and statistics of a MicroCT volume. - -

Flow inputs

- - -

Steps in flow

- - -

Flow outputs

- -""" - ) - segLabel.setWordWrap(True) - segButton = qt.QPushButton() - segButton.setFixedHeight(40) - segButton.setText("Start Modelling Flow") - segButton.clicked.connect(lambda: select_module("StreamlinedModelling")) - segLayout.addWidget(segLabel) - segLayout.addWidget(segButton) - - modelingGroupBox = qt.QGroupBox() - modelingLayout = qt.QVBoxLayout() - modelingGroupBox.setLayout(modelingLayout) - modelingLabel = qt.QLabel( - """ -

Virtual Segmentation Flow

-Segment without loading an entire image into memory. Ideal for big images. - -

Flow inputs

- -Note: go to the 'Big Image' module to load a virtual image and its sample. - -

Steps in flow

- - -

Flow outputs

- -""" - ) - modelingLabel.setWordWrap(True) - modelingButton = qt.QPushButton() - modelingButton.setFixedHeight(40) - modelingButton.setText("Start Virtual Segmentation Flow") - modelingButton.clicked.connect(lambda: select_module("StreamlinedSegmentation")) - modelingLayout.addWidget(modelingLabel) - modelingLayout.addWidget(modelingButton) - - layout.addWidget(helpLabel) - layout.addWidget(segGroupBox) - layout.addWidget(modelingGroupBox) - layout.addStretch(1) - def enter(self): - pass - - def exit(self): - pass +from ltrace.slicer.helpers import svgToQIcon +from ltrace.slicer.widget.custom_toolbar_buttons import addAction, addMenu +from ltrace.slicer_utils import LTracePlugin, LTracePluginLogic, getResourcePath, LTraceEnvironmentMixin class MicroCTEnv(LTracePlugin): @@ -129,97 +16,113 @@ class MicroCTEnv(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Micro CT Environment" - self.parent.categories = ["Environments"] + self.parent.categories = ["Environment", "MicroCT"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] - self.parent.helpText = ( - MicroCTEnv.help() - + CustomizedData.help() - + MicroCTLoader.help() - + RawLoader.help() - + CustomizedCropVolume.help() - + FilteringTools.help() - + ShadingCorrection.help() - + MicroCTTransforms.help() - + CTAutoRegistration.help() - + SegmentationEnv.help() - + ((MicrotomRemote.help() + PoreNetworkEnv.help()) if MicrotomRemote else "" + PoreNetworkEnv.help()) - + MultiScale.help() - + MultiscalePostProcessing.help() - ) + self.parent.helpText = "" + self.environment = MicroCTEnvLogic() @classmethod def readme_path(cls): return str(cls.MODULE_DIR / "README.md") -class MicroCTEnvWidget(LTracePluginWidget): - def setup(self): - LTracePluginWidget.setup(self) - self.mainTab = qt.QTabWidget() - - dataTab = qt.QTabWidget() - dataTab.addTab(slicer.modules.customizeddata.createNewWidgetRepresentation(), "Explorer") - dataTab.addTab(slicer.modules.microctloader.createNewWidgetRepresentation(), "Import") - dataTab.addTab(slicer.modules.rawloader.createNewWidgetRepresentation(), "RAW import") - dataTab.addTab(slicer.modules.microctexport.createNewWidgetRepresentation(), "Export") - dataTab.addTab(FlowTabWidget(), "Flows") - - self.mainTab.addTab(dataTab, "Data") - self.mainTab.addTab(slicer.modules.customizedcropvolume.createNewWidgetRepresentation(), "Crop") - self.mainTab.addTab(slicer.modules.filteringtools.createNewWidgetRepresentation(), "Filtering Tools") - - # Registration sub tab - self.transformTab = qt.QTabWidget() - self.transformTab.addTab( - slicer.modules.customresamplescalarvolume.createNewWidgetRepresentation(), "Resampling" - ) - self.transformTab.addTab(slicer.modules.microcttransforms.createNewWidgetRepresentation(), "Manual Register") - self.transformTab.addTab(slicer.modules.ctautoregistration.createNewWidgetRepresentation(), "Auto Register") - self.transformTab.tabBarClicked.connect(self.onTransformTabClicked) - self.mainTab.addTab(self.transformTab, "Transforms") - - # Segmentation sub tab - self.segmentationEnv = slicer.modules.microctsegmentationenv.createNewWidgetRepresentation() - self.segmentationEnv.self().segmentEditorWidget.self().selectParameterNodeByTag(MicroCTEnv.SETTING_KEY) - self.mainTab.addTab(self.segmentationEnv, "Segmentation") - - # Simulation sub tab - self.simulationTab = qt.QTabWidget() - if MicrotomRemote: - self.simulationTab.addTab(slicer.modules.microtomremote.createNewWidgetRepresentation(), "Microtom") - self.simulationTab.addTab(slicer.modules.porenetworkenv.createNewWidgetRepresentation(), "Pore Network") - self.mainTab.addTab(self.simulationTab, "Simulation") - - # Multiscale sub tab - self.multiscaleTab = qt.QTabWidget() - self.multiscaleTab.addTab(slicer.modules.multiscale.createNewWidgetRepresentation(), "Image generation") - self.multiscaleTab.addTab( - slicer.modules.multiscalepostprocessing.createNewWidgetRepresentation(), - "Post-processing", +class MicroCTEnvLogic(LTracePluginLogic, LTraceEnvironmentMixin): + def __init__(self): + super().__init__() + self.__modulesToolbar = None + self.__customSegmentEditor = None + + def setupEnvironment(self): + relatedModules = self.getModuleManager().fetchByCategory([self.category]) + + modules = [ + "CustomizedData", + "MicroCTLoader", + "MicroCTExport", + "CustomizedCropVolume", + "FilteringTools", + "CustomResampleScalarVolume", + self.setupSegmentation, + "MicrotomRemote", + "SegmentationModelling", + "StreamlinedModelling", + ( + "Register", + [ + "MicroCTTransforms", + "CTAutoRegistration", + ], + ), + ( + "Pore Network", + [ + "PoreNetworkCompare", + "PoreNetworkExtractor", + "PoreNetworkKrelEda", + "PoreNetworkProduction", + "PoreNetworkSimulation", + ], + ), + ( + "Multi-Scale", + [ + "MultiScale", + "MultiscalePostProcessing", + ], + ), + ( + "BigImage", + [ + "BigImage", + "PolynomialShadingCorrectionBigImage", + "BoundaryRemovalBigImage", + "ExpandSegmentsBigImage", + "MultipleThresholdBigImage", + ], + ), + ] + + for module in modules: + if isinstance(module, str): + addAction(relatedModules[module], self.modulesToolbar) + elif isinstance(module, tuple): + name, modules = module + addMenu( + svgToQIcon(getResourcePath("Icons") / "IconSet-svg" / f"{name}.svg"), + name, + [relatedModules[m] for m in modules], + self.modulesToolbar, + ) + elif callable(module): + module() + + self.setupTools() + self.setupLoaders() + + self.getModuleManager().setEnvironment(("Volumes", "MicroCTEnv")) + + def setupSegmentation(self): + modules = self.getModuleManager().fetchByCategory(("Thin Section",), intersectWith="Segmentation") + + addMenu( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Layers.svg"), + "Segmentation", + [ + modules["CustomizedSegmentEditor"], + modules["Segmenter"], + modules["SegmentInspector"], + ], + self.modulesToolbar, ) - self.mainTab.addTab(self.multiscaleTab, "Multiscale") - self.lastAccessedWidget = dataTab.widget(0) + segmentEditor = slicer.util.getModuleWidget("CustomizedSegmentEditor") + segmentEditor.configureEffects() - self.mainTab.tabBarClicked.connect(self.onMainTabClicked) - self.layout.addWidget(self.mainTab) - - def onMainTabClicked(self, index): - self.lastAccessedWidget.exit() - self.lastAccessedWidget = self.mainTab.widget(index) - if type(self.lastAccessedWidget) is qt.QTabWidget: - self.lastAccessedWidget = self.lastAccessedWidget.currentWidget() - self.lastAccessedWidget.enter() - - def onTransformTabClicked(self, index): - self.lastAccessedWidget.exit() - self.lastAccessedWidget = self.transformTab.widget(index) - self.lastAccessedWidget.enter() + def segmentEditor(self): + return slicer.util.getModuleWidget("CustomizedSegmentEditor") def enter(self) -> None: - super().enter() - self.lastAccessedWidget.enter() - - def exit(self): - self.lastAccessedWidget.exit() + layoutNode = slicer.app.layoutManager().layoutLogic().GetLayoutNode() + if layoutNode.GetViewArrangement() != slicer.vtkMRMLLayoutNode.SlicerLayoutFourUpView: + layoutNode.SetViewArrangement(slicer.vtkMRMLLayoutNode.SlicerLayoutFourUpView) diff --git a/src/modules/MicroCTExport/MicroCTExport.py b/src/modules/MicroCTExport/MicroCTExport.py index 9c4c021..458c741 100644 --- a/src/modules/MicroCTExport/MicroCTExport.py +++ b/src/modules/MicroCTExport/MicroCTExport.py @@ -6,12 +6,12 @@ import numpy as np from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic from ltrace.slicer.ui import hierarchyVolumeInput -from ltrace.slicer import helpers +from ltrace.slicer import helpers, export, netcdf from ltrace.slicer.widget.help_button import HelpButton +from ltrace.utils.callback import Callback from collections import OrderedDict from pathlib import Path -from Export import ExportLogic, Callback -from NetCDFExport import exportNetcdf + # Checks if closed source code is available try: @@ -33,7 +33,7 @@ class MicroCTExport(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Micro CT Export" - self.parent.categories = ["Micro CT"] + self.parent.categories = ["MicroCT", "Multiscale"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = MicroCTExport.help() @@ -89,6 +89,7 @@ class MicroCTExportWidget(LTracePluginWidget): def __init__(self, parent): LTracePluginWidget.__init__(self, parent) + self.logic = MicroCTExportLogic() def _addRow(self, name, widget): @@ -106,7 +107,6 @@ def _addSpace(self): def setup(self): LTracePluginWidget.setup(self) - self.gridLayout = qt.QGridLayout() self.layout.addLayout(self.gridLayout) @@ -164,6 +164,7 @@ def setup(self): self._addRow(None, self.warningLabel) self.exportDirButton = ctk.ctkDirectoryButton() + self.exportDirButton.setMaximumWidth(374) self.exportDirButton.directoryChanged.connect(self._updateInterface) self._addRow("Export directory", self.exportDirButton) self._addSpace() @@ -307,7 +308,14 @@ def __init__(self): def export(self, imageFormat, imageDict, tableNode, outputDir, imageName, callback): if tableNode: callback.on_update("Exporting table…", 0) - self._exportTable(tableNode, outputDir, "CSV") + + tableBrowserNode = slicer.modules.sequences.logic().GetFirstBrowserNodeForProxyNode(tableNode) + if tableBrowserNode: + tableSequenceNode = tableBrowserNode.GetSequenceNode(tableNode) + for sequence_index in range(tableSequenceNode.GetNumberOfDataNodes()): + self._exportTable(tableSequenceNode.GetNthDataNode(sequence_index), outputDir, "CSV") + else: + self._exportTable(tableNode, outputDir, "CSV") imageDict = self._convertToNodes(imageDict) @@ -334,12 +342,26 @@ def export(self, imageFormat, imageDict, tableNode, outputDir, imageName, callba names.append(self.TYPE_TO_NC_NAME[type_]) dtypes.append(self.TYPE_TO_DTYPE[type_]) - exportNetcdf(path, images, nodeNames=names, nodeDtypes=dtypes, callback=callback) + netcdf.exportNetcdf(path, images, nodeNames=names, nodeDtypes=dtypes, callback=callback) else: for i, (type_, image) in enumerate(imageDict.items()): callback.on_update(f"Exporting {type_} image…", i * 100 / len(imageDict)) dtype = self.TYPE_TO_DTYPE[type_] - self._exportImage(image, imageName, type_, dtype, outputDir, imageFormat) + + browserNode = slicer.modules.sequences.logic().GetFirstBrowserNodeForProxyNode(image) + if browserNode: + sequenceNode = browserNode.GetSequenceNode(image) + for sequence_index in range(sequenceNode.GetNumberOfDataNodes()): + self._exportImage( + sequenceNode.GetNthDataNode(sequence_index), + f"{imageName}_{sequence_index}", + type_, + dtype, + outputDir, + imageFormat, + ) + else: + self._exportImage(image, imageName, type_, dtype, outputDir, imageFormat) helpers.removeTemporaryNodes() callback.on_update("Export complete", 100) @@ -456,7 +478,6 @@ def _convertToNodes(imageDict): return nodeDict def _exportImage(self, image, imageName, imageType, imageDtype, outputDir, imageFormat): - logic = ExportLogic() nodeDir = Path(outputDir) if imageType == "BASINS": @@ -471,20 +492,19 @@ def _exportImage(self, image, imageName, imageType, imageDtype, outputDir, image if isinstance(image, slicer.vtkMRMLLabelMapVolumeNode) and imageType in self.SEGMENTATION_TYPES: format_ = { - MicroCTExport.FORMAT_RAW: ExportLogic.LABEL_MAP_FORMAT_RAW, + MicroCTExport.FORMAT_RAW: export.LABEL_MAP_FORMAT_RAW, }[imageFormat] - logic.exportLabelMap(image, outputDir, nodeDir, format_, imageName, imageType, imageDtype) + export.exportLabelMap(image, outputDir, nodeDir, format_, imageName, imageType, imageDtype) elif isinstance(image, slicer.vtkMRMLScalarVolumeNode): format_ = { - MicroCTExport.FORMAT_RAW: ExportLogic.SCALAR_VOLUME_FORMAT_RAW, - MicroCTExport.FORMAT_TIF: ExportLogic.SCALAR_VOLUME_FORMAT_TIF, + MicroCTExport.FORMAT_RAW: export.SCALAR_VOLUME_FORMAT_RAW, + MicroCTExport.FORMAT_TIF: export.SCALAR_VOLUME_FORMAT_TIF, }[imageFormat] - logic.exportScalarVolume(image, outputDir, nodeDir, format_, imageName, imageType, imageDtype) + export.exportScalarVolume(image, outputDir, nodeDir, format_, imageName, imageType, imageDtype) def _exportTable(self, table, outputDir, tableFormat): - logic = ExportLogic() nodeDir = Path(outputDir) format_ = { - MicroCTExport.FORMAT_CSV: ExportLogic.TABLE_FORMAT_CSV, + MicroCTExport.FORMAT_CSV: export.TABLE_FORMAT_CSV, }[tableFormat] - logic.exportTable(table, outputDir, nodeDir, format_) + export.exportTable(table, outputDir, nodeDir, format_) diff --git a/src/modules/MicroCTExport/Resources/Icons/MicroCTExport.png b/src/modules/MicroCTExport/Resources/Icons/MicroCTExport.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/MicroCTExport/Resources/Icons/MicroCTExport.png and /dev/null differ diff --git a/src/modules/MicroCTExport/Resources/Icons/MicroCTExport.svg b/src/modules/MicroCTExport/Resources/Icons/MicroCTExport.svg new file mode 100644 index 0000000..b8b6d68 --- /dev/null +++ b/src/modules/MicroCTExport/Resources/Icons/MicroCTExport.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MicroCTLoader/Libs/MicroCTLoaderBaseWidget.py b/src/modules/MicroCTLoader/Libs/MicroCTLoaderBaseWidget.py index a3068f5..60bc99e 100644 --- a/src/modules/MicroCTLoader/Libs/MicroCTLoaderBaseWidget.py +++ b/src/modules/MicroCTLoader/Libs/MicroCTLoaderBaseWidget.py @@ -1,15 +1,13 @@ import ctk import qt import slicer -import numpy as np +import importlib from dataclasses import dataclass, field from ltrace.slicer import microct -import importlib -import Libs.RawLoader -importlib.reload(Libs.RawLoader) -from Libs.RawLoader import RawLoaderWidget +# importlib.reload(Libs.RawLoader) +from .RawLoader import RawLoaderWidget from ltrace.slicer.ui import DirOrFileWidget from ltrace.slicer.helpers import ( highlight_error, @@ -19,15 +17,11 @@ from ltrace.units import global_unit_registry as ureg from pathlib import Path from threading import Lock +from ltrace.utils.callback import Callback COLORS = [(1, 0, 0), (0, 0.5, 1), (1, 0, 1)] -class Callback(object): - def __init__(self, on_update=None): - self.on_update = on_update or (lambda *args, **kwargs: None) - - @dataclass class LoadParameters: callback: Callback = Callback() @@ -224,6 +218,11 @@ def setup(self): self.layout.addStretch() + self.rawWidget.visible = False + self.rawParamsSection.visible = False + self.normalWidget.visible = False + self.enableProcessing(False) + if self.pathWidget.path != "": # Path is set from settings self.onPathSelected(self.pathWidget.path) diff --git a/src/modules/MicroCTLoader/Libs/RawLoader.py b/src/modules/MicroCTLoader/Libs/RawLoader.py index 408a6f0..fe2db20 100644 --- a/src/modules/MicroCTLoader/Libs/RawLoader.py +++ b/src/modules/MicroCTLoader/Libs/RawLoader.py @@ -56,16 +56,11 @@ def setup(self): self.endiannessComboBox.addItem("Big endian") parametersFormLayout.addRow("Endianness:", self.endiannessComboBox) - self.imageSkipSliderWidget = ctk.ctkSliderWidget() - self.imageSkipSliderWidget.setToolTip( + self.imageSkipSpinBox = ui.numberParamInt((0, 10e6), value=0, step=1) + self.imageSkipSpinBox.setToolTip( "If the file has a header, it can be skipped. Set the number of bytes to skip here." ) - self.imageSkipSliderWidget.setDecimals(0) - # self.imageSkipSliderWidget.singleStep = 1 - # self.imageSkipSliderWidget.minimum = 0 - self.imageSkipSliderWidget.maximum = 1000000 - # self.imageSkipSliderWidget.value = 0 - parametersFormLayout.addRow("Header size:", self.imageSkipSliderWidget) + parametersFormLayout.addRow("Header size:", self.imageSkipSpinBox) self.imageSizeXSliderWidget = ui.numberParamInt((1, 99999), value=100, step=1) self.imageSizeXSliderWidget.setToolTip("Set the image dimensions on the X axis.") @@ -79,11 +74,6 @@ def setup(self): self.imageSizeZSliderWidget.setToolTip("Set the image dimensions on the Z axis.") parametersFormLayout.addRow("Z dimension:", self.imageSizeZSliderWidget) - self.skipSlicesSliderWidget = ui.numberParamInt((0, 99999), value=0, step=1) - self.skipSlicesSliderWidget.setToolTip( - "Skip this many number of slices before adding the first slice to the ouput volume." - ) - self.imageSpacingXSliderWidget = ui.numberParam((0.01, 99999.0), value=0.01, step=0.01, decimals=2) self.imageSpacingXSliderWidget.setToolTip("Size of a voxel along X axis in microns.") parametersFormLayout.addRow("X voxel size (μm):", self.imageSpacingXSliderWidget) @@ -96,6 +86,10 @@ def setup(self): self.imageSpacingZSliderWidget.setToolTip("Size of a voxel along Z axis in microns.") parametersFormLayout.addRow("Z voxel size (μm):", self.imageSpacingZSliderWidget) + self.realTimeCheckBox = qt.QCheckBox() + self.realTimeCheckBox.setToolTip("Show the updated image instantly whenever a parameter changes.") + parametersFormLayout.addRow("Real-time update:", self.realTimeCheckBox) + parametersFormLayout.addRow(" ", None) # Output section @@ -127,11 +121,10 @@ def setup(self): outputFormLayout.addRow(" ", None) - self.updateButton = ctk.ctkCheckablePushButton() + self.updateButton = qt.QPushButton() self.updateButton.setText("Load") self.updateButton.setMinimumHeight(40) self.updateButton.setToolTip("Load view.") - self.updateButton.checkState = qt.Qt.Unchecked self.updateButton.setEnabled(False) formLayout.addRow(self.updateButton) @@ -143,30 +136,29 @@ def setup(self): # connections self.endiannessComboBox.connect("currentIndexChanged(int)", self.onImageSizeChanged) - self.imageSkipSliderWidget.connect("valueChanged(double)", self.onImageSizeChanged) - self.imageSizeXSliderWidget.connect("valueChanged(double)", self.onImageSizeChanged) - self.imageSizeYSliderWidget.connect("valueChanged(double)", self.onImageSizeChanged) - self.imageSizeZSliderWidget.connect("valueChanged(double)", self.onImageSizeChanged) - self.skipSlicesSliderWidget.connect("valueChanged(double)", self.onImageSizeChanged) + self.imageSkipSpinBox.connect("valueChanged(double)", self.onImageSizeChanged) + self.imageSizeXSliderWidget.connect("valueChanged(int)", self.onImageSizeChanged) + self.imageSizeYSliderWidget.connect("valueChanged(int)", self.onImageSizeChanged) + self.imageSizeZSliderWidget.connect("valueChanged(int)", self.onImageSizeChanged) self.imageSpacingXSliderWidget.connect("valueChanged(double)", self.onImageSizeChanged) self.imageSpacingYSliderWidget.connect("valueChanged(double)", self.onImageSizeChanged) self.imageSpacingZSliderWidget.connect("valueChanged(double)", self.onImageSizeChanged) self.pixelTypeComboBox.connect("currentIndexChanged(int)", self.onImageSizeChanged) self.fitToViewsCheckBox.connect("toggled(bool)", self.onFitToViewsCheckboxClicked) self.updateButton.connect("clicked()", self.onUpdateButtonClicked) - self.updateButton.connect("checkBoxToggled(bool)", self.onUpdateCheckboxClicked) + self.realTimeCheckBox.stateChanged.connect(self.onUpdateCheckboxClicked) self.loadParametersFromSettings() def exit(self): # disable auto-update when exiting the module to prevent accidental # updates of other volumes (when the current output volume is deleted) - self.updateButton.checkState = qt.Qt.Unchecked + self.realTimeCheckBox.setChecked(False) def onCurrentPathChanged(self, path): stem = Path(path).stem self.fillInterfaceParametersFromFileName(stem) - if self.updateButton.checkState == qt.Qt.Checked: + if self.realTimeCheckBox.isChecked(): self.onUpdate() self.showOutputVolume() @@ -232,7 +224,7 @@ def showOutputVolume(self): if selectedVolumeNode: if selectedVolumeNode.IsA(slicer.vtkMRMLSegmentationNode.__name__): # Poking the manual segmentation combobox to update the reference volume - segmenterWidget = slicer.modules.MicroCTEnvWidget.segmentationEnv.self().segmentEditorWidget.self() + segmenterWidget = slicer.modules.MicroCTEnvInstance.environment.segmentEditor() segmenterWidget.segmentationNodeComboBox.setMRMLScene(None) segmenterWidget.segmentationNodeComboBox.setMRMLScene(slicer.mrmlScene) @@ -248,7 +240,7 @@ def showOutputVolume(self): slicer.util.setSliceViewerLayers(background=selectedVolumeNode, fit=fit) def onImageSizeChanged(self, value): - if self.updateButton.checkState == qt.Qt.Checked: + if self.realTimeCheckBox.isChecked(): self.onUpdate() self.showOutputVolume() @@ -256,11 +248,10 @@ def saveParametersToSettings(self): settings = qt.QSettings() settings.setValue("RawImageGuess/pixelType", self.pixelTypeComboBox.currentText) settings.setValue("RawImageGuess/endianness", self.endiannessComboBox.currentText) - settings.setValue("RawImageGuess/headerSize", self.imageSkipSliderWidget.value) + settings.setValue("RawImageGuess/headerSize", self.imageSkipSpinBox.value) settings.setValue("RawImageGuess/sizeX", self.imageSizeXSliderWidget.value) settings.setValue("RawImageGuess/sizeY", self.imageSizeYSliderWidget.value) settings.setValue("RawImageGuess/sizeZ", self.imageSizeZSliderWidget.value) - settings.setValue("RawImageGuess/skipSlices", self.skipSlicesSliderWidget.value) settings.setValue("RawImageGuess/spacingX", self.imageSpacingXSliderWidget.value) settings.setValue("RawImageGuess/spacingY", self.imageSpacingYSliderWidget.value) settings.setValue("RawImageGuess/spacingZ", self.imageSpacingZSliderWidget.value) @@ -270,11 +261,10 @@ def loadParametersFromSettings(self): settings = qt.QSettings() self.pixelTypeComboBox.currentText = settings.value("RawImageGuess/pixelType") self.endiannessComboBox.currentText = settings.value("RawImageGuess/endianness") - self.imageSkipSliderWidget.value = int(settings.value("RawImageGuess/headerSize", 0)) + self.imageSkipSpinBox.value = int(settings.value("RawImageGuess/headerSize", 0)) self.imageSizeXSliderWidget.value = int(settings.value("RawImageGuess/sizeX", 200)) self.imageSizeYSliderWidget.value = int(settings.value("RawImageGuess/sizeY", 200)) self.imageSizeZSliderWidget.value = int(settings.value("RawImageGuess/sizeZ", 1)) - self.skipSlicesSliderWidget.value = int(settings.value("RawImageGuess/skipSlices", 0)) self.imageSpacingXSliderWidget.value = float(settings.value("RawImageGuess/spacingX", 1.0)) self.imageSpacingYSliderWidget.value = float(settings.value("RawImageGuess/spacingY", 1.0)) self.imageSpacingZSliderWidget.value = float(settings.value("RawImageGuess/spacingZ", 1.0)) @@ -284,7 +274,7 @@ def onFitToViewsCheckboxClicked(self, enable): self.showOutputVolume() def onUpdateCheckboxClicked(self, enable): - if enable: + if enable == qt.Qt.Checked: self.onUpdate() self.showOutputVolume() @@ -295,9 +285,7 @@ def onUpdateButtonClicked(self): with ProgressBarProc() as pb: pb.nextStep(0, "Loading image...") - if self.updateButton.checkState == qt.Qt.Checked: - # If update button is untoggled then make it unchecked, too - self.updateButton.checkState = qt.Qt.Unchecked + self.realTimeCheckBox.setChecked(False) self.onUpdate() self.showOutputVolume() @@ -335,8 +323,7 @@ def onUpdate(self): int(self.imageSizeXSliderWidget.value), int(self.imageSizeYSliderWidget.value), int(self.imageSizeZSliderWidget.value), - int(self.imageSkipSliderWidget.value), - int(self.skipSlicesSliderWidget.value), + int(self.imageSkipSpinBox.value), (float(self.imageSpacingXSliderWidget.value) * ureg.micrometer).m_as(SLICER_LENGTH_UNIT), (float(self.imageSpacingYSliderWidget.value) * ureg.micrometer).m_as(SLICER_LENGTH_UNIT), (float(self.imageSpacingZSliderWidget.value) * ureg.micrometer).m_as(SLICER_LENGTH_UNIT), @@ -377,7 +364,6 @@ def updateImage( sizeY, sizeZ, headerSize, - skipSlices, spacingX, spacingY, spacingZ, diff --git a/src/modules/MicroCTLoader/MicroCTLoader.py b/src/modules/MicroCTLoader/MicroCTLoader.py index 181721c..e60f045 100644 --- a/src/modules/MicroCTLoader/MicroCTLoader.py +++ b/src/modules/MicroCTLoader/MicroCTLoader.py @@ -20,8 +20,8 @@ class MicroCTLoader(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Micro CT Loader" - self.parent.categories = ["Micro CT"] + self.parent.title = "Micro CT Import" + self.parent.categories = ["MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = MicroCTLoader.help() diff --git a/src/modules/MicroCTLoader/Resources/Icons/MicroCTLoader.png b/src/modules/MicroCTLoader/Resources/Icons/MicroCTLoader.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/MicroCTLoader/Resources/Icons/MicroCTLoader.png and /dev/null differ diff --git a/src/modules/MicroCTLoader/Resources/Icons/MicroCTLoader.svg b/src/modules/MicroCTLoader/Resources/Icons/MicroCTLoader.svg new file mode 100644 index 0000000..84d86da --- /dev/null +++ b/src/modules/MicroCTLoader/Resources/Icons/MicroCTLoader.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MicroCTTransforms/MicroCTTransforms.py b/src/modules/MicroCTTransforms/MicroCTTransforms.py index d44cf84..1d29152 100644 --- a/src/modules/MicroCTTransforms/MicroCTTransforms.py +++ b/src/modules/MicroCTTransforms/MicroCTTransforms.py @@ -6,9 +6,10 @@ import qt import slicer import vtk -from Customizer import Customizer + from ltrace.slicer_utils import * from ltrace.slicer.helpers import BlockSignals +from ltrace.slicer_utils import getResourcePath def normalize_angle(angle): @@ -23,8 +24,8 @@ class MicroCTTransforms(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Micro CT Transforms" - self.parent.categories = ["Micro CT"] + self.parent.title = "MicroCT Manual Registration" + self.parent.categories = ["MicroCT"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = MicroCTTransforms.help() @@ -214,7 +215,7 @@ def increment(*args, dial=dial, incBox=incrementSpinBox, mul=mul): buttonsLayout = qt.QFormLayout(self.buttonsWidget) self.undoButton = qt.QPushButton("Undo") - self.undoButton.setIcon(qt.QIcon(str(Customizer.UNDO_ICON_PATH))) + self.undoButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Undo.png")) self.undoButton.setToolTip( "Undo last change. The earliest undo is when the volume was loaded or transformation was last applied." ) @@ -222,7 +223,7 @@ def increment(*args, dial=dial, incBox=incrementSpinBox, mul=mul): self.undoButton.clicked.connect(self.onUndoButtonClicked) self.redoButton = qt.QPushButton("Redo") - self.redoButton.setIcon(qt.QIcon(str(Customizer.REDO_ICON_PATH))) + self.redoButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Redo.png")) self.redoButton.setToolTip("Redo last change.") self.redoButton.enabled = False self.redoButton.clicked.connect(self.onRedoButtonClicked) @@ -233,13 +234,13 @@ def increment(*args, dial=dial, incBox=incrementSpinBox, mul=mul): buttonsLayout.addRow(buttonsHBoxLayout) self.applyButton = qt.QPushButton("Apply") - self.applyButton.setIcon(qt.QIcon(str(Customizer.APPLY_ICON_PATH))) + self.applyButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Apply.png")) self.applyButton.setToolTip("Apply changes. This action cannot be undone.") self.applyButton.enabled = False self.applyButton.clicked.connect(self.onApplyButtonClicked) self.resetButton = qt.QPushButton("Reset") - self.resetButton.setIcon(qt.QIcon(str(Customizer.RESET_ICON_PATH))) + self.resetButton.setIcon(qt.QIcon(getResourcePath("Icons") / "Reset.png")) self.resetButton.setToolTip("Reset changes to the last applied state.") self.resetButton.enabled = False self.resetButton.clicked.connect(self.onResetButtonClicked) diff --git a/src/modules/MicroCTTransforms/Resources/Icons/MicroCTTransforms.png b/src/modules/MicroCTTransforms/Resources/Icons/MicroCTTransforms.png deleted file mode 100644 index b103164..0000000 Binary files a/src/modules/MicroCTTransforms/Resources/Icons/MicroCTTransforms.png and /dev/null differ diff --git a/src/modules/MicroCTTransforms/Resources/Icons/MicroCTTransforms.svg b/src/modules/MicroCTTransforms/Resources/Icons/MicroCTTransforms.svg new file mode 100644 index 0000000..98c1389 --- /dev/null +++ b/src/modules/MicroCTTransforms/Resources/Icons/MicroCTTransforms.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MicrotomRemote/Libs/microtom/requirements.txt b/src/modules/MicrotomRemote/Libs/microtom/requirements.txt index 58c4ea4..447c127 100644 --- a/src/modules/MicrotomRemote/Libs/microtom/requirements.txt +++ b/src/modules/MicrotomRemote/Libs/microtom/requirements.txt @@ -1,5 +1,5 @@ # Note: graphical related libraries must be disabled -setuptools==59.8.0 +setuptools==60.2.0 numpy==1.23.1 dask-image==0.4.0 dask[complete]==2.30.0 diff --git a/src/modules/MicrotomRemote/MicrotomRemote.py b/src/modules/MicrotomRemote/MicrotomRemote.py index 0fa9045..e64ee2e 100644 --- a/src/modules/MicrotomRemote/MicrotomRemote.py +++ b/src/modules/MicrotomRemote/MicrotomRemote.py @@ -57,8 +57,9 @@ def __init__(self, parent=None) -> None: layout = qt.QFormLayout(self) self._correction = None - self._por_map_table = ui.hierarchyVolumeInput(onChange=self.onSelect, hasNone=True) - self._por_map_table.setNodeTypes(["vtkMRMLTableNode"]) + self._por_map_table = ui.hierarchyVolumeInput( + onChange=self.onSelect, hasNone=True, nodeTypes=["vtkMRMLTableNode"] + ) self._por_map_table.addNodeAttributeIncludeFilter("NodeEnvironment", "PorosityMap") self._total_micro_porosity_label = qt.QLabel() self._total_porosity_label = qt.QLabel() @@ -434,8 +435,8 @@ class MicrotomRemote(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Microtom Remote" - self.parent.categories = ["Micro CT"] + self.parent.title = "Microtom" + self.parent.categories = ["MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] # replace with "Firstname Lastname (Organization)" self.parent.helpText = "" @@ -496,7 +497,10 @@ def setup(self): def resetAll(): for i in range(self.configWidget.count): - self.configWidget.widget(i).reset() + # PNM Report does not share the base class, so we need to check if the method exists + widget = self.configWidget.widget(i) + if hasattr(widget, "reset") and callable(widget.reset): + self.configWidget.widget(i).reset() self.modeWidgets[widgets.SingleShotInputWidget.MODE_NAME].segmentListUpdated.connect(resetAll) @@ -639,10 +643,18 @@ def _setupConfigSection(self): self.psdConfigWidget = DistributionsForm(hasSatCorrection=True, hasResolutionConfig=True) self.hpsdConfigWidget = DistributionsForm(hasSatCorrection=True, hasResolutionConfig=False) self.distribWidget = DirectedDistributionForm() - self.pnmReportWidget = ReportForm() - self.pnmReportWidget.objectName = "PNMReportForm" - self.configWidget.addWidget(self.pnmReportWidget) + if ReportForm is not None: + self.pnmReportWidget = ReportForm() + self.pnmReportWidget.objectName = "PNMReportForm" + self.configWidget.addWidget(self.pnmReportWidget) + else: + warningPlaceHolder = qt.QWidget() + warningPlaceHolderLayout = qt.QVBoxLayout(warningPlaceHolder) + warningPlaceHolderLayout.addWidget(qt.QLabel("Report module not available")) + warningPlaceHolderLayout.addStretch(1) + self.configWidget.addWidget(warningPlaceHolder) + self.configWidget.addWidget(self.psdConfigWidget) self.configWidget.addWidget(self.hpsdConfigWidget) self.configWidget.addWidget(self.distribWidget) @@ -652,7 +664,9 @@ def _setupConfigSection(self): self.configWidget.addWidget(KrelForm()) for i in range(self.configWidget.count): - self.configWidget.widget(i).setup() + widget = self.configWidget.widget(i) + if hasattr(widget, "setup") and callable(widget.setup): + self.configWidget.widget(i).setup() self.ioFileExecDataSetButton = self._setupExecEnv() self.ioFileExecDataSetButton.setToolTip("Select how the algorithm will run, locally or remotely") @@ -832,6 +846,15 @@ def _setupServerManager(self): folderLineEdit.currentPathChanged.emit(self.server.report_folder) advancedFormLayout.addRow("Report Folder:", folderLineEdit) + hbox = qt.QHBoxLayout() + updateScripts = qt.QCheckBox("Update scripts in report folder") + updateScripts.stateChanged.connect(self.server.onUpdateScriptsChecked) + updateScripts.setToolTip( + "This checkbox will replace existing Streamlit codes in the report folder with the versions from the current GeoSlicer release." + ) + hbox.addWidget(updateScripts) + advancedFormLayout.addRow(hbox) + ApplicationObservables().applicationLoadFinished.connect(self.server.retrieveActiveStreamlit) else: self.server = None @@ -870,7 +893,9 @@ def onSelectionChanged(): def onLocationToggled(self, option, toggle): if toggle: self.chosenExecutionMode = option - self.configWidget.currentWidget().showOnly(option) + widget = self.configWidget.currentWidget() + if hasattr(widget, "showOnly") and callable(widget.showOnly): + widget.showOnly(option) self.warningLabel.visible = self.chosenExecutionMode == "Remote" @@ -1184,6 +1209,9 @@ def onExecuteClicked(self): if uid: self.showJobs() self.restartApplyButton() + elif not uid: + # if interrupted locally (uid is None), we can restart the button + self.restartApplyButton() def restartApplyButton(self): slicer.app.processEvents() @@ -1640,11 +1668,7 @@ def run( else: if not labels: - slicer.util.errorDisplay( - "Please, select at least one segment by checking the segment box on the segment list. " - "The selected segment will be considered as the pore space." - ) - return + raise ValueError("No segment selected") nodeName = segmentationNode.GetName() inputVolumeNode, _ = helpers.createLabelmapInput( @@ -1673,6 +1697,11 @@ def run( uid = simulator return uid + except ValueError as ve: + slicer.util.errorDisplay( + "Please, select at least one segment by checking the segment box on the segment list. " + "The selected segment will be considered as the pore space." + ) except Exception as e: import traceback @@ -1680,6 +1709,8 @@ def run( helpers.removeTemporaryNodes(environment=tag) slicer.util.errorDisplay("Sorry, something went wrong...check out the logs") + return None + def sequencePreRun(self, simulator, sequenceNode, labels, outputPrefix, tag, output_path, params): sequenceName = slicer.mrmlScene.GenerateUniqueName(f"{outputPrefix}_{simulator.upper()}_Output_Proxy") diff --git a/src/modules/MicrotomRemote/Resources/Icons/MicrotomRemote.png b/src/modules/MicrotomRemote/Resources/Icons/MicrotomRemote.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/MicrotomRemote/Resources/Icons/MicrotomRemote.png and /dev/null differ diff --git a/src/modules/MicrotomRemote/Resources/Icons/MicrotomRemote.svg b/src/modules/MicrotomRemote/Resources/Icons/MicrotomRemote.svg new file mode 100644 index 0000000..983329d --- /dev/null +++ b/src/modules/MicrotomRemote/Resources/Icons/MicrotomRemote.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/ModuleInstaller/ModuleInstaller.py b/src/modules/ModuleInstaller/ModuleInstaller.py index 246d1c7..c1032b4 100644 --- a/src/modules/ModuleInstaller/ModuleInstaller.py +++ b/src/modules/ModuleInstaller/ModuleInstaller.py @@ -25,11 +25,11 @@ class ModuleInstaller(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Module Installer" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools"] self.parent.dependencies = [] self.parent.contributors = ["Rafael Arenhart (LTrace Geophysics)"] self.parent.helpText = """ -Imports a new module from a filder, zip file or git URL. +Imports a new module from a directory, zip file or git URL. """ self.parent.helpText += self.getDefaultModuleDocumentationLink() self.parent.acknowledgementText = """ @@ -140,7 +140,7 @@ def run(self, source_string, username=None, password=None): install_git_module(remote) else: qt.QMessageBox.warning( - slicer.util.mainWindow(), + slicer.modules.AppContextInstance.mainWindow, "Source is not valid", "Given source for module is neither a zip file nor an accessible git repository.", ) @@ -148,7 +148,7 @@ def run(self, source_string, username=None, password=None): slicer.modules.CustomizerInstance.set_paths() qt.QMessageBox.information( - slicer.util.mainWindow(), + slicer.modules.AppContextInstance.mainWindow, "Module installation successful", "Please restart GeoSlicer to finish new modules setup.", ) diff --git a/src/modules/ModuleToolBarCustomizer/ModuleToolBarCustomizer.py b/src/modules/ModuleToolBarCustomizer/ModuleToolBarCustomizer.py index 47062a4..86075d1 100644 --- a/src/modules/ModuleToolBarCustomizer/ModuleToolBarCustomizer.py +++ b/src/modules/ModuleToolBarCustomizer/ModuleToolBarCustomizer.py @@ -15,11 +15,9 @@ class ModuleToolBarCustomizer(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Module ToolBar Customizer" - if not slicer.app.commandOptions().noMainWindow: - slicer.app.connect("startupCompleted()", self.customize) def customize(self): - moduleToolBar = slicer.util.findChild(slicer.util.mainWindow(), "ModuleToolBar") + moduleToolBar = slicer.util.findChild(slicer.modules.AppContextInstance.mainWindow, "ModuleToolBar") # Actions black-list actionBlackList = ["Customized Data", "Segmentation Tools"] diff --git a/src/modules/MonaiLabelServer/MonaiLabelServer.py b/src/modules/MonaiLabelServer/MonaiLabelServer.py index 438af58..b2b680a 100644 --- a/src/modules/MonaiLabelServer/MonaiLabelServer.py +++ b/src/modules/MonaiLabelServer/MonaiLabelServer.py @@ -31,7 +31,7 @@ class MonaiLabelServer(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "MONAILabel Server" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] @@ -146,15 +146,19 @@ def browseApp(self): dialog = qt.QFileDialog() dialog.setFileMode(qt.QFileDialog.Directory) dialog.setOption(qt.QFileDialog.ShowDirsOnly) - directory = dialog.getExistingDirectory(slicer.util.mainWindow(), "Choose App Directory") + directory = dialog.getExistingDirectory(slicer.modules.AppContextInstance.mainWindow, "Choose App Directory") self.appLineEdit.setText(directory) + dialog.delete() def browseDataset(self): dialog = qt.QFileDialog() dialog.setFileMode(qt.QFileDialog.Directory) dialog.setOption(qt.QFileDialog.ShowDirsOnly) - directory = dialog.getExistingDirectory(slicer.util.mainWindow(), "Choose Dataset Directory") + directory = dialog.getExistingDirectory( + slicer.modules.AppContextInstance.mainWindow, "Choose Dataset Directory" + ) self.datasetLineEdit.setText(directory) + dialog.delete() def _onLocalToggled(self): self.remoteRadioButton.setChecked(False) diff --git a/src/modules/MultiScale/MultiScale.py b/src/modules/MultiScale/MultiScale.py index bcc495f..5db9a93 100644 --- a/src/modules/MultiScale/MultiScale.py +++ b/src/modules/MultiScale/MultiScale.py @@ -1,24 +1,22 @@ +import json +import math import os -from multiprocessing import cpu_count import shutil +from multiprocessing import cpu_count from pathlib import Path -import json -import math -from ltrace.slicer.node_attributes import NodeEnvironment import ctk -from Customizer import Customizer -import qt -import slicer import mpslib as mps import numpy as np +import qt +import slicer from tifffile import tifffile -from ltrace.slicer import widgets -from ltrace.slicer import ui from ltrace.slicer import helpers -from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic +from ltrace.slicer import ui +from ltrace.slicer import widgets from ltrace.slicer.widget.global_progress_bar import LocalProgressBar +from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic, getResourcePath from ltrace.utils.ProgressBarProc import ProgressBarProc try: @@ -40,8 +38,8 @@ class MultiScale(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Multi Scale" - self.parent.categories = ["Micro CT"] + self.parent.title = "Multi-Scale Image Generation" + self.parent.categories = ["MicroCT", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = MultiScale.help() @@ -73,79 +71,52 @@ def setup(self): hideSoi=True, hideCalcProp=False, allowedInputNodes=[ - "vtkMRMLVectorVolumeNode", "vtkMRMLScalarVolumeNode", "vtkMRMLSegmentationNode", "vtkMRMLLabelMapVolumeNode", ], mainName="Training Image", - referenceName="Reference", + referenceName="TI Reference", setDefaultMargins=False, + objectNamePrefix="Training Image", ) self.trainingImageWidget.formLayout.setContentsMargins(0, 0, 0, 0) - self.trainingImageWidget.mainInput.currentItemChanged.connect(self.setTrainingImageListChecked) + self.trainingImageWidget.mainInput.currentItemChanged.connect(self.onTrainingImageChange) self.trainingImageWidget.onReferenceSelectedSignal.connect(self.updateFinalImageWidgets) self.trainingImageWidget.segmentListGroup[1].itemChanged.connect(lambda: self.listItemChange()) - self.trainingImageWidget.autoPorosityCalcCb.stateChanged.connect(self.setTrainingImageListChecked) + self.trainingImageWidget.autoPorosityCalcCb.stateChanged.connect(self.onTrainingImageChange) - self.hardDataComboBox = ui.hierarchyVolumeInput( - hasNone=True, - nodeTypes=[ - "vtkMRMLVectorVolumeNode", + self.hardDataWidget = widgets.SingleShotInputWidget( + hideSoi=True, + allowedInputNodes=[ "vtkMRMLScalarVolumeNode", "vtkMRMLSegmentationNode", "vtkMRMLLabelMapVolumeNode", ], - onChange=self.onHardDataChange, - tooltip="Select the hard data image", + mainName="Hard Data Image", + referenceName="HD Reference", + setDefaultMargins=False, + objectNamePrefix="Hard Data", ) - self.hardDataComboBox.objectName = "hardDataInput" - self.HardDataWidgetLabel = qt.QLabel("Hard data image:") - - self.referenceComboBox = ui.hierarchyVolumeInput( - hasNone=True, - nodeTypes=[ - "vtkMRMLVectorVolumeNode", - "vtkMRMLScalarVolumeNode", - "vtkMRMLLabelMapVolumeNode", - ], - onChange=self.onReferenceChange, - tooltip="Segmentation node reference volume. This is used as reference for the hard data resolution and imagelog depth", + self.hardDataWidget.formLayout.setContentsMargins(0, 0, 0, 0) + self.hardDataWidget.mainInput.currentItemChanged.connect(self.onHardDataChange) + self.hardDataWidget.onReferenceSelectedSignal.connect(self.onReferenceChange) + self.hardDataWidget.segmentListGroup[1].itemChanged.connect(self.listItemChange) + self.hardDataWidget.autoPorosityCalcCb.stateChanged.connect( + lambda: self.checkListItems(self.hardDataWidget.segmentListGroup[1]) ) - self.referenceComboBox.objectName = "referenceComboBox" - self.referenceComboBox.hide() - - self.referenceLabel = qt.QLabel("Reference:") - self.referenceLabel.objectName = "hardDataLabel" - self.referenceLabel.hide() - - self.segmentListWidget = qt.QListWidget() - self.segmentListWidget.setFixedHeight(120) - self.segmentListWidget.hide() - self.segmentListWidget.objectName = "hardDataSegmentList" - self.segmentListWidget.itemChanged.connect(self.listItemChange) - self.segmentListWidget.setToolTip("Select the values of the volume to act as Hard Data for the algorithm") - - self.segmentsContainerLabel = qt.QLabel("Hard data values:") - self.segmentsContainerLabel.hide() - self.segmentsContainerWidget = qt.QWidget() - segmentsLayout = qt.QFormLayout(self.segmentsContainerWidget) - segmentsLayout.addRow(self.segmentListWidget) - segmentsLayout.setContentsMargins(0, 0, 0, 0) - self.segmentsContainerWidget.hide() - self.segmentsContainerWidget.objectName = "segmentContainer" self.hardDataResolution = [] self.hardDataResolutionText = qt.QLabel("0 x 0 x 0 (mm)") self.hardDataResolutionText.setToolTip( "Resolution of the hard data voxel in mm. For the multiscale simulations, the units are converted to micrometers" ) - self.hardDataResolutionText.objectName = "hardDataText" + self.hardDataResolutionText.objectName = "Hard Data Resolution Label" self.hardDataResolutionText.hide() - self.hardDataResolutionLabel = qt.QLabel("Hard Data resolution:") + self.hardDataResolutionLabel = qt.QLabel("HD resolution:") self.hardDataResolutionLabel.objectName = "hardDataLabel" self.hardDataResolutionLabel.hide() @@ -172,7 +143,7 @@ def setup(self): self.previewDimensionSpinBox.objectName = "previewDimensionSpinBox" self.previewButton = qt.QPushButton() - self.previewButton.setIcon(qt.QIcon(str(Customizer.CLOSED_EYE_ICON_PATH))) + self.previewButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeClosed.png")) self.previewButton.setFixedWidth(30) self.previewButton.enabled = False self.previewButton.clicked.connect(self.onViewPreviewToggle) @@ -254,9 +225,7 @@ def setup(self): inputFormLayout = qt.QFormLayout(inputSection) inputFormLayout.addRow(self.trainingImageWidget) inputFormLayout.addRow("", None) - inputFormLayout.addRow(self.HardDataWidgetLabel, self.hardDataComboBox) - inputFormLayout.addRow(self.referenceLabel, self.referenceComboBox) - inputFormLayout.addRow(self.segmentsContainerLabel, self.segmentsContainerWidget) + inputFormLayout.addRow(self.hardDataWidget) inputFormLayout.addRow(self.hardDataResolutionLabel, self.hardDataResolutionText) inputFormLayout.addRow(self.previewLabel, self.previewWidget) inputFormLayout.addRow("", None) @@ -270,7 +239,7 @@ def setup(self): parametersSection.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum) parametersLayout = qt.QFormLayout(parametersSection) - self.imageSpacingValidator = qt.QRegExpValidator(qt.QRegExp("[+]?[0-9]*\\.?[0-9]+([eE][-+]?[0-9]+)?")) + self.imageSpacingValidator = qt.QRegExpValidator(qt.QRegExp("[+]?[0-9]*\\.?[0-9]{0,5}([eE][-+]?[0-9]+)?")) self.finalImageResolution = [] self.finalImageSize = [] @@ -459,6 +428,7 @@ def setup(self): self.saveOptionsWidgets.setLayout(saveOptionsLayout) self.exportDirectoryButton = ctk.ctkDirectoryButton() + self.exportDirectoryButton.setMaximumWidth(374) self.exportDirectoryButton.caption = "Export directory" self.exportDirectoryButton.setDisabled(True) self.exportDirectoryButton.objectName = "fileDirectory" @@ -497,11 +467,18 @@ def setup(self): buttonsHBoxLayout.addWidget(self.cancelButton) self.localProgressBar = LocalProgressBar() + self.localProgressBar.progressBar.setStatusVisibility(0) + + self.statusLabel = qt.QLabel("Status: Idle") + self.statusLabel.setVisible(False) + self.statusLabel.setAlignment(qt.Qt.AlignRight | qt.Qt.AlignVCenter) + self.statusLabel.objectName = "MPS Time QLabel" self.layout.addWidget(inputSection) self.layout.addWidget(parametersSection) self.layout.addWidget(outputSection) self.layout.addLayout(buttonsHBoxLayout) + self.layout.addWidget(self.statusLabel) self.layout.addWidget(self.localProgressBar) self.layout.addStretch(1) @@ -540,68 +517,46 @@ def updateFinalImageWidgets(self, node): if node is not None: spacing = node.GetSpacing() dimensions = node.GetImageData().GetDimensions() - rseedMax = dimensions[0] * dimensions[1] * dimensions[2] + rseedMax = min(dimensions[0] * dimensions[1] * dimensions[2], 2147483647) for dim in range(3): - self.finalImageResolution[dim].setText(spacing[dim]) + self.finalImageResolution[dim].setText(round(spacing[dim], 5)) self.finalImageResolution[dim].enabled = enableWidgets self.finalImageSize[dim].setValue(dimensions[dim]) self.finalImageSize[dim].enabled = enableWidgets self.rseedSpinBox.setRange(0, rseedMax) - def onReferenceChange(self, nodeId: int = None, node=None): + def onReferenceChange(self, node=None): self.enableWrapCheckBox.enabled = False self.enableWrapCheckBox.setChecked(qt.Qt.Unchecked) - if nodeId: - node = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene).GetItemDataNode(nodeId) + self.updateHardDataResolution(None) + self.setPreviewValues() if node: + self.hardDataResolution = np.array(node.GetSpacing()) + self.updateHardDataResolution(self.hardDataResolution) if node.GetImageData().GetDimensions()[1] == 1: self.setPreviewValues(True, node) self.enableWrapCheckBox.enabled = True else: - self.hardDataResolution = np.array(node.GetSpacing()) self.setPreviewValues(False, node) - self.updateHardDataResolution(self.hardDataResolution) - else: - self.updateHardDataResolution(None) self.checkRunButtonState() def onHardDataChange(self, nodeID): - self.valuesList = [] - self.segmentListWidget.hide() - self.segmentsContainerLabel.hide() - self.segmentsContainerWidget.hide() - self.updateHardDataResolution(None) self.changePreviewOptionsVisibility() - self.setPreviewValues() - self.updateContinuousCheckBoxState() - self.referenceLabel.hide() - self.referenceComboBox.hide() - self.referenceComboBox.setCurrentNode(None) + self.checkListItems(self.hardDataWidget.segmentListGroup[1]) - node = self.hardDataComboBox.currentNode() + node = self.hardDataWidget.mainInput.currentNode() if node: if isinstance(node, slicer.vtkMRMLLabelMapVolumeNode) or isinstance(node, slicer.vtkMRMLSegmentationNode): - segments = helpers.getSegmentList(node) self.changePreviewOptionsVisibility(True) - elif isinstance(node, slicer.vtkMRMLScalarVolumeNode): - segments = self.getSegmentsFromScalar(node) - - self.newUpdateSegmentList(segments) - self.segmentsContainerLabel.show() - self.segmentsContainerWidget.show() background = None - if isinstance(node, slicer.vtkMRMLLabelMapVolumeNode) or isinstance(node, slicer.vtkMRMLScalarVolumeNode): - background = node if isinstance(node, slicer.vtkMRMLScalarVolumeNode) else None - sourceVolumeNode = helpers.getSourceVolume(self.hardDataComboBox.currentNode()) - self.referenceComboBox.setCurrentNode(node) + if isinstance(node, slicer.vtkMRMLScalarVolumeNode): + background = node + elif isinstance(node, slicer.vtkMRMLSegmentationNode): - self.referenceLabel.show() - self.referenceComboBox.show() - sourceVolumeNode = helpers.getSourceVolume(self.hardDataComboBox.currentNode()) + sourceVolumeNode = helpers.getSourceVolume(self.hardDataWidget.mainInput.currentNode()) if sourceVolumeNode: - self.referenceComboBox.setCurrentNode(sourceVolumeNode) background = sourceVolumeNode slicer.util.setSliceViewerLayers( @@ -626,22 +581,21 @@ def updateInputWidgetsVisibility(self): showHardData = True showMask = True - if self.hardDataComboBox.currentNode() is not None: + if self.hardDataWidget.mainInput.currentNode() is not None: showMask = False else: if self.maskWidget.mainInput.currentNode() is not None: showHardData = False - self.hardDataComboBox.setVisible(showHardData) - self.hardDataComboBox.enabled = showHardData - self.HardDataWidgetLabel.setVisible(showHardData) + self.hardDataWidget.setVisible(showHardData) + self.hardDataWidget.enabled = showHardData self.maskWidget.setVisible(showMask) self.maskWidget.enabled = showMask def updateOutputPrefix(self): text = "" - if self.hardDataComboBox.currentNode() is not None: - text = f"{self.hardDataComboBox.currentNode().GetName()}_multiscale" + if self.hardDataWidget.mainInput.currentNode() is not None: + text = f"{self.hardDataWidget.mainInput.currentNode().GetName()}_multiscale" elif self.maskWidget.mainInput.currentNode() is not None: text = f"{self.maskWidget.mainInput.currentNode().GetName()}_multiscale" elif self.trainingImageWidget.mainInput.currentNode() is not None: @@ -649,46 +603,13 @@ def updateOutputPrefix(self): self.outputPrefix.text = text - def newUpdateSegmentList(self, segments): - self.segmentListWidget.clear() - self.valuesList = np.empty(len(segments)) - index = 0 - for label in segments: - item = qt.QListWidgetItem() - item.setText(segments[label]["name"]) - if len(segments[label]["color"]) > 0: - icon = self.ColoredIcon(*[int(c * 255) for c in segments[label]["color"][:3]]) - item.setIcon(icon) - item.setFlags(item.flags() | qt.Qt.ItemIsUserCheckable) - item.setCheckState(qt.Qt.Checked) - self.segmentListWidget.addItem(item) - self.valuesList[index] = label - index += 1 - self.segmentListWidget.show() - - def ColoredIcon(self, r, g, b): - img = qt.QImage(16, 16, qt.QImage.Format_RGB32) - p = qt.QPainter(img) - p.fillRect(img.rect(), qt.QColor(r, g, b)) - p.end() - return qt.QIcon(qt.QPixmap.fromImage(img)) - - def getSegmentsFromScalar(self, volume): - segments = {} - for value in self.logic.getScalarVolumeValues(volume): - segments[value] = {"name": str(value), "color": []} - return segments - - def getCheckedItems(self, list): - items = np.full(list.count, True) - for n in range(list.count): - items[n] = list.item(n).checkState() == qt.Qt.Checked - return items - - def setTrainingImageListChecked(self): - if self.trainingImageWidget.segmentListGroup[1].visible: - for item in range(self.trainingImageWidget.segmentListGroup[1].count): - self.trainingImageWidget.segmentListGroup[1].item(item).setCheckState(qt.Qt.Checked) + def onTrainingImageChange(self): + self.checkListItems(self.trainingImageWidget.segmentListGroup[1]) + + def checkListItems(self, segmentList): + if segmentList.visible: + for item in range(segmentList.count): + segmentList.item(item).setCheckState(qt.Qt.Checked) self.updateContinuousCheckBoxState() self.checkRunButtonState() @@ -709,9 +630,10 @@ def checkRunButtonState(self): ) hardDataIsValid = True - if self.hardDataComboBox.currentNode() is not None: - hardDataIsValid = self.referenceComboBox.currentNode() is not None and not np.all( - self.getCheckedItems(self.segmentListWidget) == False + if self.hardDataWidget.mainInput.currentNode() is not None: + hardDataIsValid = self.hardDataWidget.referenceInput.currentNode() is not None and ( + type(self.hardDataWidget.mainInput.currentNode()) is slicer.vtkMRMLScalarVolumeNode + or len(self.trainingImageWidget.getSelectedSegments()) > 0 ) maskIsValid = True @@ -750,9 +672,9 @@ def runLogic(self, isParallel): self.trainingImageWidget.mainInput.currentNode(), "_TI" ) - if isinstance(self.hardDataComboBox.currentNode(), slicer.vtkMRMLSegmentationNode): + if isinstance(self.hardDataWidget.mainInput.currentNode(), slicer.vtkMRMLSegmentationNode): hardDataSegmentation = True - hardDataLabelMap = self.segmentationInputToLabelmap(self.hardDataComboBox.currentNode(), "_HD") + hardDataLabelMap = self.segmentationInputToLabelmap(self.hardDataWidget.mainInput.currentNode(), "_HD") self.hardDataResolution = np.array(hardDataLabelMap.GetSpacing()) if isinstance(self.maskWidget.mainInput.currentNode(), slicer.vtkMRMLSegmentationNode): @@ -763,25 +685,30 @@ def runLogic(self, isParallel): self.changeRunButtonsState(False) preprocessing = { - "trainingDataVolume": trainingImageLabelMap - if TISegmentation - else self.trainingImageWidget.mainInput.currentNode(), - "trainingReference": self.trainingImageWidget.referenceInput.currentNode() - if self.continuousDataCheckBox.isChecked() - else None, + "trainingDataVolume": ( + trainingImageLabelMap if TISegmentation else self.trainingImageWidget.mainInput.currentNode() + ), + "trainingReference": ( + self.trainingImageWidget.referenceInput.currentNode() + if self.continuousDataCheckBox.isChecked() + else None + ), "trainingDataSegments": [segment + 1 for segment in self.trainingImageWidget.getSelectedSegments()], "wrapCylinder": self.enableWrapCheckBox.isChecked(), } - if self.hardDataComboBox.currentNode() is not None: + + if self.hardDataWidget.mainInput.currentNode() is not None: preprocessing["hardDataVolume"] = ( - hardDataLabelMap if hardDataSegmentation else self.hardDataComboBox.currentNode() + hardDataLabelMap if hardDataSegmentation else self.hardDataWidget.mainInput.currentNode() ) preprocessing["hardDataReference"] = ( - self.referenceComboBox.currentNode() if self.continuousDataCheckBox.isChecked() else None + self.hardDataWidget.referenceInput.currentNode() + if self.continuousDataCheckBox.isChecked() + else None ) - preprocessing["hardDataValues"] = self.valuesList[ - self.getCheckedItems(self.segmentListWidget) - ].tolist() + preprocessing["hardDataValues"] = [ + segment + 1 for segment in self.hardDataWidget.getSelectedSegments() + ] preprocessing["hardDataResolution"] = self.hardDataResolution if self.maskWidget.mainInput.currentNode() is not None: @@ -888,7 +815,7 @@ def segmentationInputToLabelmap(self, segmentationNode, volumeType=""): def updateHardDataResolution(self, spacing): if spacing is not None: - self.hardDataResolutionText.setText(f"{spacing[0]:.9f} x {spacing[1]:.9f} x {spacing[2]:.9f} (mm)") + self.hardDataResolutionText.setText(f"{spacing[0]:.3f} x {spacing[1]:.3f} x {spacing[2]:.3f} (mm)") self.hardDataResolutionText.show() self.hardDataResolutionLabel.show() else: @@ -897,8 +824,9 @@ def updateHardDataResolution(self, spacing): self.hardDataResolutionLabel.hide() def getPreviewSegmentationNode(self): - if isinstance(self.hardDataComboBox.currentNode(), slicer.vtkMRMLSegmentationNode): - hardDataLabelMap, _ = helpers.createLabelmapInput(self.hardDataComboBox.currentNode(), "previewLabelmap") + node = self.hardDataWidget.mainInput.currentNode() + if isinstance(node, slicer.vtkMRMLSegmentationNode): + hardDataLabelMap, _ = helpers.createLabelmapInput(node, "previewLabelmap") if hardDataLabelMap.GetImageData().GetDimensions()[1] == 1: self.previewSegmentationNode = self.logic.generatePreview( hardDataLabelMap, @@ -915,19 +843,19 @@ def getPreviewSegmentationNode(self): self.previewSegmentationNode.SetName("previewSegmentation") helpers.makeNodeTemporary(self.previewSegmentationNode, hide=True) - elif isinstance(self.hardDataComboBox.currentNode(), slicer.vtkMRMLLabelMapVolumeNode): - if self.hardDataComboBox.currentNode().GetImageData().GetDimensions()[1] == 1: + elif isinstance(node, slicer.vtkMRMLLabelMapVolumeNode): + if node.GetImageData().GetDimensions()[1] == 1: self.previewSegmentationNode = self.logic.generatePreview( - self.hardDataComboBox.currentNode(), + node, self.previewDimensionSpinBox.value, self.depthTopSpinBox.value, self.depthBottomSpinBox.value, ) else: - slicer.util.setSliceViewerLayers(background=None, label=self.hardDataComboBox.currentNode(), fit=True) + slicer.util.setSliceViewerLayers(background=None, label=node, fit=True) self.previewSegmentationNode = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLSegmentationNode") slicer.modules.segmentations.logic().ImportLabelmapToSegmentationNode( - self.hardDataComboBox.currentNode(), self.previewSegmentationNode + node, self.previewSegmentationNode ) self.previewSegmentationNode.SetName("previewSegmentation") helpers.makeNodeTemporary(self.previewSegmentationNode, hide=True) @@ -955,20 +883,20 @@ def changePreviewState(self, progressBar): self.previewSegmentationNode.CreateClosedSurfaceRepresentation() - self.previewButton.setIcon(qt.QIcon(str(Customizer.OPEN_EYE_ICON_PATH))) + self.previewButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeOpen.png")) self.isViewOn = True elif self.isViewOn: progressBar.setMessage("Removing preview") self.previewSegmentationNode.RemoveClosedSurfaceRepresentation() helpers.removeTemporaryNodes() - self.previewButton.setIcon(qt.QIcon(str(Customizer.CLOSED_EYE_ICON_PATH))) + self.previewButton.setIcon(qt.QIcon(getResourcePath("Icons") / "EyeClosed.png")) self.isViewOn = False def checkSegmentsVisibility(self, displayNode): - for n in range(self.segmentListWidget.count): - if not self.segmentListWidget.item(n).checkState() == qt.Qt.Checked: - displayNode.SetSegmentOpacity(self.segmentListWidget.item(n).text(), 0) + for n in range(self.hardDataWidget.segmentListGroup[1].count): + if not self.hardDataWidget.segmentListGroup[1].item(n).checkState() == qt.Qt.Checked: + displayNode.SetSegmentOpacity(self.hardDataWidget.segmentListGroup[1].item(n).text(), 0) def onSegmentVisibilityChange(self, id, isVisible): if self.isViewOn: @@ -991,8 +919,8 @@ def updateContinuousCheckBoxState(self): hardDataScalar = False if self.trainingImageWidget.mainInput.currentNode() is not None: tiScalar = self.trainingImageWidget.mainInput.currentNode().GetClassName() == "vtkMRMLScalarVolumeNode" - if self.hardDataComboBox.currentNode() is not None: - hardDataScalar = self.hardDataComboBox.currentNode().GetClassName() == "vtkMRMLScalarVolumeNode" + if self.hardDataWidget.mainInput.currentNode() is not None: + hardDataScalar = self.hardDataWidget.mainInput.currentNode().GetClassName() == "vtkMRMLScalarVolumeNode" isScalar = tiScalar or hardDataScalar @@ -1033,17 +961,26 @@ def onCLIEvent(self): self.cancelButton.enabled = False self.changeRunButtonsState(True) + def updateTime(self, time): + self.statusLabel.setVisible(True) + self.updateStatusLabel("Completed", f" (MPSlib execution took {time:.2f} seconds)") + + def updateStatusLabel(self, status, text=""): + self.statusLabel.text = f"Status: {status}{text}" + class MultiScaleLogic(LTracePluginLogic): def __init__(self, widget): LTracePluginLogic.__init__(self) self.image = [] + self.time = 0 self.cliNode = None self.widget = widget self.outputName = "" self.outputDir = 0 self.save_options = {} self.mask_options = {} + self.cliObserver = None def configureOutput(self, outputPrefix): subjectHierarchyNode = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) @@ -1075,18 +1012,43 @@ def mps_preprocessing(self, data: dict, isContinuous: bool, sequentialProgressBa """ sequentialProgressBar.setMessage("Writing TI data file") + invertHDSelectedSegments = False + invertTISelectedSegments = False + nullValueHD = 0 + selectedSegmentsTI = data["trainingDataSegments"] + if isContinuous: simulationTI = slicer.util.arrayFromVolume(data["trainingReference"]) if "hardDataVolume" in data: SimulationHD = data["hardDataReference"] + + if ( + type(data["hardDataVolume"]) is slicer.vtkMRMLScalarVolumeNode + and data["hardDataVolume"] == data["hardDataReference"] + ): + nullValueHD = helpers.getVolumeNullValue(SimulationHD) + if nullValueHD: + invertHDSelectedSegments = True + + if ( + type(data["trainingDataVolume"]) is slicer.vtkMRMLScalarVolumeNode + and data["trainingDataVolume"] == data["trainingReference"] + ): + nullValueTI = helpers.getVolumeNullValue(data["trainingReference"]) + if nullValueTI: + invertTISelectedSegments = True + selectedSegmentsTI = [nullValueTI] + else: simulationTI = slicer.util.arrayFromVolume(data["trainingDataVolume"]) if "hardDataVolume" in data: SimulationHD = data["hardDataVolume"] - if data["trainingDataSegments"]: + if selectedSegmentsTI: mask = slicer.util.arrayFromVolume(data["trainingDataVolume"]) - filteredArray = np.where(np.isin(mask, data["trainingDataSegments"]), simulationTI, np.nan) + filteredArray = np.where( + np.isin(mask, selectedSegmentsTI, invert=invertTISelectedSegments), simulationTI, np.nan + ) self.createTrainingImagefile(filteredArray, self.temporaryPath) else: self.createTrainingImagefile(simulationTI, self.temporaryPath) @@ -1101,11 +1063,14 @@ def mps_preprocessing(self, data: dict, isContinuous: bool, sequentialProgressBa CONVERSION_FACTOR, ) else: + hardDataSelectedSegments = [nullValueHD] if invertHDSelectedSegments else data["hardDataValues"] + self.createHardDataFile( slicer.util.arrayFromVolume(SimulationHD), slicer.util.arrayFromVolume(data["hardDataVolume"]), np.around(np.array(data["hardDataResolution"]) * CONVERSION_FACTOR, 2), - data["hardDataValues"], + hardDataSelectedSegments, + invertHDSelectedSegments, ) if "maskVolume" in data and "maskSegments" in data: @@ -1154,6 +1119,8 @@ def run_parallel(self, run_data: dict, reference_volume, progressBar): if progressBar is not None: progressBar.setCommandLineModuleNode(self.cliNode) + self.widget.updateStatusLabel("Running") + def run_sequential(self, run_data: dict, reference_volume, progress_bar): """ Method responsible for configuring mps and running sequential algorithm @@ -1260,6 +1227,8 @@ def save_outputs(self, run_data, reference_volume): run_data["finalImageResolution"], run_data["nreal"], self.save_options["directory"], self.outputName ) + self.widget.updateTime(self.time) + self.cleanUp(self.temporaryPath) def runMultiscale( @@ -1339,6 +1308,8 @@ def onCliChangeEvent( return if caller.GetStatusString() == "Completed": + self.time = float(caller.GetParameterAsString("mpsTime")) + for realization in range(info["nreal"]): self.image.append(np.load(os.path.join(self.temporaryPath, f"sim_data_{realization}.npy"))) @@ -1371,13 +1342,14 @@ def cleanUp(self, temporaryPath, parallelFiles=None): os.remove("hard.dat") self.image = [] - self.time = [] + self.time = 0 self.save_options = {} self.mask_options = {} def cancelCLI(self): if self.cliNode: self.cliNode.Cancel() + self.widget.updateStatusLabel("Canceled") def createTrainingImagefile(self, array, temporaryPath, filename="ti.dat"): flipAxis = self.save_options["flipAxis"] @@ -1391,9 +1363,11 @@ def createTrainingImagefile(self, array, temporaryPath, filename="ti.dat"): f.write("\n".join([str(num) for num in flatList])) f.write("\n") - def createHardDataFile(self, hardDataValues, hardDataMask, hardDataResolution, selectedSegments): + def createHardDataFile( + self, hardDataValues, hardDataMask, hardDataResolution, selectedSegments, invertSelected=False + ): if selectedSegments: - indicesz, indicesy, indicesx = np.where(np.isin(hardDataMask, selectedSegments)) + indicesz, indicesy, indicesx = np.where(np.isin(hardDataMask, selectedSegments, invert=invertSelected)) else: indicesz, indicesy, indicesx = np.where(hardDataMask) @@ -1555,7 +1529,7 @@ def createOutputVolume(self, refVolume, spacing, realization, name, outputDir=No outputSpacing = np.around(np.array(spacing), 5) if not self.mask_options: - slicer.util.updateVolumeFromArray(labelmapNode, outputArray) + slicer.util.updateVolumeFromArray(labelmapNode, outputArray.astype(np.int32)) else: new_colors = {} @@ -1572,7 +1546,7 @@ def createOutputVolume(self, refVolume, spacing, realization, name, outputDir=No outputArray[slicer.util.arrayFromVolume(refVolume) == old_color_num] = new_color_num new_colors[new_color_num] = self.mask_options["maskSegmentList"][old_color_num] - slicer.util.updateVolumeFromArray(labelmapNode, outputArray) + slicer.util.updateVolumeFromArray(labelmapNode, outputArray.astype(np.int32)) if "maskColorNode" in self.mask_options: colorNode = self.mask_options["maskColorNode"] diff --git a/src/modules/MultiScale/MultiscaleCLI/MultiscaleCLI.py b/src/modules/MultiScale/MultiscaleCLI/MultiscaleCLI.py index 97c31d5..50e62f7 100644 --- a/src/modules/MultiScale/MultiscaleCLI/MultiscaleCLI.py +++ b/src/modules/MultiScale/MultiscaleCLI/MultiscaleCLI.py @@ -8,18 +8,11 @@ # These imports should go first to guarantee the transversing of wrapped classes by instantiation time # Refer to github.com/Slicer/Slicer/issues/6484 import vtk, slicer, slicer.util, mrml -import json -import logging -import numpy as np import os -import sys -from multiprocessing import cpu_count - -from pathlib import Path -from ltrace.slicer.cli_utils import writeDataInto, readFrom, progressUpdate +import json import mpslib as mps -from tifffile import tifffile +import numpy as np MRML_TYPES = { "vtkMRMLScalarVolumeNode": mrml.vtkMRMLScalarVolumeNode, @@ -27,16 +20,6 @@ } -def saveRealizationFiles(image, grid_cell_size, filePath): - tifffile.imwrite( - filePath, - np.flip(np.transpose(image), axis=0).astype("float32"), - imagej=True, - resolution=(1 / grid_cell_size[0], 1 / grid_cell_size[1]), - metadata={"spacing": grid_cell_size[2], "unit": "microns"}, - ) - - def MPS(args): temporaryPath = args.temporaryPath params = json.loads(args.params) if args.params is not None else {} @@ -63,6 +46,9 @@ def MPS(args): for realization in range(args.nreal): np.save(os.path.join(temporaryPath, f"sim_data_{realization}.npy"), mpslib.sim[realization]) + with open(args.returnparameterfile, "a") as returnFile: + returnFile.write("mpsTime=" + str(mpslib.time) + "\n") + if __name__ == "__main__": import argparse @@ -116,6 +102,14 @@ def MPS(args): required=True, help="Set if data is continuous or discrete.", ) + parser.add_argument( + "--mpsTime", + type=float, + dest="mpsTime", + default=0.0, + required=False, + help="Set if data is continuous or discrete.", + ) parser.add_argument( "--temporaryPath", diff --git a/src/modules/MultiScale/MultiscaleCLI/MultiscaleCLI.xml b/src/modules/MultiScale/MultiscaleCLI/MultiscaleCLI.xml index a1c45f4..6db8008 100644 --- a/src/modules/MultiScale/MultiscaleCLI/MultiscaleCLI.xml +++ b/src/modules/MultiScale/MultiscaleCLI/MultiscaleCLI.xml @@ -83,6 +83,13 @@ Set if data is continuous or discrete + + mpsTime + + output + 0.0 + Float value that will return mps execution time + diff --git a/src/modules/MultiScale/README.md b/src/modules/MultiScale/README.md index 5bdda57..644fa5d 100644 --- a/src/modules/MultiScale/README.md +++ b/src/modules/MultiScale/README.md @@ -1,6 +1,6 @@ # Multi Scale -This module provides _GeoSlicer_ an interface for the MPSLib Library, a set of algorithms based on a multiple point statistical (MPS) models inferred from a training image. +This module provides _GeoSlicer_ an interface for the MPSlib Library, a set of algorithms based on a multiple point statistical (MPS) models inferred from a training image. ## Methods Currently only the Generalized ENESIM algorithm with direct sampling mode (DS) is available diff --git a/src/modules/MultiScale/Resources/Icons/MultiScale.png b/src/modules/MultiScale/Resources/Icons/MultiScale.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/MultiScale/Resources/Icons/MultiScale.png and /dev/null differ diff --git a/src/modules/MultiScale/Resources/Icons/MultiScale.svg b/src/modules/MultiScale/Resources/Icons/MultiScale.svg new file mode 100644 index 0000000..b54825a --- /dev/null +++ b/src/modules/MultiScale/Resources/Icons/MultiScale.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MultiThresholdEffect/MultiThresholdEffect.py b/src/modules/MultiThresholdEffect/MultiThresholdEffect.py index 6c4a742..c4c4d1d 100644 --- a/src/modules/MultiThresholdEffect/MultiThresholdEffect.py +++ b/src/modules/MultiThresholdEffect/MultiThresholdEffect.py @@ -15,7 +15,7 @@ class MultiThresholdEffect(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Multiple Thresholds Effect" - self.parent.categories = ["Segmentation"] + self.parent.categories = ["Segmentation", "MicroCT"] self.parent.dependencies = ["Segmentations"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.hidden = True diff --git a/src/modules/MultiThresholdEffect/MultiThresholdEffectLib/SegmentEditorEffect.py b/src/modules/MultiThresholdEffect/MultiThresholdEffectLib/SegmentEditorEffect.py index 29a488b..a1d3cc5 100644 --- a/src/modules/MultiThresholdEffect/MultiThresholdEffectLib/SegmentEditorEffect.py +++ b/src/modules/MultiThresholdEffect/MultiThresholdEffectLib/SegmentEditorEffect.py @@ -11,7 +11,7 @@ from SegmentEditorEffects.SegmentEditorThresholdEffect import PreviewPipeline from ltrace.algorithms.common import randomChoice -from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUE +from ltrace.image.optimized_transforms import DEFAULT_NULL_VALUES from ltrace.slicer.helpers import getVolumeNullValue, getPythonQtWidget, hide_masking_widget from ltrace.slicer.ui import numberParamInt from ltrace.slicer.widget.customized_pyqtgraph.GraphicsLayoutWidget import GraphicsLayoutWidget @@ -38,7 +38,7 @@ def __init__(self, scriptedEffect): self.defaults = {} - self.nullValue = lambda: self.defaults.get("nullableValue", DEFAULT_NULL_VALUE) + self.nullValue = lambda: self.defaults.get("nullableValue", DEFAULT_NULL_VALUES) self.transitions = list() self._observerHandlers = list() self.segmentationNode = None @@ -259,7 +259,7 @@ def onBinsChanged(self, value): def createCursor(self, widget): # Turn off effect-specific cursor for this effect - return slicer.util.mainWindow().cursor + return slicer.modules.AppContextInstance.mainWindow.cursor def getParentLazyNode(self): if self.scriptedEffect.parameterSetNode() is None: diff --git a/src/modules/Multicore/Multicore.py b/src/modules/Multicore/Multicore.py index c255968..9b30ea0 100644 --- a/src/modules/Multicore/Multicore.py +++ b/src/modules/Multicore/Multicore.py @@ -20,6 +20,7 @@ from ltrace.slicer_utils import * from ltrace.transforms import transformPoints, getRoundedInteger from ltrace.units import global_unit_registry as ureg, SLICER_LENGTH_UNIT +from ltrace.utils.callback import Callback from scipy.ndimage import gaussian_filter from ltrace.utils.ProgressBarProc import ProgressBarProc @@ -47,7 +48,7 @@ class Multicore(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Multicore" - self.parent.categories = ["Core"] + self.parent.categories = ["Core", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysical Solutions"] self.parent.helpText = Multicore.help() @@ -535,11 +536,6 @@ def updateStatus(self, message, progress=None, processEvents=True): slicer.app.processEvents() -class Callback(object): - def __init__(self, on_update=None): - self.on_update = on_update or (lambda *args, **kwargs: None) - - class MulticoreLogic(LTracePluginLogic): HIDDEN_ATTRIBUTE_FLAG = "(hidden) " UNWRAP_OUTDATED_FLAG = " (outdated)" diff --git a/src/modules/Multicore/Resources/Icons/Multicore.png b/src/modules/Multicore/Resources/Icons/Multicore.png deleted file mode 100644 index 29cf1cd..0000000 Binary files a/src/modules/Multicore/Resources/Icons/Multicore.png and /dev/null differ diff --git a/src/modules/Multicore/Resources/Icons/Multicore.svg b/src/modules/Multicore/Resources/Icons/Multicore.svg new file mode 100644 index 0000000..0163a27 --- /dev/null +++ b/src/modules/Multicore/Resources/Icons/Multicore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MulticoreExport/MulticoreExport.py b/src/modules/MulticoreExport/MulticoreExport.py index d7d9b15..e7154f0 100644 --- a/src/modules/MulticoreExport/MulticoreExport.py +++ b/src/modules/MulticoreExport/MulticoreExport.py @@ -1,3 +1,4 @@ +import logging import os from pathlib import Path @@ -7,11 +8,9 @@ import qt import slicer import vtk -from Export import ExportLogic -from Export import checkUniqueNames - from MulticoreExportLib import MulticoreCSV -from ltrace.slicer.helpers import getNodeDataPath +from ltrace.slicer import export +from ltrace.slicer.helpers import getNodeDataPath, checkUniqueNames from ltrace.slicer_utils import LTracePlugin, LTracePluginLogic, LTracePluginWidget from ltrace.transforms import getRoundedInteger, transformPoints from ltrace.units import global_unit_registry as ureg @@ -33,7 +32,7 @@ class MulticoreExport(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Multicore Export" - self.parent.categories = ["Core"] + self.parent.categories = ["Core", "Multiscale"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = MulticoreExport.help() @@ -82,6 +81,7 @@ def setup(self): ) self.directorySelector = ctk.ctkDirectoryButton() + self.directorySelector.setMaximumWidth(374) self.directorySelector.caption = "Export directory" self.directorySelector.directory = MulticoreExport.get_setting( self.EXPORT_DIR, Path(slicer.mrmlScene.GetRootDirectory()).parent @@ -143,7 +143,7 @@ def _stopExport(self): def _updateNodesAndExportButton(self): items = vtk.vtkIdList() self.subjectHierarchyTreeView.currentItems(items) - self.nodes = ExportLogic().getDataNodes(items, self.EXPORTABLE_TYPES) + self.nodes = export.getDataNodes(items, self.EXPORTABLE_TYPES) format = self.formatComboBox.currentText @@ -184,16 +184,21 @@ def onExportClicked(self): self._startExport() - if format == MulticoreExport.FORMAT_TECHLOG_CSV or format == MulticoreExport.FORMAT_MATRIX_CSV: - self.exportCSV(format, outputDir, ignoreDirStructure) - elif format == MulticoreExport.FORMAT_SUMMARY: - self.exportSummary(outputDir, ignoreDirStructure) - elif format == MulticoreExport.FORMAT_TIF or format == MulticoreExport.FORMAT_PNG: - self.exportImages(format, outputDir, ignoreDirStructure) + processFailed = False + try: + if format == MulticoreExport.FORMAT_TECHLOG_CSV or format == MulticoreExport.FORMAT_MATRIX_CSV: + self.exportCSV(format, outputDir, ignoreDirStructure) + elif format == MulticoreExport.FORMAT_SUMMARY: + self.exportSummary(outputDir, ignoreDirStructure) + elif format == MulticoreExport.FORMAT_TIF or format == MulticoreExport.FORMAT_PNG: + self.exportImages(format, outputDir, ignoreDirStructure) + except Exception as error: + slicer.util.errorDisplay(f"Failed to export:\n{error}") + processFailed = True self._stopExport() self.progressBar.setValue(100) - self.currentStatusLabel.text = "Export completed." + self.currentStatusLabel.text = "Export completed." if not processFailed else "Export completed with errors." def exportImages(self, format, outputDir, ignoreDirStructure): for i, node in enumerate(self.nodes): @@ -259,6 +264,8 @@ class MultiCoreExportLogic(LTracePluginLogic): BASE_NAME = "Base name" WELL_DIAMETER = "Well diameter" + processFinished = qt.Signal() + def __init__(self, parent=None): super().__init__(parent) self.multicore_logic = slicer.modules.multicore.widgetRepresentation().self().logic diff --git a/src/modules/MulticoreExport/MulticoreExportLib/MulticoreCSV.py b/src/modules/MulticoreExport/MulticoreExportLib/MulticoreCSV.py index e8e0fc3..6c34bb2 100644 --- a/src/modules/MulticoreExport/MulticoreExportLib/MulticoreCSV.py +++ b/src/modules/MulticoreExport/MulticoreExportLib/MulticoreCSV.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Iterator, Union -from Export import ExportLogic +from ltrace.slicer import export def _units(node: slicer.vtkMRMLNode) -> str: @@ -68,7 +68,7 @@ def exportCSV(node: slicer.vtkMRMLNode, directory: Path, isTechlog: bool = False ) # Write color table - colorTable = ExportLogic().getLabelMapLabelsCSV(labelMap) + colorTable = export.getLabelMapLabelsCSV(labelMap) colorFilename = directory / f"{node.GetName()} Colors.csv" with open(colorFilename, mode="w", newline="") as csvFile: writer = csv.writer(csvFile, delimiter="\n") diff --git a/src/modules/MulticoreExport/Resources/Icons/MultiCoreExport.png b/src/modules/MulticoreExport/Resources/Icons/MultiCoreExport.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/MulticoreExport/Resources/Icons/MultiCoreExport.png and /dev/null differ diff --git a/src/modules/MulticoreExport/Resources/Icons/MulticoreExport.svg b/src/modules/MulticoreExport/Resources/Icons/MulticoreExport.svg new file mode 100644 index 0000000..b8b6d68 --- /dev/null +++ b/src/modules/MulticoreExport/Resources/Icons/MulticoreExport.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MulticoreTransforms/MulticoreTransforms.py b/src/modules/MulticoreTransforms/MulticoreTransforms.py index 593d770..d9abf97 100644 --- a/src/modules/MulticoreTransforms/MulticoreTransforms.py +++ b/src/modules/MulticoreTransforms/MulticoreTransforms.py @@ -6,7 +6,6 @@ import slicer from ltrace.slicer_utils import * from ltrace.units import global_unit_registry as ureg, SLICER_LENGTH_UNIT -from Multicore import MulticoreLogic class MulticoreTransforms(LTracePlugin): @@ -148,7 +147,7 @@ def exit(self): class MulticoreTransformsLogic(LTracePluginLogic): def __init__(self): LTracePluginLogic.__init__(self) - self.multicoreLogic = MulticoreLogic() + self.multicoreLogic = slicer.util.getModuleLogic("Multicore") def applyTransform(self, volumeNodeNames, depthIncrement, orientationIncrement): """ diff --git a/src/modules/MulticoreTransforms/Resources/Icons/MulticoreTransforms.png b/src/modules/MulticoreTransforms/Resources/Icons/MulticoreTransforms.png deleted file mode 100644 index b103164..0000000 Binary files a/src/modules/MulticoreTransforms/Resources/Icons/MulticoreTransforms.png and /dev/null differ diff --git a/src/modules/MulticoreTransforms/Resources/Icons/MulticoreTransforms.svg b/src/modules/MulticoreTransforms/Resources/Icons/MulticoreTransforms.svg new file mode 100644 index 0000000..3648c98 --- /dev/null +++ b/src/modules/MulticoreTransforms/Resources/Icons/MulticoreTransforms.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MultipleImageAnalysis/AnalysisTypes/histogram_in_depth_analysis.py b/src/modules/MultipleImageAnalysis/AnalysisTypes/histogram_in_depth_analysis.py index 9b6e8e7..b837e7f 100644 --- a/src/modules/MultipleImageAnalysis/AnalysisTypes/histogram_in_depth_analysis.py +++ b/src/modules/MultipleImageAnalysis/AnalysisTypes/histogram_in_depth_analysis.py @@ -1,20 +1,18 @@ -import ctk -import qt -import slicer - import logging -import numpy as np import os +from typing import Dict, List + +import ctk +import numpy as np import pandas as pd +import qt -from .analysis_base import AnalysisBase, AnalysisReport, AnalysisWidgetBase, FILE_NOT_FOUND +from ltrace.slicer.node_attributes import TableDataOrientation, TableType from ltrace.slicer.segment_inspector.inspector_files.inspector_file_reader import InspectorFileReader from ltrace.slicer.segment_inspector.inspector_files.inspector_report_file import InspectorReportFile from ltrace.slicer.segment_inspector.inspector_files.inspector_variables_file import InspectorVariablesFile -from ltrace.slicer.node_attributes import TableDataOrientation, TableType from ltrace.slicer.ui import numberParamInt -from ltrace.utils.ProgressBarProc import ProgressBarProc -from typing import Dict, List +from .analysis_base import AnalysisBase, AnalysisReport, AnalysisWidgetBase, FILE_NOT_FOUND class HistogramInDepthAnalysisWidget(AnalysisWidgetBase): @@ -198,99 +196,99 @@ def run(self, filesDir: str, outputName: str) -> AnalysisReport: if poresNumber <= 0: raise Exception("No data found in the selected directory") - with ProgressBarProc() as progressBar: - progressBar.setTitle("Histogram in Depth Analysis") - progressStep = 99 // (poresNumber * 3) # 3 for loops - currentProgress = 0 - progressBar.nextStep(0, "Starting...") - - # Filter valid depths - for pore in list(poresDataDict.keys()): - if poresDataDict[pore]["Report"] is None: - poresDataDict.pop(pore) - - currentProgress += progressStep - progressBar.nextStep(currentProgress, f"Filtering valid data for {pore}...") - - # check min / max value from sample data - minSampleValue = np.inf - maxSampleValue = -np.inf - for pore in poresDataDict.keys(): - currentProgress += progressStep - progressBar.nextStep(currentProgress, f"Checking minimum/maximum values for {pore}...") - - reportData = poresDataDict[pore]["Report"].data - sampleData = reportData.loc[:, sampleColumnLabel] - currentMin = np.amin(sampleData) - currentMax = np.amax(sampleData) - - minSampleValue = min(minSampleValue, currentMin) - maxSampleValue = max(maxSampleValue, currentMax) - - limitBins = nBins - if sampleColumnLabel == "pore_size_class": - firestPoreSizeClass = 0 - lasPoreSizeClass = 7 - limitBins = np.linspace( - start=firestPoreSizeClass, - stop=lasPoreSizeClass + 1, - num=lasPoreSizeClass + 1 - firestPoreSizeClass + 1, - ) - elif minSampleValue != 0: - limitBins = np.zeros(nBins + 1) - for i in range(0, len(limitBins)): - limitBins[i] = minSampleValue * np.power(np.power(maxSampleValue / minSampleValue, 1 / (nBins)), i) - - data = dict() - for pore in poresDataDict.keys(): - currentProgress += progressStep - progressBar.nextStep(currentProgress, f"Generating histogram for {pore}...") - - reportData = poresDataDict[pore]["Report"].data - variablesFile = poresDataDict[pore]["Variables"] - variablesData = variablesFile.data if variablesFile else None - weights = None - if weightColumnLabel != "None" and weightColumnLabel in list(reportData.columns): - weights = reportData.loc[:, weightColumnLabel] - - yValues, x = np.histogram(reportData.loc[:, sampleColumnLabel], bins=limitBins, weights=weights) - if data.get("X") is None: - if sampleColumnLabel == "pore_size_class": - data["X"] = x[:-1] - else: - xValues = [np.sqrt(x[i + 1] * x[i]) for i in range(0, nBins)] - data["X"] = xValues - - if ( - normalizationEnabled - and normalizationLabel != "" - and normalizationLabel != self.configWidget.NORMALIZATION_BY_SUM_LABEL - and variablesData is not None - and normalizationLabel in list(variablesData["Properties"]) - ): - normalizationFactor = float( - variablesData.loc[variablesData["Properties"] == normalizationLabel, "Values"].iloc[0] - ) - if normalizationFactor > 0: - yValues = yValues / normalizationFactor - elif normalizeCheckBox: - yValues = yValues / yValues.sum() + # with ProgressBarProc() as progressBar: + # progressBar.setTitle("Histogram in Depth Analysis") + progressStep = 99 // (poresNumber * 3) # 3 for loops + currentProgress = 0 + # progressBar.nextStep(0, "Starting...") + + # Filter valid depths + for pore in list(poresDataDict.keys()): + if poresDataDict[pore]["Report"] is None: + poresDataDict.pop(pore) + + currentProgress += progressStep + # progressBar.nextStep(currentProgress, f"Filtering valid data for {pore}...") + + # check min / max value from sample data + minSampleValue = np.inf + maxSampleValue = -np.inf + for pore in poresDataDict.keys(): + currentProgress += progressStep + # progressBar.nextStep(currentProgress, f"Checking minimum/maximum values for {pore}...") + + reportData = poresDataDict[pore]["Report"].data + sampleData = reportData.loc[:, sampleColumnLabel] + currentMin = np.amin(sampleData) + currentMax = np.amax(sampleData) + + minSampleValue = min(minSampleValue, currentMin) + maxSampleValue = max(maxSampleValue, currentMax) + + limitBins = nBins + if sampleColumnLabel == "pore_size_class": + firestPoreSizeClass = 0 + lasPoreSizeClass = 7 + limitBins = np.linspace( + start=firestPoreSizeClass, + stop=lasPoreSizeClass + 1, + num=lasPoreSizeClass + 1 - firestPoreSizeClass + 1, + ) + elif minSampleValue != 0: + limitBins = np.zeros(nBins + 1) + for i in range(0, len(limitBins)): + limitBins[i] = minSampleValue * np.power(np.power(maxSampleValue / minSampleValue, 1 / (nBins)), i) + + data = dict() + for pore in poresDataDict.keys(): + currentProgress += progressStep + # progressBar.nextStep(currentProgress, f"Generating histogram for {pore}...") + + reportData = poresDataDict[pore]["Report"].data + variablesFile = poresDataDict[pore]["Variables"] + variablesData = variablesFile.data if variablesFile else None + weights = None + if weightColumnLabel != "None" and weightColumnLabel in list(reportData.columns): + weights = reportData.loc[:, weightColumnLabel] + + yValues, x = np.histogram(reportData.loc[:, sampleColumnLabel], bins=limitBins, weights=weights) + if data.get("X") is None: + if sampleColumnLabel == "pore_size_class": + data["X"] = x[:-1] else: - mx = np.nanmax(yValues) / histogramHeight - if np.isscalar(mx): - yValues = np.true_divide(yValues, mx) - - data[str(pore)] = yValues - - # Create AnalysisReport from data - config = { - TableDataOrientation.name(): TableDataOrientation.ROW.value, - TableType.name(): TableType.HISTOGRAM_IN_DEPTH.value, - } - report = AnalysisReport(name=outputName, data=data, config=config) - progressBar.nextStep(100, "Done!") - - return report + xValues = [np.sqrt(x[i + 1] * x[i]) for i in range(0, nBins)] + data["X"] = xValues + + if ( + normalizationEnabled + and normalizationLabel != "" + and normalizationLabel != self.configWidget.NORMALIZATION_BY_SUM_LABEL + and variablesData is not None + and normalizationLabel in list(variablesData["Properties"]) + ): + normalizationFactor = float( + variablesData.loc[variablesData["Properties"] == normalizationLabel, "Values"].iloc[0] + ) + if normalizationFactor > 0: + yValues = yValues / normalizationFactor + elif normalizeCheckBox: + yValues = yValues / yValues.sum() + else: + mx = np.nanmax(yValues) / histogramHeight + if np.isscalar(mx): + yValues = np.true_divide(yValues, mx) + + data[str(pore)] = yValues + + # Create AnalysisReport from data + config = { + TableDataOrientation.name(): TableDataOrientation.ROW.value, + TableType.name(): TableType.HISTOGRAM_IN_DEPTH.value, + } + report = AnalysisReport(name=outputName, data=data, config=config) + # progressBar.nextStep(100, "Done!") + + return report def getSuggestedOutputName(self, filesDir: str) -> str: projectName = os.path.basename(filesDir) diff --git a/src/modules/MultipleImageAnalysis/MultipleImageAnalysis.py b/src/modules/MultipleImageAnalysis/MultipleImageAnalysis.py index df9e427..562958b 100644 --- a/src/modules/MultipleImageAnalysis/MultipleImageAnalysis.py +++ b/src/modules/MultipleImageAnalysis/MultipleImageAnalysis.py @@ -32,12 +32,11 @@ class MultipleImageAnalysis(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Multiple Image Analysis" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Multi-Image Analysis" + self.parent.categories = ["Tools", "Thin Section"] self.parent.dependencies = [] self.parent.contributors = ["LTrace Geophysics Team"] # replace with "Firstname Lastname (Organization)" - self.parent.helpText = MultipleImageAnalysis.help() - self.parent.helpText += self.getDefaultModuleDocumentationLink() + self.parent.helpText = f"file:///{Path(helpers.get_scripted_modules_path() + '/Resources/manual/Thin%20Section/Modulos/MultipleImageAnalysis.html').as_posix()}" @classmethod def readme_path(cls): @@ -252,7 +251,7 @@ def _onDirectoryInputChanged(self, folder: str) -> None: if folder == "" or not os.path.isdir(folder): message = "The selected path is invalid. Please select a directory." logging.warning(message) - qt.QMessageBox.information(slicer.util.mainWindow(), "Error", message) + qt.QMessageBox.information(slicer.modules.AppContextInstance.mainWindow, "Error", message) return self._refreshInputReportFiles(folder) @@ -269,7 +268,7 @@ def _refreshInputReportFiles(self, folder: str) -> None: if analysisModel is None: message = "The selected analysis' type is invalid. Please, contact the technical support." logging.warning(message) - qt.QMessageBox.information(slicer.util.mainWindow(), "Error", message) + qt.QMessageBox.information(slicer.modules.AppContextInstance.mainWindow, "Error", message) return try: @@ -277,7 +276,7 @@ def _refreshInputReportFiles(self, folder: str) -> None: self._onOutputNameChangedSignal(analysisModel) except RuntimeError as error: logging.warning(error) - qt.QMessageBox.information(slicer.util.mainWindow(), "Error", error) + qt.QMessageBox.information(slicer.modules.AppContextInstance.mainWindow, "Error", error) else: self._updateTable(tableData) @@ -408,6 +407,7 @@ def __init__(self, parent=None) -> None: self.__generatedReportNodeId = None self.data = None self.__errorDetected = False + self.__imageLogDataLogic = slicer.util.getModuleLogic("ImageLogData") @property def generating(self) -> bool: @@ -460,10 +460,9 @@ def _run(self, tableNode: slicer.vtkMRMLTableNode = None, displayImageLogView: b # Show node in image log view if displayImageLogView: - slicer.modules.ImageLogDataWidget.logic.changeToLayout() - subjectItemId = slicer.mrmlScene.GetSubjectHierarchyNode().GetItemByDataNode(node) + self.__imageLogDataLogic.changeToLayout() try: - slicer.modules.ImageLogDataWidget.logic.addView(subjectItemId) + self.__imageLogDataLogic.addView(node) except Exception as error: slicer.util.warningDisplay( f"The maximum number of views has been reached, preventing the current preview from being displayed.\nTo view the current analysis, please select the analysis node in an already open view." @@ -501,7 +500,7 @@ def _exportReportTableNode( tableNode.SetUseFirstColumnAsRowHeader(True) df = pd.DataFrame.from_dict(report.data, orient="index") - df.sort_index(ascending=True, inplace=True) + df = df.sort_index(ascending=True) df = df.round(decimals=5) # Workaround to create a row header @@ -517,7 +516,7 @@ def _exportReportTableNode( # Rename first table's cell random name else: df = pd.DataFrame.from_dict(report.data) - df.sort_values(by=df.columns[0], ascending=True, inplace=True) + df = df.sort_values(by=df.columns[0], ascending=True) df = df.round(decimals=5) dataFrameToTableNode(dataFrame=df, tableNode=tableNode) @@ -566,7 +565,7 @@ def _clear(self) -> None: self.__generating = False if self.__generatedReportNodeId is not None: try: - slicer.modules.ImageLogDataWidget.logic.removeViewFromPrimaryNode(self.__generatedReportNodeId) + self.__imageLogDataLogic.removeViewFromPrimaryNode(self.__generatedReportNodeId) except Exception as error: logging.error(error) diff --git a/src/modules/MultipleImageAnalysis/Resources/Icons/MultipleImageAnalysis.png b/src/modules/MultipleImageAnalysis/Resources/Icons/MultipleImageAnalysis.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/MultipleImageAnalysis/Resources/Icons/MultipleImageAnalysis.png and /dev/null differ diff --git a/src/modules/MultipleImageAnalysis/Resources/Icons/MultipleImageAnalysis.svg b/src/modules/MultipleImageAnalysis/Resources/Icons/MultipleImageAnalysis.svg new file mode 100644 index 0000000..4c996cc --- /dev/null +++ b/src/modules/MultipleImageAnalysis/Resources/Icons/MultipleImageAnalysis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MultipleThresholdBigImage/MultipleThresholdBigImage.py b/src/modules/MultipleThresholdBigImage/MultipleThresholdBigImage.py index 627b168..50cfc89 100644 --- a/src/modules/MultipleThresholdBigImage/MultipleThresholdBigImage.py +++ b/src/modules/MultipleThresholdBigImage/MultipleThresholdBigImage.py @@ -4,6 +4,7 @@ import slicer import json +from ltrace.slicer.app import getApplicationVersion from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic from ltrace.slicer.widget.global_progress_bar import LocalProgressBar from ltrace.slicer import helpers @@ -18,8 +19,8 @@ class MultipleThresholdBigImage(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Multiple Threshold Big Image" - self.parent.categories = ["LTrace Tools"] + self.parent.title = "Multiple Threshold for Big Image" + self.parent.categories = ["Tools", "MicroCT"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = MultipleThresholdBigImage.help() @@ -42,7 +43,7 @@ def setup(self): nodeTypes=["vtkMRMLTextNode"], tooltip="Select the image within the NetCDF dataset to preview.", ) - self.volumeSelector.addNodeAttributeFilter("LazyNode", "1") + self.volumeSelector.selectorWidget.addNodeAttributeFilter("LazyNode", "1") self.exportPathEdit = ctk.ctkPathLineEdit() self.exportPathEdit.filters = ctk.ctkPathLineEdit.Files | ctk.ctkPathLineEdit.Writable @@ -97,7 +98,7 @@ def apply(self, lazyData, outputPath, progress_bar=None): "threshs": self.thresholds, "colors": self.colors, "names": self.names, - "geoslicer_version": slicer.app.applicationVersion, + "geoslicerVersion": getApplicationVersion(), "lazyDataNodeHost": lazyDataNodeHost.to_dict(), } cli_config = { diff --git a/src/modules/MultipleThresholdBigImage/MultipleThresholdBigImageCLI/MultipleThresholdBigImageCLI.py b/src/modules/MultipleThresholdBigImage/MultipleThresholdBigImageCLI/MultipleThresholdBigImageCLI.py index 8739b08..47ce9dc 100644 --- a/src/modules/MultipleThresholdBigImage/MultipleThresholdBigImageCLI/MultipleThresholdBigImageCLI.py +++ b/src/modules/MultipleThresholdBigImage/MultipleThresholdBigImageCLI/MultipleThresholdBigImageCLI.py @@ -67,6 +67,6 @@ def multithresh(lazy_data, threshs, colors, names, url, version, hostData): params["colors"], params["names"], params["output_url"], - params["geoslicer_version"], + params["geoslicerVersion"], hostData, ) diff --git a/src/modules/MultiscaleEnv/MultiscaleEnv.py b/src/modules/MultiscaleEnv/MultiscaleEnv.py new file mode 100644 index 0000000..7510f71 --- /dev/null +++ b/src/modules/MultiscaleEnv/MultiscaleEnv.py @@ -0,0 +1,169 @@ +import os +from pathlib import Path + +import slicer +import qt + +from ltrace.slicer.helpers import svgToQIcon +from ltrace.slicer.widget.custom_toolbar_buttons import addAction, addMenu +from ltrace.slicer_utils import LTracePlugin, LTracePluginLogic, getResourcePath, LTraceEnvironmentMixin +from ltrace.constants import ImageLogConst + + +class MultiscaleEnv(LTracePlugin): + SETTING_KEY = "MultiscaleEnv" + MODULE_DIR = Path(os.path.dirname(os.path.realpath(__file__))) + RES_DIR = MODULE_DIR / "Resources" + + def __init__(self, parent): + LTracePlugin.__init__(self, parent) + self.parent.title = "Multiscale Environment" + self.parent.categories = ["Environment", "Multiscale"] + self.parent.contributors = ["LTrace Geophysics Team"] + + self.environment = MultiscaleEnvLogic() + + @classmethod + def readme_path(cls): + return str(cls.MODULE_DIR / "README.md") + + +class MultiscaleEnvLogic(LTracePluginLogic, LTraceEnvironmentMixin): + def __init__(self): + super().__init__() + self.__modulesToolbar = None + + def setupEnvironment(self): + relatedModules = self.getModuleManager().fetchByCategory([self.category]) + + modules = [ + "CustomizedData", + "ImageLogData", + "GeologEnv", + # imports + ("Import Tools", ["ImageLogImport", "ImageLogUnwrapImport", "MicroCTLoader", "Multicore"]), + # exports + ( + "Export Tools", + [ + "ImageLogExport", + "MicroCTExport", + "MulticoreExport", + ], + ), + # ImageLog Modules + ("Image Log Pre-Processing", ["ImageLogCropVolume", "AzimuthShiftTool", "SpiralFilter", "ImageLogInpaint"]), + # Volumes Modules + ( + "Volumes Pre-Processing", + ["CustomizedCropVolume", "CustomResampleScalarVolume", "FilteringTools"], + ), + "MicrotomRemote", + "MultiScale", + "MultiscalePostProcessing", + "PoreNetworkSimulation", + ] + + for module in modules: + if isinstance(module, str): + addAction(relatedModules[module], self.modulesToolbar) + elif isinstance(module, tuple): + name, modules = module + iconName = name.replace(" ", "").replace("-", "") + addMenu( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / f"{iconName}.svg"), + name, + [relatedModules[m] for m in modules], + self.modulesToolbar, + ) + elif callable(module): + module() + + self.setupSegmentation("MicroCT") + self.setupSegmentation("ImageLog") + + self.modulesToolbar.actions()[1].setVisible(False) + self.modulesToolbar.actions()[11].setVisible(False) + + self.setupTools() + + self.addImageLogViewOption() + + self.getModuleManager().setEnvironment(("Multiscale", "MultiscaleEnv")) + + self.modulesToolbar.setIconSize(qt.QSize(24, 30)) + + def enter(self) -> None: + slicer.app.layoutManager().layoutChanged.connect(self.switchViewDataModule) + + def setupSegmentation(self, category: str) -> None: + modules = self.getModuleManager().fetchByCategory((category,), intersectWith="Segmentation") + + if category == "MicroCT": + name = "Volume" + segmentationModules = [ + modules["CustomizedSegmentEditor"], + modules["Segmenter"], + modules["SegmentInspector"], + ] + else: + name = "Image Log" + segmentationModules = [ + modules["ImageLogSegmentEditor"], + modules["ImageLogInstanceSegmenter"], + modules["InstanceSegmenterEditor"], + ] + + addMenu( + svgToQIcon(getResourcePath("Icons") / "IconSet-dark" / "Layers.svg"), + f"{name} Segmentation", + segmentationModules, + self.modulesToolbar, + ) + + def switchViewDataModule(self, layoutID: int): + if self.getModuleManager().currentWorkingDataType[0] == "Multiscale": + isImageLogView = layoutID > ImageLogConst.DEFAULT_LAYOUT_ID_START_VALUE + self.modulesToolbar.actions()[0].setVisible(not isImageLogView) + self.modulesToolbar.actions()[11].setVisible(not isImageLogView) + self.modulesToolbar.actions()[1].setVisible(isImageLogView) + self.modulesToolbar.actions()[12].setVisible(isImageLogView) + + if slicer.util.selectedModule() in ["CustomizedData", "ImageLogData"]: + slicer.util.selectModule("ImageLogData" if isImageLogView else "CustomizedData") + elif isImageLogView and slicer.util.selectedModule() in [ + "CustomizedSegmentEditor", + "Segmenter", + "SegmentInspector", + ]: + slicer.util.selectModule("ImageLogSegmentEditor") + elif not isImageLogView and slicer.util.selectedModule() in [ + "ImageLogSegmentEditor", + "ImageLogInstanceSegmenter", + "InstanceSegmenterEditor", + ]: + slicer.util.selectModule("CustomizedSegmentEditor") + + def addImageLogViewOption(self) -> None: + self.viewToolBar = slicer.util.mainWindow().findChild("QToolBar", "ViewToolBar") + layoutMenu = self.viewToolBar.widgetForAction(self.viewToolBar.actions()[0]).menu() + + imageLogActionText = "ImageLog View" + imageLogActionInMenu = imageLogActionText in [action.text for action in layoutMenu.actions()] + + if not imageLogActionInMenu: + self.imageLogLayoutViewAction = qt.QAction(imageLogActionText) + self.imageLogLayoutViewAction.setIcon(qt.QIcon(getResourcePath("Icons") / "ImageLog.png")) + self.imageLogLayoutViewAction.triggered.connect(self.__onImagelogLayoutViewActionClicked) + + after3DOnlyActionIndex = next( + (i for i, action in enumerate(layoutMenu.actions()) if action.text == "3D only"), None + ) + layoutMenu.insertAction( + layoutMenu.actions()[after3DOnlyActionIndex + 1], self.imageLogLayoutViewAction + ) # insert new action before reference + + def __onImagelogLayoutViewActionClicked(self) -> None: + if self.getModuleManager().currentWorkingDataType[0] in ["ImageLog", "Multiscale"]: + slicer.util.getModuleLogic("ImageLogData").changeToLayout() + self.imageLogLayoutViewAction.setData(slicer.modules.AppContextInstance.imageLogLayoutId) diff --git a/src/modules/MultiscaleEnv/README.md b/src/modules/MultiscaleEnv/README.md new file mode 100644 index 0000000..073da08 --- /dev/null +++ b/src/modules/MultiscaleEnv/README.md @@ -0,0 +1 @@ +# Multiscale Env diff --git a/src/modules/AzimuthShiftTool/Resources/Icons/AzimuthShiftTool.png b/src/modules/MultiscaleEnv/Resources/Icons/MultiscaleEnv.png similarity index 100% rename from src/modules/AzimuthShiftTool/Resources/Icons/AzimuthShiftTool.png rename to src/modules/MultiscaleEnv/Resources/Icons/MultiscaleEnv.png diff --git a/src/modules/MultiscalePostProcessing/MultiscalePostProcessing.py b/src/modules/MultiscalePostProcessing/MultiscalePostProcessing.py index 22081f8..4456c63 100644 --- a/src/modules/MultiscalePostProcessing/MultiscalePostProcessing.py +++ b/src/modules/MultiscalePostProcessing/MultiscalePostProcessing.py @@ -9,6 +9,7 @@ from ltrace.slicer import ui, helpers, widgets from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, LTracePluginLogic, dataFrameToTableNode from ltrace.utils.ProgressBarProc import ProgressBarProc +from ltrace.slicer.node_attributes import ImageLogDataSelectable, TableType try: from Test.MultiscalePostProcessingTest import MultiscalePostProcessingTest @@ -24,8 +25,8 @@ class MultiscalePostProcessing(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) - self.parent.title = "Multiscale Post-processing" - self.parent.categories = ["Micro CT"] + self.parent.title = "Multi-Scale Post-Processing" + self.parent.categories = ["MicroCT", "Multiscale"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = MultiscalePostProcessing.help() @@ -71,6 +72,8 @@ def setup(self): onChange=self.onRealizationNodeChange, nodeTypes=[ "vtkMRMLScalarVolumeNode", + "vtkMRMLLabelMapVolumeNode", + "vtkMRMLSegmentationNode", ], hasNone=True, ) @@ -83,6 +86,8 @@ def setup(self): onChange=self.onTrainingImageChange, nodeTypes=[ "vtkMRMLScalarVolumeNode", + "vtkMRMLLabelMapVolumeNode", + "vtkMRMLSegmentationNode", ], hasNone=True, ) @@ -139,7 +144,10 @@ def setup(self): self.porosityValueSpinBox.setToolTip("Set the value of the segment classified as pore in the image.") self.singleShotWidget = widgets.SingleShotInputWidget( - hideImage=True, hideSoi=True, hideCalcProp=False, allowedInputNodes=["vtkMRMLLabelMapVolumeNode"] + hideImage=True, + hideSoi=True, + hideCalcProp=False, + allowedInputNodes=["vtkMRMLLabelMapVolumeNode", "vtkMRMLSegmentationNode"], ) self.singleShotWidget.segmentListGroup[1].itemChanged.connect(self.checkRunButtonState) @@ -185,15 +193,24 @@ def onApplyClicked(self): self.runFrequencyLogic() def runPorosityLogic(self): - node = self.realizationNodeComboBox.currentNode() + mainNode = self.realizationNodeComboBox.currentNode() + TINode = ( + self.trainingImageComboBox.currentNode() if self.trainingImageComboBox.currentNode() is not None else None + ) + + if mainNode is not None and isinstance(mainNode, slicer.vtkMRMLSegmentationNode): + mainNode, _ = helpers.createLabelmapInput(mainNode, "temporary_Main") + + if TINode is not None and isinstance(TINode, slicer.vtkMRMLSegmentationNode): + TINode, _ = helpers.createLabelmapInput(TINode, "temporary_TI") self.logic.generatePorosityPerRealization( - node, + mainNode, np.array(self.singleShotWidget.getSelectedSegments()) + 1 if self.isSegment else [self.porosityValueSpinBox.value], self.outputPrefix.text, - self.trainingImageComboBox.currentNode() if self.trainingImageComboBox.currentNode() is not None else None, + TINode, ) def runFrequencyLogic(self): @@ -225,12 +242,12 @@ def checkRunButtonState(self) -> None: def onRealizationNodeChange(self, itemId): node = self.subjectHierarchyNode.GetItemDataNode(itemId) if node: - if isinstance(node, slicer.vtkMRMLLabelMapVolumeNode): - self.singleShotWidget.mainInput.setCurrentNode(node) - self.changePoreValueSelector(True) - else: + if type(node) is slicer.vtkMRMLScalarVolumeNode: self.changePoreValueSelector(False) self.singleShotWidget.mainInput.setCurrentNode(None) + else: + self.singleShotWidget.mainInput.setCurrentNode(node) + self.changePoreValueSelector(True) self.outputPrefix.text = "Porosity_per_realization_table" else: @@ -406,6 +423,9 @@ def generatePorosityPerRealization(self, input_node, poreValues, outputPrefix, t result = dataFrameToTableNode(df) result.SetName(slicer.mrmlScene.GenerateUniqueName(outputPrefix)) - result.SetAttribute("table_type", "porosity_per_realization") + result.SetAttribute(TableType.name(), TableType.POROSITY_PER_REALIZATION.value) + result.SetAttribute(ImageLogDataSelectable.name(), ImageLogDataSelectable.TRUE.value) self.__AddNodeToHierarchy(result, METHODS["Porosity"]) + + helpers.removeTemporaryNodes() diff --git a/src/modules/MultiscalePostProcessing/Resources/Icons/MultiScalePostProcessing.svg b/src/modules/MultiscalePostProcessing/Resources/Icons/MultiScalePostProcessing.svg new file mode 100644 index 0000000..b994011 --- /dev/null +++ b/src/modules/MultiscalePostProcessing/Resources/Icons/MultiScalePostProcessing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/MultiscalePostProcessing/Resources/Icons/MultiscalePostProcessing.png b/src/modules/MultiscalePostProcessing/Resources/Icons/MultiscalePostProcessing.png deleted file mode 100644 index b89476c..0000000 Binary files a/src/modules/MultiscalePostProcessing/Resources/Icons/MultiscalePostProcessing.png and /dev/null differ diff --git a/src/modules/NetCDF/NetCDF.py b/src/modules/NetCDF/NetCDF.py index 0169e13..5e44784 100644 --- a/src/modules/NetCDF/NetCDF.py +++ b/src/modules/NetCDF/NetCDF.py @@ -19,7 +19,7 @@ class NetCDF(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "NetCDF" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "MicroCT"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = NetCDF.help() @@ -39,12 +39,12 @@ def setup(self): self.export_module = slicer.modules.netcdfexport.createNewWidgetRepresentation() self.save_module = SaveNetcdfWidget() - main_tab = qt.QTabWidget() - main_tab.addTab(self.import_module, "Import") - main_tab.addTab(self.save_module, "Save") - main_tab.addTab(self.export_module, "Export") + self.main_tab = qt.QTabWidget() + self.main_tab.addTab(self.import_module, "Import") + self.main_tab.addTab(self.save_module, "Save") + self.main_tab.addTab(self.export_module, "Export") - self.layout.addWidget(main_tab) + self.layout.addWidget(self.main_tab) self.layout.addStretch(1) diff --git a/src/modules/NetCDF/Resources/Icons/NetCDF.svg b/src/modules/NetCDF/Resources/Icons/NetCDF.svg new file mode 100644 index 0000000..a458b4f --- /dev/null +++ b/src/modules/NetCDF/Resources/Icons/NetCDF.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/NetCDFExport/NetCDFExport.py b/src/modules/NetCDFExport/NetCDFExport.py index dfe3079..e857a93 100644 --- a/src/modules/NetCDFExport/NetCDFExport.py +++ b/src/modules/NetCDFExport/NetCDFExport.py @@ -4,22 +4,25 @@ import slicer import vtk import ctk -import Export import xarray as xr import numpy as np + +from dataclasses import dataclass +from ltrace.slicer.app import getApplicationVersion from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget -from ltrace.slicer import ui +from ltrace.slicer import ui, export from pathlib import Path -from scipy import ndimage from ltrace.slicer.helpers import ( createTemporaryVolumeNode, removeTemporaryNodes, getSourceVolume, save_path, safe_convert_array, + checkUniqueNames, ) -from dataclasses import dataclass -from typing import Tuple, List +from ltrace.slicer import netcdf +from ltrace.utils.callback import Callback +from typing import List, Tuple class NetCDFExport(LTracePlugin): @@ -29,7 +32,7 @@ class NetCDFExport(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "NetCDF Export" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "MicroCT"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = NetCDFExport.help() @@ -129,7 +132,7 @@ def setup(self): def getItemsToExport(self): selected_items = vtk.vtkIdList() self.subjectHierarchyTreeView.currentItems(selected_items) - return Export.ExportLogic().getDataNodes(selected_items, self.EXPORTABLE_TYPES) + return export.getDataNodes(selected_items, self.EXPORTABLE_TYPES) def onSelectionChanged(self): selected_items = self.getItemsToExport() @@ -153,7 +156,7 @@ def onSelectionChanged(self): self.netcdfReferenceNodeBox.setCurrentNode(ref_node) def onExportNetcdfButtonClicked(self): - callback = Export.Callback(on_update=lambda message, percent: self.updateStatus(message, progress=percent)) + callback = Callback(on_update=lambda message, percent: self.updateStatus(message, progress=percent)) try: exportPath = self.exportPathEdit.currentPath save_path(self.exportPathEdit) @@ -164,16 +167,13 @@ def onExportNetcdfButtonClicked(self): useCompression = self.compressionCheckBox.checked singleCoords = self.singleCoordsCheckBox.checked - warnings = exportNetcdf(exportPath, dataNodes, referenceItem, singleCoords, useCompression, callback) + warnings = netcdf.exportNetcdf(exportPath, dataNodes, referenceItem, singleCoords, useCompression, callback) callback.on_update("", 100) if warnings: slicer.util.warningDisplay("\n".join(warnings), windowTitle="NetCDF export warnings") else: slicer.util.infoDisplay("Export completed.") - except Export.ExportInfo as e: - slicer.util.infoDisplay(str(e)) - raise except Exception as e: slicer.util.errorDisplay(str(e)) raise @@ -225,7 +225,7 @@ def _node_to_data_array( raise ValueError(f"Unsupported node type: {type(node)}") if isinstance(node, slicer.vtkMRMLLabelMapVolumeNode): - attrs["labels"] = ["Name,Index,Color"] + Export.ExportLogic.getLabelMapLabelsCSV(node, withColor=True) + attrs["labels"] = ["Name,Index,Color"] + export.getLabelMapLabelsCSV(node, withColor=True) array = slicer.util.arrayFromVolume(node) if dtype: @@ -335,7 +335,7 @@ def exportNetcdf( raise ValueError("No images selected.") if callback is None: - callback = Export.Callback(on_update=lambda *args, **kwargs: None) + callback = Callback(on_update=lambda *args, **kwargs: None) if single_coords and not save_in_place: if not referenceItem: @@ -347,7 +347,7 @@ def exportNetcdf( callback.on_update("Starting…", 0) if not nodeNames: - Export.checkUniqueNames(dataNodes) + checkUniqueNames(dataNodes) arrays = {} coords = {} diff --git a/src/modules/NetCDFLoader/NetCDFLoader.py b/src/modules/NetCDFLoader/NetCDFLoader.py index d3a9c8b..134537c 100644 --- a/src/modules/NetCDFLoader/NetCDFLoader.py +++ b/src/modules/NetCDFLoader/NetCDFLoader.py @@ -18,7 +18,7 @@ class NetCDFLoader(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "NetCDF Loader" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "MicroCT"] self.parent.contributors = ["LTrace Geophysics Team"] self.parent.helpText = NetCDFLoader.help() diff --git a/src/modules/OpenRockData/OpenRockData.py b/src/modules/OpenRockData/OpenRockData.py index f94982e..e3c8f1c 100644 --- a/src/modules/OpenRockData/OpenRockData.py +++ b/src/modules/OpenRockData/OpenRockData.py @@ -1,15 +1,17 @@ import os +import subprocess +import sys +from pathlib import Path + +import drd import qt import slicer import xarray as xr -import drd -import sys -import subprocess -from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget -from ltrace.slicer import netcdf -from pathlib import Path from Libs.drd_wrapper import Source +from ltrace.slicer import netcdf +from ltrace.slicer.widget.help_button import HelpButton +from ltrace.slicer_utils import LTracePlugin, LTracePluginWidget, getResourcePath try: from Test.OpenRockDataTest import OpenRockDataTest @@ -24,9 +26,9 @@ class OpenRockData(LTracePlugin): def __init__(self, parent): LTracePlugin.__init__(self, parent) self.parent.title = "Digital Rocks Portal" - self.parent.categories = ["LTrace Tools"] + self.parent.categories = ["Tools", "OpenRockData", "MicroCT", "Thin Section", "Image Log", "Core", "Multiscale"] self.parent.contributors = ["LTrace Geophysics Team"] - self.parent.helpText = OpenRockData.help() + self.parent.helpText = f"file:///{(getResourcePath('manual') / 'Modules/Volumes/OpenRockData.html').as_posix()}" @classmethod def readme_path(cls): @@ -40,6 +42,25 @@ def __init__(self, parent): def setup(self): LTracePluginWidget.setup(self) + helpLayout = qt.QFormLayout() + helpButton = HelpButton( + message=""" +This module uses the drd +library by Lukas-Mosser to access microtomography data from various sources. + +

Data Sources

+