diff --git a/invesalius/constants.py b/invesalius/constants.py index ced611b..64745ae 100644 --- a/invesalius/constants.py +++ b/invesalius/constants.py @@ -517,6 +517,7 @@ ID_CLEAN_MASK = wx.NewId() ID_REORIENT_IMG = wx.NewId() ID_FLOODFILL_MASK = wx.NewId() +ID_FILL_HOLE_AUTO = wx.NewId() ID_REMOVE_MASK_PART = wx.NewId() ID_SELECT_MASK_PART = wx.NewId() ID_FLOODFILL_SEGMENTATION = wx.NewId() diff --git a/invesalius/data/floodfill.pyx b/invesalius/data/floodfill.pyx index 954adc2..6636cc9 100644 --- a/invesalius/data/floodfill.pyx +++ b/invesalius/data/floodfill.pyx @@ -4,7 +4,9 @@ cimport cython from collections import deque +from cython.parallel import prange from libc.math cimport floor, ceil +from libcpp cimport bool from libcpp.deque cimport deque as cdeque from libcpp.vector cimport vector @@ -230,3 +232,43 @@ def floodfill_auto_threshold(np.ndarray[image_t, ndim=3] data, list seeds, float if to_return: return out + + +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.nonecheck(False) +def fill_holes_automatically(np.ndarray[mask_t, ndim=3] mask, np.ndarray[np.uint16_t, ndim=3] labels, unsigned int nlabels, unsigned int max_size): + """ + Fill mask holes automatically. The hole must <= max_size. Return True if any hole were filled. + """ + cdef np.ndarray[np.uint32_t, ndim=1] sizes = np.zeros(shape=(nlabels + 1), dtype=np.uint32) + cdef int x, y, z + cdef int dx, dy, dz + cdef int i + + cdef bool modified = False + + dz = mask.shape[0] + dy = mask.shape[1] + dx = mask.shape[2] + + for z in xrange(dz): + for y in xrange(dy): + for x in xrange(dx): + sizes[labels[z, y, x]] += 1 + + #Checking if any hole will be filled + for i in xrange(nlabels + 1): + if sizes[i] <= max_size: + modified = True + + if not modified: + return 0 + + for z in prange(dz, nogil=True): + for y in xrange(dy): + for x in xrange(dx): + if sizes[labels[z, y, x]] <= max_size: + mask[z, y, x] = 254 + + return modified diff --git a/invesalius/data/mask.py b/invesalius/data/mask.py index 510b2c4..d00d22e 100644 --- a/invesalius/data/mask.py +++ b/invesalius/data/mask.py @@ -23,15 +23,17 @@ import random import shutil import tempfile -import numpy +import numpy as np import vtk import invesalius.constants as const import invesalius.data.imagedata_utils as iu import invesalius.session as ses -from wx.lib.pubsub import pub as Publisher +from . import floodfill +from wx.lib.pubsub import pub as Publisher +from scipy import ndimage class EditionHistoryNode(object): def __init__(self, index, orientation, array, clean=False): @@ -43,11 +45,11 @@ class EditionHistoryNode(object): self._save_array(array) def _save_array(self, array): - numpy.save(self.filename, array) + np.save(self.filename, array) print "Saving history", self.index, self.orientation, self.filename, self.clean def commit_history(self, mvolume): - array = numpy.load(self.filename) + array = np.load(self.filename) if self.orientation == 'AXIAL': mvolume[self.index+1,1:,1:] = array if self.clean: @@ -295,7 +297,7 @@ class Mask(): def _open_mask(self, filename, shape, dtype='uint8'): print ">>", filename, shape self.temp_file = filename - self.matrix = numpy.memmap(filename, shape=shape, dtype=dtype, mode="r+") + self.matrix = np.memmap(filename, shape=shape, dtype=dtype, mode="r+") def _set_class_index(self, index): Mask.general_index = index @@ -309,7 +311,7 @@ class Mask(): """ self.temp_file = tempfile.mktemp() shape = shape[0] + 1, shape[1] + 1, shape[2] + 1 - self.matrix = numpy.memmap(self.temp_file, mode='w+', dtype='uint8', shape=shape) + self.matrix = np.memmap(self.temp_file, mode='w+', dtype='uint8', shape=shape) def clean(self): self.matrix[1:, 1:, 1:] = 0 @@ -340,6 +342,49 @@ class Mask(): def clear_history(self): self.history.clear_history() + def fill_holes_auto(self, target, conn, orientation, index, size): + CON2D = {4: 1, 8: 2} + CON3D = {6: 1, 18: 2, 26: 3} + + if target == '3D': + cp_mask = self.matrix.copy() + matrix = self.matrix[1:, 1:, 1:] + bstruct = ndimage.generate_binary_structure(3, CON3D[conn]) + + imask = (~(matrix > 127)) + labels, nlabels = ndimage.label(imask, bstruct, output=np.uint16) + + if nlabels == 0: + return + + ret = floodfill.fill_holes_automatically(matrix, labels, nlabels, size) + if ret: + self.save_history(index, orientation, self.matrix.copy(), cp_mask) + else: + bstruct = ndimage.generate_binary_structure(2, CON2D[conn]) + + if orientation == 'AXIAL': + matrix = self.matrix[index+1, 1:, 1:] + elif orientation == 'CORONAL': + matrix = self.matrix[1:, index+1, 1:] + elif orientation == 'SAGITAL': + matrix = self.matrix[1:, 1:, index+1] + + cp_mask = matrix.copy() + + imask = (~(matrix > 127)) + labels, nlabels = ndimage.label(imask, bstruct, output=np.uint16) + + if nlabels == 0: + return + + labels = labels.reshape(1, labels.shape[0], labels.shape[1]) + matrix = matrix.reshape(1, matrix.shape[0], matrix.shape[1]) + + ret = floodfill.fill_holes_automatically(matrix, labels, nlabels, size) + if ret: + self.save_history(index, orientation, matrix.copy(), cp_mask) + def __del__(self): if self.is_shown: self.history._config_undo_redo(False) diff --git a/invesalius/data/slice_.py b/invesalius/data/slice_.py index da1f752..65433bb 100644 --- a/invesalius/data/slice_.py +++ b/invesalius/data/slice_.py @@ -194,6 +194,8 @@ class Slice(object): Publisher.subscribe(self.__undo_edition, 'Undo edition') Publisher.subscribe(self.__redo_edition, 'Redo edition') + + Publisher.subscribe(self._fill_holes_auto, 'Fill holes automatically') def GetMaxSliceNumber(self, orientation): shape = self.matrix.shape @@ -1482,3 +1484,28 @@ class Slice(object): #filename, filetype = pubsub_evt.data #if (filetype == const.FILETYPE_IMAGEDATA): #iu.Export(imagedata, filename) + + def _fill_holes_auto(self, pubsub_evt): + data = pubsub_evt.data + target = data['target'] + conn = data['conn'] + orientation = data['orientation'] + size = data['size'] + + if target == '2D': + index = self.buffer_slices[orientation].index + else: + index = 0 + self.do_threshold_to_all_slices() + + self.current_mask.fill_holes_auto(target, conn, orientation, index, size) + + self.buffer_slices['AXIAL'].discard_mask() + self.buffer_slices['CORONAL'].discard_mask() + self.buffer_slices['SAGITAL'].discard_mask() + + self.buffer_slices['AXIAL'].discard_vtk_mask() + self.buffer_slices['CORONAL'].discard_vtk_mask() + self.buffer_slices['SAGITAL'].discard_vtk_mask() + + Publisher.sendMessage('Reload actual slice') diff --git a/invesalius/gui/dialogs.py b/invesalius/gui/dialogs.py index 9dd8a90..bc4db77 100644 --- a/invesalius/gui/dialogs.py +++ b/invesalius/gui/dialogs.py @@ -1863,11 +1863,11 @@ class PanelTargeFFill(wx.Panel): self.Layout() class Panel2DConnectivity(wx.Panel): - def __init__(self, parent, ID=-1, style=wx.TAB_TRAVERSAL|wx.NO_BORDER): + def __init__(self, parent, ID=-1, show_orientation=False, style=wx.TAB_TRAVERSAL|wx.NO_BORDER): wx.Panel.__init__(self, parent, ID, style=style) - self._init_gui() + self._init_gui(show_orientation) - def _init_gui(self): + def _init_gui(self, show_orientation): self.conect2D_4 = wx.RadioButton(self, -1, "4", style=wx.RB_GROUP) self.conect2D_8 = wx.RadioButton(self, -1, "8") @@ -1879,10 +1879,33 @@ class Panel2DConnectivity(wx.Panel): sizer.Add(self.conect2D_8, (2, 1), flag=wx.LEFT, border=7) sizer.AddStretchSpacer((3, 0)) + if show_orientation: + self.cmb_orientation = wx.ComboBox(self, -1, choices=(_(u"Axial"), _(u"Coronal"), _(u"Sagital")), style=wx.CB_READONLY) + self.cmb_orientation.SetSelection(0) + + sizer.Add(wx.StaticText(self, -1, _(u"Orientation")), (4, 0), (1, 6), flag=wx.LEFT|wx.RIGHT|wx.ALIGN_CENTER_VERTICAL, border=5) + sizer.Add(self.cmb_orientation, (5, 0), (1, 10), flag=wx.LEFT|wx.RIGHT|wx.EXPAND, border=7) + sizer.AddStretchSpacer((6, 0)) + self.SetSizer(sizer) sizer.Fit(self) self.Layout() + def GetConnSelected(self): + if self.conect2D_4.GetValue(): + return 4 + else: + return 8 + + def GetOrientation(self): + dic_ori = { + _(u"Axial"): 'AXIAL', + _(u"Coronal"): 'CORONAL', + _(u"Sagital"): 'SAGITAL' + } + + return dic_ori[self.cmb_orientation.GetStringSelection()] + class Panel3DConnectivity(wx.Panel): def __init__(self, parent, ID=-1, style=wx.TAB_TRAVERSAL|wx.NO_BORDER): @@ -1907,6 +1930,14 @@ class Panel3DConnectivity(wx.Panel): sizer.Fit(self) self.Layout() + def GetConnSelected(self): + if self.conect3D_6.GetValue(): + return 6 + elif self.conect3D_18.GetValue(): + return 18 + else: + return 26 + class PanelFFillThreshold(wx.Panel): def __init__(self, parent, config, ID=-1, style=wx.TAB_TRAVERSAL|wx.NO_BORDER): @@ -2473,3 +2504,101 @@ class CropOptionsDialog(wx.Dialog): Publisher.sendMessage('Disable style', const.SLICE_STATE_CROP_MASK) evt.Skip() self.Destroy() + + +class FillHolesAutoDialog(wx.Dialog): + def __init__(self, title): + pre = wx.PreDialog() + pre.Create(wx.GetApp().GetTopWindow(), -1, title, style=wx.DEFAULT_DIALOG_STYLE|wx.FRAME_FLOAT_ON_PARENT) + self.PostCreate(pre) + + self._init_gui() + + def _init_gui(self): + if sys.platform == "win32": + border_style = wx.SIMPLE_BORDER + else: + border_style = wx.SUNKEN_BORDER + + self.spin_size = wx.SpinCtrl(self, -1, value='1000', min=1, max=1000000000) + self.panel_target = PanelTargeFFill(self, style=border_style|wx.TAB_TRAVERSAL) + self.panel2dcon = Panel2DConnectivity(self, show_orientation=True, style=border_style|wx.TAB_TRAVERSAL) + self.panel3dcon = Panel3DConnectivity(self, style=border_style|wx.TAB_TRAVERSAL) + + self.panel_target.target_2d.SetValue(1) + self.panel2dcon.Enable(1) + self.panel3dcon.Enable(0) + + self.apply_btn = wx.Button(self, wx.ID_APPLY) + self.close_btn = wx.Button(self, wx.ID_CLOSE) + + # Sizer + sizer = wx.BoxSizer(wx.VERTICAL) + + sizer.AddSpacer(5) + sizer.Add(wx.StaticText(self, -1, _(u"Parameters")), flag=wx.LEFT, border=5) + sizer.AddSpacer(5) + + sizer.Add(self.panel_target, flag=wx.LEFT|wx.RIGHT|wx.EXPAND, border=7) + sizer.AddSpacer(5) + sizer.Add(self.panel2dcon, flag=wx.LEFT|wx.RIGHT|wx.EXPAND, border=7) + sizer.AddSpacer(5) + sizer.Add(self.panel3dcon, flag=wx.LEFT|wx.RIGHT|wx.EXPAND, border=7) + sizer.AddSpacer(5) + + spin_sizer = wx.BoxSizer(wx.HORIZONTAL) + spin_sizer.Add(wx.StaticText(self, -1, _(u"Max hole size")), flag=wx.LEFT|wx.ALIGN_CENTER_VERTICAL, border=5) + spin_sizer.Add(self.spin_size, 0, flag=wx.LEFT|wx.RIGHT, border=5) + spin_sizer.Add(wx.StaticText(self, -1, _(u"voxels")), flag=wx.RIGHT|wx.ALIGN_CENTER_VERTICAL, border=5) + + sizer.Add(spin_sizer, 0, flag=wx.LEFT|wx.RIGHT|wx.EXPAND, border=7) + sizer.AddSpacer(5) + + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + btn_sizer.Add(self.apply_btn, 0, flag=wx.ALIGN_RIGHT, border=5) + btn_sizer.Add(self.close_btn, 0, flag=wx.LEFT|wx.ALIGN_RIGHT, border=5) + + sizer.AddSizer(btn_sizer, 0, flag=wx.ALIGN_RIGHT|wx.LEFT|wx.RIGHT, border=5) + + sizer.AddSpacer(5) + + self.SetSizer(sizer) + sizer.Fit(self) + self.Layout() + + self.apply_btn.Bind(wx.EVT_BUTTON, self.OnApply) + self.close_btn.Bind(wx.EVT_BUTTON, self.OnBtnClose) + self.Bind(wx.EVT_RADIOBUTTON, self.OnSetRadio) + + def OnApply(self, evt): + if self.panel_target.target_2d.GetValue(): + target = "2D" + conn = self.panel2dcon.GetConnSelected() + orientation = self.panel2dcon.GetOrientation() + else: + target = "3D" + conn = self.panel3dcon.GetConnSelected() + orientation = 'VOLUME' + + data = { + 'target': target, + 'conn': conn, + 'orientation': orientation, + 'size': self.spin_size.GetValue(), + } + + Publisher.sendMessage("Fill holes automatically", data) + + + def OnBtnClose(self, evt): + self.Close() + self.Destroy() + + def OnSetRadio(self, evt): + # Target + if self.panel_target.target_2d.GetValue(): + self.panel2dcon.Enable(1) + self.panel3dcon.Enable(0) + else: + self.panel3dcon.Enable(1) + self.panel2dcon.Enable(0) diff --git a/invesalius/gui/frame.py b/invesalius/gui/frame.py index 3afa1f5..b6c4bab 100644 --- a/invesalius/gui/frame.py +++ b/invesalius/gui/frame.py @@ -445,6 +445,9 @@ class Frame(wx.Frame): elif id == const.ID_FLOODFILL_MASK: self.OnFillHolesManually() + elif id == const.ID_FILL_HOLE_AUTO: + self.OnFillHolesAutomatically() + elif id == const.ID_REMOVE_MASK_PART: self.OnRemoveMaskParts() @@ -592,6 +595,10 @@ class Frame(wx.Frame): def OnFillHolesManually(self): Publisher.sendMessage('Enable style', const.SLICE_STATE_MASK_FFILL) + def OnFillHolesAutomatically(self): + fdlg = dlg.FillHolesAutoDialog(_(u"Fill holes automatically")) + fdlg.Show() + def OnRemoveMaskParts(self): Publisher.sendMessage('Enable style', const.SLICE_STATE_REMOVE_MASK_PARTS) @@ -628,6 +635,7 @@ class MenuBar(wx.MenuBar): const.ID_PROJECT_CLOSE, const.ID_REORIENT_IMG, const.ID_FLOODFILL_MASK, + const.ID_FILL_HOLE_AUTO, const.ID_REMOVE_MASK_PART, const.ID_SELECT_MASK_PART, const.ID_FLOODFILL_SEGMENTATION,] @@ -743,17 +751,23 @@ class MenuBar(wx.MenuBar): self.clean_mask_menu.Enable(False) mask_menu.AppendSeparator() + self.fill_hole_mask_menu = mask_menu.Append(const.ID_FLOODFILL_MASK, _(u"Fill holes manually")) self.fill_hole_mask_menu.Enable(False) + self.fill_hole_auto_menu = mask_menu.Append(const.ID_FILL_HOLE_AUTO, _(u"Fill holes automatically")) + self.fill_hole_mask_menu.Enable(False) + + mask_menu.AppendSeparator() + self.remove_mask_part_menu = mask_menu.Append(const.ID_REMOVE_MASK_PART, _(u"Remove parts")) self.remove_mask_part_menu.Enable(False) self.select_mask_part_menu = mask_menu.Append(const.ID_SELECT_MASK_PART, _(u"Select parts")) self.select_mask_part_menu.Enable(False) - + mask_menu.AppendSeparator() - + self.crop_mask_menu = mask_menu.Append(const.ID_CROP_MASK, _("Crop")) self.crop_mask_menu.Enable(False) -- libgit2 0.21.2