diff --git a/docs/assets/screenshots/GUI-layers.png b/docs/assets/screenshots/GUI-layers.png new file mode 100644 index 0000000000000000000000000000000000000000..0285fa99cca778942b5665e8f252c94096a8d7fb Binary files /dev/null and b/docs/assets/screenshots/GUI-layers.png differ diff --git a/docs/assets/screenshots/layers.png b/docs/assets/screenshots/layers.png new file mode 100644 index 0000000000000000000000000000000000000000..0715964a369d4f40cd198b58bf7cc815dda3ecb5 Binary files /dev/null and b/docs/assets/screenshots/layers.png differ diff --git a/docs/assets/screenshots/segmented_layers.png b/docs/assets/screenshots/segmented_layers.png new file mode 100644 index 0000000000000000000000000000000000000000..8f9a261aed7b11514ce1d3f3c56900f8152a0a75 Binary files /dev/null and b/docs/assets/screenshots/segmented_layers.png differ diff --git a/docs/cli.md b/docs/cli.md index a8d1d42e681b5b990b8d89060324c022115511ef..502ba65ca5b33424260890cb443f9c889948d264 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -30,6 +30,8 @@ This offers quick interactions, making it ideal for tasks that require efficienc | `--data-explorer` | Starts the Data Explorer | | `--iso3d` | Starts the 3D Isosurfaces visualization | | `--local-thickness` | Starts the Local thickness tool | +| `--anotation-tool` | Starts the annotation tool | +| `--layers` | Starts the tool for segmenting layers | | `--host` | Desired host for the server. By default runs on `0.0.0.0` | | `--platform` | Uses the Qim platform API for a unique path and port depending on the username | diff --git a/docs/gui.md b/docs/gui.md index 5401cb85bd40f32e9d74fadfc531c3fa0dec8879..ef996896db91d26a02037f28f288ece44d0892d3 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -34,5 +34,9 @@ For details see [here](cli.md#qim3d-gui).  ::: qim3d.gui.annotation_tool + options: + members: False + +::: qim3d.gui.layers2d options: members: False \ No newline at end of file diff --git a/docs/processing.md b/docs/processing.md index 3ea9a81035699954845a2622d76474c30423d8ea..68535f9cdf20189d5944790e07a82a14677bd368 100644 --- a/docs/processing.md +++ b/docs/processing.md @@ -14,6 +14,8 @@ Here, we provide functionalities designed specifically for 3D image analysis and - maximum - minimum - tophat + - get_lines + - segment_layers - create_mesh ::: qim3d.processing.Pipeline diff --git a/qim3d/cli.py b/qim3d/cli.py index 672bbaafa8ac35fba38f8150d69a2bb68f66cc21..401c8b84cc3620304f4c95853316c3201237cf4d 100644 --- a/qim3d/cli.py +++ b/qim3d/cli.py @@ -34,6 +34,9 @@ def main(): gui_parser.add_argument( "--local-thickness", action="store_true", help="Run local thickness tool." ) + gui_parser.add_argument( + "--layers", action="store_true", help="Run Layers." + ) gui_parser.add_argument("--host", default="0.0.0.0", help="Desired host.") gui_parser.add_argument( "--platform", action="store_true", help="Use QIM platform address" @@ -137,6 +140,8 @@ def main(): interface_class = qim3d.gui.annotation_tool.Interface elif args.local_thickness: interface_class = qim3d.gui.local_thickness.Interface + elif args.layers: + interface_class = qim3d.gui.layers2d.Interface else: print( "Please select a tool by choosing one of the following flags:\n\t--data-explorer\n\t--iso3d\n\t--annotation-tool\n\t--local-thickness" diff --git a/qim3d/examples/slice_218x193.png b/qim3d/examples/slice_218x193.png new file mode 100644 index 0000000000000000000000000000000000000000..f26c876dc030007e0d1129b42db3fa46470f3caa Binary files /dev/null and b/qim3d/examples/slice_218x193.png differ diff --git a/qim3d/gui/__init__.py b/qim3d/gui/__init__.py index f1f7591b8a47e33c4ba90f391fcefc4325f5d0d7..cc7c9ff40272df922cf79a315cf8729e98f6a858 100644 --- a/qim3d/gui/__init__.py +++ b/qim3d/gui/__init__.py @@ -4,6 +4,7 @@ from . import data_explorer from . import iso3d from . import local_thickness from . import annotation_tool +from . import layers2d from .qim_theme import QimTheme diff --git a/qim3d/gui/interface.py b/qim3d/gui/interface.py index 78830582e052d4a72f303530a48bfc522bca88aa..d528d2153e4b5a9f535d850d7a3f86c767119e90 100644 --- a/qim3d/gui/interface.py +++ b/qim3d/gui/interface.py @@ -47,6 +47,9 @@ class BaseInterface(ABC): def set_invisible(self): return gr.update(visible=False) + + def change_visibility(self, is_visible): + return gr.update(visible = is_visible) def launch(self, img=None, force_light_mode: bool = True, **kwargs): """ diff --git a/qim3d/gui/layers2d.py b/qim3d/gui/layers2d.py new file mode 100644 index 0000000000000000000000000000000000000000..cf106dedeb8f47092ce21b90380161f624a1c7bf --- /dev/null +++ b/qim3d/gui/layers2d.py @@ -0,0 +1,475 @@ +""" +The GUI can be launched directly from the command line: + +```bash +qim3d gui --layers +``` + +Or launched from a python script + +```python +import qim3d + +layers = qim3d.gui.layers2d.Interface() +app = layers.launch() +``` + + +""" + +import os + +import gradio as gr +import numpy as np + +from .interface import BaseInterface + +# from qim3d.processing import layers2d as l2d +from qim3d.processing import overlay_rgb_images, segment_layers, get_lines +from qim3d.io import load +from qim3d.viz.layers2d import image_with_lines + +#TODO figure out how not update anything and go through processing when there are no data loaded +# So user could play with the widgets but it doesnt throw error +# Right now its only bypassed with several if statements +# I opened an issue here https://github.com/gradio-app/gradio/issues/9273 + +X = 'X' +Y = 'Y' +Z = 'Z' +AXES = {X:2, Y:1, Z:0} + +DEFAULT_PLOT_TYPE = 'Segmentation mask' +SEGMENTATION_COLORS = np.array([[0, 255, 255], # Cyan + [255, 195, 0], # Yellow Orange + [199, 0, 57], # Dark orange + [218, 247, 166], # Light green + [255, 0, 255], # Magenta + [65, 105, 225], # Royal blue + [138, 43, 226], # Blue violet + [255, 0, 0], #Red + ]) + +class Interface(BaseInterface): + def __init__(self): + super().__init__("Layered surfaces 2D", 1080) + + self.data = None + # It important to keep the name of the attributes like this (including the capital letter) becuase of + # accessing the attributes via __dict__ + self.X_slice = None + self.Y_slice = None + self.Z_slice = None + self.X_segmentation = None + self.Y_segmentation = None + self.Z_segmentation = None + + self.plot_type = DEFAULT_PLOT_TYPE + + self.error = False + + + + def define_interface(self): + with gr.Row(): + with gr.Column(scale=1, min_width=320): + with gr.Row(): + with gr.Column(scale=99, min_width=128): + base_path = gr.Textbox( + max_lines=1, + container=False, + label="Base path", + value=os.getcwd(), + ) + + with gr.Column(scale=1, min_width=36): + reload_base_path = gr.Button(value="⟳") + + explorer = gr.FileExplorer( + ignore_glob="*/.*", + root_dir=os.getcwd(), + label=os.getcwd(), + render=True, + file_count="single", + interactive=True, + height = 230, + ) + + + with gr.Group(): + with gr.Row(): + axis = gr.Radio( + choices = [Z, Y, X], + value = Z, + label = 'Layer axis', + info = 'Specifies in which direction are the layers. The order of axes is ZYX',) + with gr.Row(): + wrap = gr.Checkbox( + label = "Lines start and end at the same level.", + info = "Used when segmenting layers of unfolded image." + ) + + is_inverted = gr.Checkbox( + label="Invert image before processing", + info="The algorithm effectively flips the gradient.", + ) + + with gr.Row(): + delta = gr.Slider( + minimum=0, + maximum=5, + value=0.75, + step=0.01, + interactive = True, + label="Delta value", + info="The lower the delta is, the more accurate the gradient calculation will be. However, the calculation takes longer to execute. Delta above 1 is rounded down to closest lower integer", + ) + + with gr.Row(): + min_margin = gr.Slider( + minimum=1, + maximum=50, + value=10, + step=1, + interactive = True, + label="Min margin", + info="Minimum margin between layers to be detected in the image.", + ) + + with gr.Row(): + n_layers = gr.Slider( + minimum=1, + maximum=len(SEGMENTATION_COLORS) - 1, + value=2, + step=1, + interactive=True, + label="Number of layers", + info="Number of layers to be detected in the image", + ) + + # with gr.Row(): + # btn_run = gr.Button("Run Layers2D", variant = 'primary') + + # Output panel: Plots + """ + 60em if plot is alone + 30em if two of them + 20em if all of them are visible + + When one slicing axis is made unvisible we want the other two images to be bigger + For some reason, gradio changes their width but not their height. So we have to + change their height manually + """ + + self.heights = ['60em', '30em', '20em'] # em units are relative to the parent, + + + with gr.Column(scale=2,): + # with gr.Row(): # Source image outputs + # input_image_kwargs = lambda axis: dict( + # show_label = True, + # label = F'Slice along {axis}-axis', + # visible = True, + # height = self.heights[2] + # ) + + # input_plot_x = gr.Image(**input_image_kwargs('X')) + # input_plot_y = gr.Image(**input_image_kwargs('Y')) + # input_plot_z = gr.Image(**input_image_kwargs('Z')) + + with gr.Row(): # Detected layers outputs + output_image_kwargs = lambda axis: dict( + show_label = True, + label = F'Detected layers {axis}-axis', + visible = True, + height = self.heights[2] + ) + output_plot_x = gr.Image(**output_image_kwargs('X')) + output_plot_y = gr.Image(**output_image_kwargs('Y')) + output_plot_z = gr.Image(**output_image_kwargs('Z')) + + with gr.Row(): # Axis position sliders + slider_kwargs = lambda axis: dict( + minimum = 0, + maximum = 1, + value = 0.5, + step = 0.01, + label = F'{axis} position', + info = F'The 3D image is sliced along {axis}-axis' + ) + + x_pos = gr.Slider(**slider_kwargs('X')) + y_pos = gr.Slider(**slider_kwargs('Y')) + z_pos = gr.Slider(**slider_kwargs('Z')) + + with gr.Row(): + x_check = gr.Checkbox(value = True, interactive=True, label = 'Show X slice') + y_check = gr.Checkbox(value = True, interactive=True, label = 'Show Y slice') + z_check = gr.Checkbox(value = True, interactive=True, label = 'Show Z slice') + + with gr.Row(): + with gr.Group(): + plot_type = gr.Radio( + choices= (DEFAULT_PLOT_TYPE, 'Segmentation lines',), + value = DEFAULT_PLOT_TYPE, + interactive = True, + show_label=False + ) + + alpha = gr.Slider( + minimum=0, + maximum = 1, + step = 0.01, + label = 'Alpha value', + show_label=True, + value = 0.5, + visible = True, + interactive=True + ) + + line_thickness = gr.Slider( + minimum=0.1, + maximum = 5, + value = 2, + label = 'Line thickness', + show_label = True, + visible = False, + interactive = True + ) + + with gr.Row(): + btn_run = gr.Button("Run Layers2D", variant = 'primary') + + + positions = [x_pos, y_pos, z_pos] + process_inputs = [axis, is_inverted, delta, min_margin, n_layers, wrap] + plotting_inputs = [axis, alpha, line_thickness] + # input_plots = [input_plot_x, input_plot_y, input_plot_z] + output_plots = [output_plot_x, output_plot_y, output_plot_z] + visibility_check_inputs = [x_check, y_check, z_check] + + spinner_loading = gr.Text("Loading data...", visible=False) + spinner_running = gr.Text("Running pipeline...", visible=False) + + reload_base_path.click( + fn=self.update_explorer,inputs=base_path, outputs=explorer) + + plot_type.change( + self.change_plot_type, inputs = plot_type, outputs = [alpha, line_thickness]).then( + fn = self.plot_output_img_all, inputs = plotting_inputs, outputs = output_plots + ) + + gr.on( + triggers = [alpha.release, line_thickness.release], + fn = self.plot_output_img_all, inputs = plotting_inputs, outputs = output_plots + ) + + """ + Difference between btn_run.click and the other triggers below is only loading the data. + To make it easier to maintain, I created 'update_component' variable. Its value is completely + unimportant. It exists only to be changed after loading the data which triggers further processing + which is the same for button click and the other triggers + """ + + update_component = gr.State(True) + + btn_run.click( + fn=self.set_spinner, inputs=spinner_loading, outputs=btn_run).then( + fn=self.load_data, inputs = [base_path, explorer]).then( + fn = lambda state: not state, inputs = update_component, outputs = update_component) + + gr.on( + triggers= (axis.change, is_inverted.change, delta.release, min_margin.release, n_layers.release, update_component.change, wrap.change), + fn=self.set_spinner, inputs = spinner_running, outputs=btn_run).then( + fn=self.process_all, inputs = [*positions, *process_inputs]).then( + # fn=self.plot_input_img_all, outputs = input_plots, show_progress='hidden').then( + fn=self.plot_output_img_all, inputs = plotting_inputs, outputs = output_plots, show_progress='hidden').then( + fn=self.set_relaunch_button, inputs=[], outputs=btn_run) + + # Chnages visibility and sizes of the plots - gives user the option to see only some of the images and in bigger scale + gr.on( + triggers=[x_check.change, y_check.change, z_check.change], + fn = self.change_row_visibility, inputs = visibility_check_inputs, outputs = positions).then( + # fn = self.change_row_visibility, inputs = visibility_check_inputs, outputs = input_plots).then( + fn = self.change_plot_size, inputs = visibility_check_inputs, outputs = output_plots) + + # for axis, slider, input_plot, output_plot in zip(['x','y','z'], positions, input_plots, output_plots): + for axis, slider, output_plot in zip([X,Y,Z], positions, output_plots): + slider.change( + self.process_wrapper(axis), inputs = [slider, *process_inputs]).then( + # self.plot_input_img_wrapper(axis), outputs = input_plot).then( + self.plot_output_img_wrapper(axis), inputs = plotting_inputs, outputs = output_plot) + + + + def change_plot_type(self, plot_type, ): + self.plot_type = plot_type + if plot_type == 'Segmentation lines': + return gr.update(visible = False), gr.update(visible = True) + else: + return gr.update(visible = True), gr.update(visible = False) + + def change_plot_size(self, x_check, y_check, z_check): + """ + Based on how many plots are we displaying (controlled by checkboxes in the bottom) we define + also their height because gradio doesn't do it automatically. The values of heights were set just by eye. + They are defines before defining the plot in 'define_interface' + """ + index = x_check + y_check + z_check - 1 + height = self.heights[index] # also used to define heights of plots in the begining + return gr.update(height = height, visible= x_check), gr.update(height = height, visible = y_check), gr.update(height = height, visible = z_check) + + def change_row_visibility(self, x_check, y_check, z_check): + return self.change_visibility(x_check), self.change_visibility(y_check), self.change_visibility(z_check) + + def update_explorer(self, new_path): + # Refresh the file explorer object + new_path = os.path.expanduser(new_path) + + # In case we have a directory + if os.path.isdir(new_path): + return gr.update(root_dir=new_path, label=new_path) + + elif os.path.isfile(new_path): + parent_dir = os.path.dirname(new_path) + file_name = str(os.path.basename(new_path)) + return gr.update(root_dir=parent_dir, label=parent_dir, value=file_name) + + else: + raise ValueError("Invalid path") + + def set_relaunch_button(self): + return gr.update(value=f"Relaunch", interactive=True) + + def set_spinner(self, message): + if self.error: + return gr.Button() + # spinner icon/shows the user something is happeing + return gr.update(value=f"{message}", interactive=False) + + def load_data(self, base_path, explorer): + if base_path and os.path.isfile(base_path): + file_path = base_path + elif explorer and os.path.isfile(explorer): + file_path = explorer + else: + raise gr.Error("Invalid file path") + + try: + self.data = load( + file_path, + progress_bar=False + ) + except Exception as error_message: + raise gr.Error( + f"Failed to load the image: {error_message}" + ) from error_message + + def process_all(self, x_pos:float, y_pos:float, z_pos:float, axis:str, inverted:bool, delta:float, min_margin:int, n_layers:int, wrap:bool): + self.process_wrapper(X)(x_pos, axis, inverted, delta, min_margin, n_layers, wrap) + self.process_wrapper(Y)(y_pos, axis, inverted, delta, min_margin, n_layers, wrap) + self.process_wrapper(Z)(z_pos, axis, inverted, delta, min_margin, n_layers, wrap) + + def process_wrapper(self, slicing_axis:str): + """ + The function behaves the same in all 3 directions, however we have to know in which direction we are now. + Thus we have this wrapper function, where we pass the slicing axis - in which axis are we indexing the data + and we return a function working in that direction + """ + slice_key = F'{slicing_axis}_slice' + seg_key = F'{slicing_axis}_segmentation' + slicing_axis_int = AXES[slicing_axis] + + def process(pos:float, segmenting_axis:str, inverted:bool, delta:float, min_margin:int, n_layers:int, wrap:bool): + """ + Parameters: + ----------- + pos: Relative position of a slice from data + segmenting_axis: In which direction we want to detect layers + inverted: If we want use inverted gradient + delta: Smoothness parameter + min_margin: What is the minimum distance between layers. If it was 0, all layers would be the same + n_layers: How many layer boarders we want to find + wrap: If True, the starting point and end point will be at the same level. Useful when segmenting unfolded images. + """ + slice = self.get_slice(pos, slicing_axis_int) + self.__dict__[slice_key] = slice + + if segmenting_axis == slicing_axis: + self.__dict__[seg_key] = None + else: + + if self.is_transposed(slicing_axis, segmenting_axis): + slice = np.rot90(slice) + self.__dict__[seg_key] = segment_layers(slice, inverted = inverted, n_layers = n_layers, delta = delta, min_margin = min_margin, wrap = wrap) + + return process + + def is_transposed(self, slicing_axis:str, segmenting_axis:str): + """ + Checks if the desired direction of segmentation is the same if the image would be submitted to segmentation as is. + If it is not, we have to rotate it before we put it to segmentation algorithm + """ + remaining_axis = F"{X}{Y}{Z}".replace(slicing_axis, '').replace(segmenting_axis, '') + return AXES[segmenting_axis] > AXES[remaining_axis] + + def get_slice(self, pos:float, axis:int): + idx = int(pos * (self.data.shape[axis] - 1)) + return np.take(self.data, idx, axis = axis) + + # def plot_input_img_wrapper(self, axis:str): + # slice_key = F'{axis.lower()}_slice' + # def plot_input_img(): + # slice = self.__dict__[slice_key] + # slice = slice + np.abs(np.min(slice)) + # slice = slice / np.max(slice) + # return slice + # return plot_input_img + + # def plot_input_img_all(self): + # x_plot = self.plot_input_img_wrapper('x')() + # y_plot = self.plot_input_img_wrapper('y')() + # z_plot = self.plot_input_img_wrapper('z')() + # return x_plot, y_plot, z_plot + + def plot_output_img_wrapper(self, slicing_axis:str): + slice_key = F'{slicing_axis}_slice' + seg_key = F'{slicing_axis}_segmentation' + + def plot_output_img(segmenting_axis:str, alpha:float, line_thickness:float): + slice = self.__dict__[slice_key] + seg = self.__dict__[seg_key] + + if seg is None: # In case segmenting axis si the same as slicing axis + return slice + + if self.plot_type == DEFAULT_PLOT_TYPE: + n_layers = len(seg) + 1 + seg = np.sum(seg, axis = 0) + seg = np.repeat(seg[..., None], 3, axis = -1) + for i in range(n_layers): + seg[seg[:,:,0] == i, :] = SEGMENTATION_COLORS[i] + + if self.is_transposed(slicing_axis, segmenting_axis): + seg = np.rot90(seg, k = 3) + # slice = 255 * (slice/np.max(slice)) + # return image_with_overlay(np.repeat(slice[..., None], 3, -1), seg, alpha) + return overlay_rgb_images(slice, seg, alpha) + else: + lines = get_lines(seg) + if self.is_transposed(slicing_axis, segmenting_axis): + return image_with_lines(np.rot90(slice), lines, line_thickness).rotate(270, expand = True) + else: + return image_with_lines(slice, lines, line_thickness) + + return plot_output_img + + def plot_output_img_all(self, segmenting_axis:str, alpha:float, line_thickness:float): + x_output = self.plot_output_img_wrapper(X)(segmenting_axis, alpha, line_thickness) + y_output = self.plot_output_img_wrapper(Y)(segmenting_axis, alpha, line_thickness) + z_output = self.plot_output_img_wrapper(Z)(segmenting_axis, alpha, line_thickness) + return x_output, y_output, z_output + +if __name__ == "__main__": + Interface().run_interface() + \ No newline at end of file diff --git a/qim3d/processing/__init__.py b/qim3d/processing/__init__.py index 981bb55dacd3a6e212948ad47533cee3c528dfdf..0b767fb1fe413699d3a56878ca216481fe1daae1 100644 --- a/qim3d/processing/__init__.py +++ b/qim3d/processing/__init__.py @@ -4,4 +4,5 @@ from .detection import blob_detection from .filters import * from .operations import * from .cc import get_3d_cc +from .layers2d import segment_layers, get_lines from .mesh import create_mesh diff --git a/qim3d/processing/layers2d.py b/qim3d/processing/layers2d.py new file mode 100644 index 0000000000000000000000000000000000000000..d91ef0a42f1ba8715793a674321219cbbf1d634b --- /dev/null +++ b/qim3d/processing/layers2d.py @@ -0,0 +1,97 @@ +import numpy as np +from slgbuilder import GraphObject +from slgbuilder import MaxflowBuilder + +def segment_layers(data:np.ndarray, inverted:bool = False, n_layers:int = 1, delta:float = 1, min_margin:int = 10, max_margin:int = None, wrap:bool = False): + """ + Works on 2D and 3D data. + Light one function wrapper around slgbuilder https://github.com/Skielex/slgbuilder to do layer segmentation + Now uses only MaxflowBuilder for solving. + + Args: + data: 2D or 3D array on which it will be computed + inverted: if True, it will invert the brightness of the image + n_layers: How many layers are we looking for (result in a layer and background) + delta: Smoothness parameter + min_margin: If we want more layers, we have to have a margin otherwise they are all going to be exactly the same + max_margin: Maximum margin between layers + wrap: If True, starting and ending point of the border between layers are at the same level + + Returns: + segmentations: list of numpy arrays, even if n_layers == 1, each array is only 0s and 1s, 1s segmenting this specific layer + + Raises: + TypeError: If Data is not np.array, if n_layers is not integer. + ValueError: If n_layers is less than 1, if delta is negative or zero + + Example: + Example is only shown on 2D image, but segment_layers can also take 3D structures. + ```python + import qim3d + + layers_image = qim3d.io.load('layers3d.tif')[:,:,0] + layers = qim3d.processing.segment_layers(layers_image, n_layers = 2) + layer_lines = qim3d.processing.get_lines(layers) + + from matplotlib import pyplot as plt + + plt.imshow(layers_image, cmap='gray') + plt.axis('off') + for layer_line in layer_lines: + plt.plot(layer_line, linewidth = 3) + ``` +  +  + + """ + if isinstance(data, np.ndarray): + data = data.astype(np.int32) + if inverted: + data = ~data + else: + raise TypeError(F"Data has to be type np.ndarray. Your data is of type {type(data)}") + + helper = MaxflowBuilder() + if not isinstance(n_layers, int): + raise TypeError(F"Number of layers has to be positive integer. You passed {type(n_layers)}") + + if n_layers == 1: + layer = GraphObject(data) + helper.add_object(layer) + elif n_layers > 1: + layers = [GraphObject(data) for _ in range(n_layers)] + helper.add_objects(layers) + for i in range(len(layers)-1): + helper.add_layered_containment(layers[i], layers[i+1], min_margin=min_margin, max_margin=max_margin) + + else: + raise ValueError(F"Number of layers has to be positive integer. You passed {n_layers}") + + helper.add_layered_boundary_cost() + + if delta > 1: + delta = int(delta) + elif delta <= 0: + raise ValueError(F'Delta has to be positive number. You passed {delta}') + helper.add_layered_smoothness(delta=delta, wrap = bool(wrap)) + helper.solve() + if n_layers == 1: + segmentations =[helper.what_segments(layer)] + else: + segmentations = [helper.what_segments(l).astype(np.int32) for l in layers] + + return segmentations + +def get_lines(segmentations:list|np.ndarray) -> list: + """ + Expects list of arrays where each array is 2D segmentation with only 2 classes. This function gets the border between those two + so it could be plotted. Used with qim3d.processing.segment_layers + + Args: + segmentations: list of arrays where each array is 2D segmentation with only 2 classes + + Returns: + segmentation_lines: list of 1D numpy arrays + """ + segmentation_lines = [np.argmin(s, axis=0) - 0.5 for s in segmentations] + return segmentation_lines \ No newline at end of file diff --git a/qim3d/processing/operations.py b/qim3d/processing/operations.py index 982c6960378ea49b987a655a9840937621171f63..8d2d0f83995ccb3507672a354fcb9d0b7cfd185e 100644 --- a/qim3d/processing/operations.py +++ b/qim3d/processing/operations.py @@ -200,7 +200,7 @@ def fade_mask( def overlay_rgb_images( - background: np.ndarray, foreground: np.ndarray, alpha: float = 0.5 + background: np.ndarray, foreground: np.ndarray, alpha: float = 0.5, hide_black:bool = True, ) -> np.ndarray: """ Overlay an RGB foreground onto an RGB background using alpha blending. @@ -209,6 +209,7 @@ def overlay_rgb_images( background (numpy.ndarray): The background RGB image. foreground (numpy.ndarray): The foreground RGB image (usually masks). alpha (float, optional): The alpha value for blending. Defaults to 0.5. + hide_black (bool, optional): If True, black pixels will have alpha value 0, so the black won't be visible. Used for segmentation where we don't care about background. Defaults to True. Returns: composite (numpy.ndarray): The composite RGB image with overlaid foreground. @@ -218,18 +219,36 @@ def overlay_rgb_images( Note: - The function performs alpha blending to overlay the foreground onto the background. - - It ensures that the background and foreground have the same shape before blending. + - It ensures that the background and foreground have the same first two dimensions (image size matches). + - It can handle greyscale images, values from 0 to 1, raw values which are negative or bigger than 255. - It calculates the maximum projection of the foreground and blends them onto the background. - - Brightness outside the foreground is adjusted to maintain consistency with the background. """ - # Igonore alpha in case its there - background = background[..., :3] - foreground = foreground[..., :3] + def to_uint8(image:np.ndarray): + if np.min(image) < 0: + image = image - np.min(image) + + maxim = np.max(image) + if maxim > 255: + image = (image / maxim)*255 + elif maxim <= 1: + image = image*255 + + if image.ndim == 2: + image = np.repeat(image[..., None], 3, -1) + elif image.ndim == 3: + image = image[..., :3] # Ignoring alpha channel + else: + raise ValueError(F'Input image can not have higher dimension than 3. Yours have {image.ndim}') + + return image.astype(np.uint8) + + background = to_uint8(background) + foreground = to_uint8(foreground) # Ensure both images have the same shape if background.shape != foreground.shape: - raise ValueError("Input images must have the same shape") + raise ValueError(F"Input images must have the same first two dimensions. But background is of shape {background.shape} and foreground is of shape {foreground.shape}") # Perform alpha blending foreground_max_projection = np.amax(foreground, axis=2) @@ -240,11 +259,18 @@ def overlay_rgb_images( foreground_max_projection = foreground_max_projection / np.max( foreground_max_projection ) + # Check alpha validity + if alpha < 0: + raise ValueError(F'Alpha has to be positive number. You used {alpha}') + elif alpha > 1: + alpha = 1 + + # If the pixel is black, its alpha value is set to 0, so it has no effect on the image + if hide_black: + alpha = np.full((background.shape[0], background.shape[1],1), alpha) + alpha[np.apply_along_axis(lambda x: (x == [0,0,0]).all(), axis = 2, arr = foreground)] = 0 composite = background * (1 - alpha) + foreground * alpha composite = np.clip(composite, 0, 255).astype("uint8") - # Adjust brightness outside foreground - composite = composite + (background * (1 - alpha)) * (1 - foreground_max_projection) - - return composite.astype("uint8") + return composite.astype("uint8") \ No newline at end of file diff --git a/qim3d/utils/cli.py b/qim3d/utils/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..f07135782e2cb65ea0350c53d9108c7b160dca29 --- /dev/null +++ b/qim3d/utils/cli.py @@ -0,0 +1,39 @@ +import argparse +from qim3d.gui import data_explorer, iso3d, annotation_tool, local_thickness, layers2d + +def main(): + parser = argparse.ArgumentParser(description='Qim3d command-line interface.') + subparsers = parser.add_subparsers(title='Subcommands', dest='subcommand') + + # subcommands + gui_parser = subparsers.add_parser('gui', help = 'Graphical User Interfaces.') + + gui_parser.add_argument('--data-explorer', action='store_true', help='Run data explorer.') + gui_parser.add_argument('--iso3d', action='store_true', help='Run iso3d.') + gui_parser.add_argument('--annotation-tool', action='store_true', help='Run annotation tool.') + gui_parser.add_argument('--local-thickness', action='store_true', help='Run local thickness tool.') + gui_parser.add_argument('--layers2d', action='store_true', help='Run layers2d.') + gui_parser.add_argument('--host', default='0.0.0.0', help='Desired host.') + + args = parser.parse_args() + + if args.subcommand == 'gui': + arghost = args.host + if args.data_explorer: + + data_explorer.run_interface(arghost) + + elif args.iso3d: + iso3d.run_interface(arghost) + + elif args.annotation_tool: + annotation_tool.run_interface(arghost) + + elif args.local_thickness: + local_thickness.run_interface(arghost) + + elif args.layers2d: + layers2d.run_interface(arghost) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/qim3d/viz/__init__.py b/qim3d/viz/__init__.py index 28a9ed9257c921e03e3c3eb5c515b8eb2a0769c7..444c190c1b545642a1e5c3bc491e3ea0dce8cc87 100644 --- a/qim3d/viz/__init__.py +++ b/qim3d/viz/__init__.py @@ -13,3 +13,4 @@ from .local_thickness_ import local_thickness from .structure_tensor import vectors from .metrics import plot_metrics, grid_overview, grid_pred, vol_masked from .preview import image_preview +from . import layers2d diff --git a/qim3d/viz/layers2d.py b/qim3d/viz/layers2d.py new file mode 100644 index 0000000000000000000000000000000000000000..1e284461f2eb86bf5883891cd9205911f151d835 --- /dev/null +++ b/qim3d/viz/layers2d.py @@ -0,0 +1,37 @@ +""" Provides a collection of visualisation functions for the Layers2d class.""" +import io + +import matplotlib.pyplot as plt +import numpy as np + + +from PIL import Image + + +def image_with_lines(image:np.ndarray, lines: list, line_thickness:float|int) -> Image: + """ + Plots the image and plots the lines on top of it. Then extracts it as PIL.Image and in the same size as the input image was. + Paramters: + ----------- + image: Image on which we put the lines + lines: list of 1D arrays to be plotted on top of the image + line_thickness: how thick is the line supposed to be + + Returns: + ---------- + image_with_lines: + """ + fig, ax = plt.subplots() + ax.imshow(image, cmap = 'gray') + ax.axis('off') + + for line in lines: + ax.plot(line, linewidth = line_thickness) + + buf = io.BytesIO() + plt.savefig(buf, format='png', bbox_inches='tight', pad_inches=0) + plt.close() + + buf.seek(0) + return Image.open(buf).resize(size = image.squeeze().shape[::-1]) + diff --git a/requirements.txt b/requirements.txt index 7decb4f38c595496b9d87cfdb7cb7d9311994f77..45e52daffd5e28a5b6c4f4085087bb5dfafc25da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ Pillow>=10.0.1 plotly>=5.14.1 scipy>=1.11.2 seaborn>=0.12.2 -pydicom>=2.4.4 +pydicom==2.4.4 setuptools>=68.0.0 tifffile>=2023.4.12 torch>=2.0.1 @@ -29,3 +29,4 @@ zarr>=2.18.2 ome_zarr>=0.9.0 dask-image>=2024.5.3 trimesh>=4.4.9 +slgbuilder>=0.2.1 diff --git a/setup.py b/setup.py index 8abce05e40f9453d379b3d741a0a8fd9796890e3..10cdf4e6be8d58d6826836f3acf3e57192299613 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ setup( "h5py>=3.9.0", "localthickness>=0.1.2", "matplotlib>=3.8.0", - "pydicom>=2.4.4", + "pydicom==2.4.4", "numpy>=1.26.0", "outputformat>=0.1.3", "Pillow>=10.0.1",