From 7aeb2f3b893a5d28e5b2ff85eb986856ac425d7d Mon Sep 17 00:00:00 2001 From: henriquenunez Date: Thu, 1 Aug 2024 23:59:31 +0300 Subject: [PATCH] FEAT: Implemented frontal 3D editing (no rotation) Functionality for using the polygon drawn in the volume viewer to cut the mask has been implemented. --- invesalius/constants.py | 2 +- invesalius/gui/task_slice.py | 16 +-- invesalius/segmentation/mask_3d_edit.py | 160 ++++++++++++++++++++++++ invesalius_cy/mask_cut.pyx | 62 +++++++++ setup.py | 13 +- 5 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 invesalius/segmentation/mask_3d_edit.py create mode 100644 invesalius_cy/mask_cut.pyx diff --git a/invesalius/constants.py b/invesalius/constants.py index 399a84758..f68dbaf05 100644 --- a/invesalius/constants.py +++ b/invesalius/constants.py @@ -730,7 +730,7 @@ # Override all other states when in navigation mode. STATE_NAVIGATION: 4, # TODO(Henrique): check whether we need to override the previous states - STATE_MASK_3D_EDIT: 3 + STATE_MASK_3D_EDIT: 3, } # ------------ Prefereces options key ------------ diff --git a/invesalius/gui/task_slice.py b/invesalius/gui/task_slice.py index afeec3450..42a66a630 100644 --- a/invesalius/gui/task_slice.py +++ b/invesalius/gui/task_slice.py @@ -762,9 +762,9 @@ def __init__(self, parent): # txt_mesh_edit_3d = wx.StaticText(self, -1, "Edit Mesh in 3D") cbox_mask_edit_3d = wx.CheckBox(self, -1, "Edit Mask in 3D?") btn_make_mesh_slice = wx.Button(self, -1, _("Apply Edit")) - combo_mask_edit_3d_op = wx.ComboBox(self, -1, "", - choices = const.MASK_3D_EDIT_OP_NAME, - style = wx.CB_DROPDOWN|wx.CB_READONLY) + combo_mask_edit_3d_op = wx.ComboBox( + self, -1, "", choices=const.MASK_3D_EDIT_OP_NAME, style=wx.CB_DROPDOWN | wx.CB_READONLY + ) combo_mask_edit_3d_op.SetSelection(const.MASK_3D_EDIT_INCLUDE) line5 = wx.BoxSizer(wx.HORIZONTAL) @@ -913,17 +913,17 @@ def OnCheckMaskEdit3D(self, evt): style_id = const.STATE_MASK_3D_EDIT if self.cbox_mask_edit_3d.GetValue(): - Publisher.sendMessage('Enable style', style=style_id) - Publisher.sendMessage('Enable mask 3D preview') + Publisher.sendMessage("Enable style", style=style_id) + Publisher.sendMessage("Enable mask 3D preview") else: - Publisher.sendMessage('Disable style', style=style_id) + Publisher.sendMessage("Disable style", style=style_id) def OnDoMaskEdit3D(self, evt): - Publisher.sendMessage('M3E apply edit') + Publisher.sendMessage("M3E apply edit") def OnComboMaskEdit3DMode(self, evt): op_id = evt.GetSelection() - Publisher.sendMessage('M3E set edit mode', mode=op_id) + Publisher.sendMessage("M3E set edit mode", mode=op_id) class WatershedTool(EditionTools): diff --git a/invesalius/segmentation/mask_3d_edit.py b/invesalius/segmentation/mask_3d_edit.py new file mode 100644 index 000000000..e62150d6d --- /dev/null +++ b/invesalius/segmentation/mask_3d_edit.py @@ -0,0 +1,160 @@ +# -------------------------------------------------------------------------- +# Software: InVesalius - Software de Reconstrucao 3D de Imagens Medicas +# Copyright: (C) 2001 Centro de Pesquisas Renato Archer +# Homepage: http://www.softwarepublico.gov.br +# Contact: invesalius@cti.gov.br +# License: GNU - GPL 2 (LICENSE.txt/LICENCA.txt) +# -------------------------------------------------------------------------- +# Este programa e software livre; voce pode redistribui-lo e/ou +# modifica-lo sob os termos da Licenca Publica Geral GNU, conforme +# publicada pela Free Software Foundation; de acordo com a versao 2 +# da Licenca. +# +# Este programa eh distribuido na expectativa de ser util, mas SEM +# QUALQUER GARANTIA; sem mesmo a garantia implicita de +# COMERCIALIZACAO ou de ADEQUACAO A QUALQUER PROPOSITO EM +# PARTICULAR. Consulte a Licenca Publica Geral GNU para obter mais +# detalhes. +# -------------------------------------------------------------------------- + +import numpy as np +from skimage.draw import polygon + +import invesalius.constants as const +import invesalius.data.slice_ as slc +from invesalius.pubsub import pub as Publisher +from invesalius_cy.mask_cut import mask_cut + + +class Mask3DEditor: + def __init__(self): + self.__bind_events() + self.polygons_to_operate = [] + self.model_to_screen = None + self.spacing = None + self.edit_mode = const.MASK_3D_EDIT_INCLUDE + + def __bind_events(self): + Publisher.subscribe(self.AddPolygon, "M3E add polygon") + Publisher.subscribe(self.DoMaskEdit, "M3E apply edit") + Publisher.subscribe(self.ClearPolygons, "M3E clear polygons") + Publisher.subscribe(self.SetMVP, "M3E set model_to_screen") + Publisher.subscribe(self.SetEditMode, "M3E set edit mode") + + def AddPolygon(self, points, screen): + """ + Adds polygon to be used in the edit + """ + self.polygons_to_operate.append((points, screen)) + + def ClearPolygons(self): + """ + Discards all added polygons + """ + self.polygons_to_operate = [] + + def SetMVP(self, model_to_screen): + """ + Sets the model-view-projection matrix used by the viewer + """ + self.model_to_screen = model_to_screen + + def SetEditMode(self, mode): + """ + Sets edit mode to either discard points within or outside + the polygon. + """ + self.edit_mode = mode + + def __create_filter(self): + """ + Based on the polygons and screen resolution, + create a filter for the edit. + """ + w, h = tuple(self.polygons_to_operate[0][1]) + poly_points = self.polygons_to_operate[0][0] + + # print(f'Filter: {_filter.shape}') + print(f"Poly Points: {poly_points}") + + _filter = np.zeros((h, w), dtype="uint8") + poly = np.array(poly_points) + rr, cc = polygon(poly[:, 1], poly[:, 0], _filter.shape) + _filter[rr, cc] = 1 + + if self.edit_mode == const.MASK_3D_EDIT_INCLUDE: + _filter = 1 - _filter + + return _filter + + # Unoptimized implementation + def __cut_mask_left_right(self, mask_data, spacing, _filter): + """ + Cuts an Invesalius Mask with a filter (pixel-wise filter) + mask_data: matrix data without metadata ([1:]) + spacing: the spacing used in the mask matrix + _filter: matrix the same size as the viewer that rasterizes + points to be edited + """ + + dz, dy, dx = mask_data.shape + sx, sy, sz = spacing + + M = self.model_to_screen + h, w = _filter.shape + + for z in range(dz): + for y in range(dy): + for x in range(dx): + # Voxel to world space + p0 = x * sx + p1 = y * sy + p2 = z * sz + p3 = 1.0 + + # _q = M * _p + _q0 = p0 * M[0, 0] + p1 * M[0, 1] + p2 * M[0, 2] + p3 * M[0, 3] + _q1 = p0 * M[1, 0] + p1 * M[1, 1] + p2 * M[1, 2] + p3 * M[1, 3] + _q2 = p0 * M[2, 0] + p1 * M[2, 1] + p2 * M[2, 2] + p3 * M[2, 3] + _q3 = p0 * M[3, 0] + p1 * M[3, 1] + p2 * M[3, 2] + p3 * M[3, 3] + + if _q3 > 0: + q0 = _q0 / _q3 + q1 = _q1 / _q3 + q2 = _q2 / _q3 + + # Normalized coordinates back to pixels + px = (q0 / 2.0 + 0.5) * (w - 1) + py = (q1 / 2.0 + 0.5) * (h - 1) + + if 0 <= px < w and 0 <= py < h: + if ( + _filter[int(py), int(px)] == 1 + ): # NOTE: The lack of round here might be a problem + mask_data[z, y, x] = 0 + + def DoMaskEdit(self): + s = slc.Slice() + _cur_mask = s.current_mask + + if _cur_mask is None: + raise Exception("Attempted Slicing an empty mask") + + _filter = self.__create_filter() + + # Unoptimized implementation + # self.__cut_mask_left_right(_cur_mask.matrix[1:, 1:, 1:], s.spacing, _filter) + + # Optimized implementation + _mat = _cur_mask.matrix[1:, 1:, 1:].copy() + sx, sy, sz = s.spacing + mask_cut(_mat, sx, sy, sz, _filter, self.model_to_screen, _mat) + _cur_mask.matrix[1:, 1:, 1:] = _mat + _cur_mask.modified(all_volume=True) + + # Discard all buffers to reupdate view + for ori in ["AXIAL", "CORONAL", "SAGITAL"]: + s.buffer_slices[ori].discard_buffer() + + Publisher.sendMessage("Update mask 3D preview") + Publisher.sendMessage("Reload actual slice") diff --git a/invesalius_cy/mask_cut.pyx b/invesalius_cy/mask_cut.pyx new file mode 100644 index 000000000..08a4936d0 --- /dev/null +++ b/invesalius_cy/mask_cut.pyx @@ -0,0 +1,62 @@ +import numpy as np +cimport numpy as np +cimport cython + +from libc.math cimport floor, ceil, sqrt, fabs, round +from cython.parallel import prange + +ctypedef np.float64_t DTYPEF64_t + +from .cy_my_types cimport image_t, mask_t + +@cython.boundscheck(False) # turn of bounds-checking for entire function +@cython.cdivision(True) +def mask_cut(np.ndarray[image_t, ndim=3] mask_data, + float sx, float sy, float sz, + np.ndarray[mask_t, ndim=2] filter, + np.ndarray[DTYPEF64_t, ndim=2] M, + np.ndarray[image_t, ndim=3] out): + + cdef int dz = mask_data.shape[0] + cdef int dy = mask_data.shape[1] + cdef int dx = mask_data.shape[2] + + cdef int x + cdef int y + cdef int z + + cdef int h = filter.shape[0] + cdef int w = filter.shape[1] + + cdef DTYPEF64_t px + cdef DTYPEF64_t py + + cdef DTYPEF64_t p0, p1, p2, p3 + cdef DTYPEF64_t _q0, _q1, _q2, _q3 + cdef DTYPEF64_t q0, q1, q2 + + for z in prange(dz, nogil=True): + # for z in range(dz): + for y in range(dy): + for x in range(dx): + p0 = (x*sx) + p1 = (y*sy) + p2 = (z*sz) + p3 = 1.0 + + _q0 = p0 * M[0, 0] + p1 * M[0, 1] + p2 * M[0, 2] + p3 * M[0, 3] + _q1 = p0 * M[1, 0] + p1 * M[1, 1] + p2 * M[1, 2] + p3 * M[1, 3] + _q2 = p0 * M[2, 0] + p1 * M[2, 1] + p2 * M[2, 2] + p3 * M[2, 3] + _q3 = p0 * M[3, 0] + p1 * M[3, 1] + p2 * M[3, 2] + p3 * M[3, 3] + + if _q3 > 0: + q0 = _q0/_q3 + q1 = _q1/_q3 + q2 = _q2/_q3 + + px = (q0/2.0 + 0.5) * (w - 1) + py = (q1/2.0 + 0.5) * (h - 1) + + if 0 <= px and 0 <= py: + if filter[round(py), round(px)]: + out[z, y, x] = 0 diff --git a/setup.py b/setup.py index b183b7059..59780e67e 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,3 @@ -import setuptools import logging import os import pathlib @@ -6,7 +5,8 @@ import sys import numpy -from Cython.Build import cythonize, build_ext +import setuptools +from Cython.Build import build_ext, cythonize if sys.platform == "darwin": unix_copt = ["-Xpreprocessor", "-fopenmp", "-lomp"] @@ -62,9 +62,7 @@ def run(self): plugin_folder = plugins_folder.joinpath(p) self.announce("Compiling plugin: {}".format(p)) os.chdir(plugin_folder) - subprocess.check_call( - [sys.executable, "setup.py", "build_ext", "--inplace"] - ) + subprocess.check_call([sys.executable, "setup.py", "build_ext", "--inplace"]) os.chdir(inv_folder) @@ -97,6 +95,11 @@ def run(self): ["invesalius_cy/cy_mesh.pyx"], language="c++", ), + setuptools.Extension( + "invesalius_cy.mask_cut", + ["invesalius_cy/mask_cut.pyx"], + language="c++", + ), ] ), )