Skip to content

Commit

Permalink
FEAT: Implemented frontal 3D editing (no rotation)
Browse files Browse the repository at this point in the history
Functionality for using the polygon drawn in the volume viewer to cut
the mask has been implemented.
  • Loading branch information
henriquenunez committed Aug 9, 2024
1 parent c24cd83 commit 7aeb2f3
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 14 deletions.
2 changes: 1 addition & 1 deletion invesalius/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------
Expand Down
16 changes: 8 additions & 8 deletions invesalius/gui/task_slice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
160 changes: 160 additions & 0 deletions invesalius/segmentation/mask_3d_edit.py
Original file line number Diff line number Diff line change
@@ -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: [email protected]
# 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")
62 changes: 62 additions & 0 deletions invesalius_cy/mask_cut.pyx
Original file line number Diff line number Diff line change
@@ -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 = <float>(x*sx)
p1 = <float>(y*sy)
p2 = <float>(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[<int>round(py), <int>round(px)]:
out[z, y, x] = 0
13 changes: 8 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import setuptools
import logging
import os
import pathlib
import subprocess
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"]
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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++",
),
]
),
)

0 comments on commit 7aeb2f3

Please sign in to comment.