Skip to content

Commit 5eae75a

Browse files
committed
[release] improve brain1020 to be more robust,
porting fangq/brain2mesh@2e2760e to handle neck/shoulder and multi-loop cuts
1 parent cdcbc2c commit 5eae75a

File tree

6 files changed

+170
-23
lines changed

6 files changed

+170
-23
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
* **Copyright**: (C) Qianqian Fang (2024-2025) <q.fang at neu.edu>
66
* **License**: GNU Public License V3 or later
7-
* **Version**: 0.5.1
7+
* **Version**: 0.5.2
88
* **URL**: [https://pypi.org/project/iso2mesh/](https://pypi.org/project/iso2mesh/)
99
* **Homepage**: [https://iso2mesh.sf.net](https://iso2mesh.sf.net)
1010
* **Github**: [https://github.com/NeuroJSON/pyiso2mesh](https://github.com/NeuroJSON/pyiso2mesh)

iso2mesh/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@
205205
closestnode,
206206
polylineinterp,
207207
polylinesimplify,
208+
maxloop,
208209
)
209210

210211
from .raster import (
@@ -241,7 +242,7 @@
241242
thinbinvol,
242243
)
243244

244-
__version__ = "0.5.1"
245+
__version__ = "0.5.2"
245246
__all__ = [
246247
"advancefront",
247248
"barycentricgrid",
@@ -362,6 +363,7 @@
362363
"closestnode",
363364
"polylineinterp",
364365
"polylinesimplify",
366+
"maxloop",
365367
"affinemap",
366368
"meshinterp",
367369
"meshremap",

iso2mesh/brain2mesh.py

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@
3737
)
3838
from iso2mesh.geometry import meshabox
3939
from iso2mesh.volume import volgrow, fillholes3d
40-
from iso2mesh.line import polylineinterp, polylinelen, polylinesimplify, closestnode
40+
from iso2mesh.line import (
41+
polylineinterp,
42+
polylinelen,
43+
polylinesimplify,
44+
closestnode,
45+
maxloop,
46+
)
4147
from iso2mesh.plot import plotmesh
4248

4349
from typing import Tuple, Dict, Union, Optional, List
@@ -1367,11 +1373,54 @@ def brain1020(
13671373
landmarks["cz"],
13681374
]
13691375
)
1370-
1376+
else:
1377+
landmarks = {
1378+
"nz": initpoints[0, :],
1379+
"iz": initpoints[1, :],
1380+
"lpa": initpoints[2, :],
1381+
"rpa": initpoints[3, :],
1382+
"cz": initpoints[4, :],
1383+
}
13711384
# Convert tetrahedral mesh into a surface mesh
13721385
if face.shape[1] >= 4:
13731386
face = volface(face[:, :4])[0] # Use first 4 columns for tetrahedral faces
13741387

1388+
if kwargs.get("clean", 1):
1389+
# Find the bounding box of the top part of the head, and remove all other triangles
1390+
p0 = landmarks.copy()
1391+
1392+
v_ni = p0["nz"] - p0["iz"]
1393+
v_lr = p0["lpa"] - p0["rpa"]
1394+
v_cz0 = np.cross(v_ni, v_lr)
1395+
v_cz0 = v_cz0 / np.linalg.norm(v_cz0)
1396+
v_cn = p0["cz"] - p0["nz"]
1397+
d_czlpa = np.dot(p0["cz"] - p0["lpa"], v_cz0)
1398+
d_cznz = np.dot(v_cn, v_cz0)
1399+
1400+
if abs(d_czlpa) > abs(d_cznz): # if lpa is further away from cz than nz
1401+
# Move nz to the same level as lpa, can also add rpa
1402+
p0["nz"] = p0["nz"] - v_cz0 * (abs(d_czlpa) - abs(d_cznz))
1403+
# Move iz to the same level as lpa, can also add rpa
1404+
p0["iz"] = p0["iz"] - v_cz0 * (abs(d_czlpa) - abs(d_cznz))
1405+
1406+
v_cz = d_cznz * v_cz0
1407+
bbx0 = p0["nz"] - 0.6 * v_lr - 0.1 * v_cz + 0.1 * v_ni
1408+
1409+
# Calculate mesh centroids
1410+
c0 = meshcentroid(node, face)
1411+
1412+
# Calculate distance from bounding box plane
1413+
v_cz0_rep = np.tile(
1414+
v_cz0, (face.shape[0], 1)
1415+
) # repmat(v_cz0, size(face, 1), 1)
1416+
bbx0_rep = np.tile(bbx0, (face.shape[0], 1)) # repmat(bbx0, size(face, 1), 1)
1417+
dz = np.sum(v_cz0_rep * (c0 - bbx0_rep), axis=1)
1418+
1419+
# Filter faces - keep only those with dz > 0
1420+
face = face[dz > 0, :]
1421+
1422+
del p0, bbx0, c0, dz
1423+
13751424
# Remove nodes not located in the surface
13761425
node, face, _ = removeisolatednode(node, face)
13771426

@@ -1387,16 +1436,6 @@ def brain1020(
13871436
print("Initial points:")
13881437
print(initpoints)
13891438

1390-
# Save input initpoints to landmarks output, cz is not finalized
1391-
if initpoints.shape[0] >= 5:
1392-
landmarks = {
1393-
"nz": initpoints[0, :],
1394-
"iz": initpoints[1, :],
1395-
"lpa": initpoints[2, :],
1396-
"rpa": initpoints[3, :],
1397-
"cz": initpoints[4, :],
1398-
}
1399-
14001439
# At this point, initpoints contains {nz, iz, lpa, rpa, cz0}
14011440
# Plot the head mesh
14021441
if showplot:
@@ -1417,7 +1456,8 @@ def brain1020(
14171456
while np.linalg.norm(initpoints[4, :] - lastcz) > tol and cziter < maxcziter:
14181457

14191458
# Step 1: nz, iz and cz0 to determine saggital reference curve
1420-
nsagg = slicesurf(node, face, initpoints[[0, 1, 4], :])
1459+
nsagg, curveloop, _ = slicesurf(node, face, initpoints[[0, 1, 4], :], full=True)
1460+
nsagg = nsagg[maxloop(curveloop) - 1, :]
14211461

14221462
# Step 1.1: get cz1 as the mid-point between iz and nz
14231463
slen, nsagg, _ = polylinelen(
@@ -1429,7 +1469,11 @@ def brain1020(
14291469
initpoints[4, :] = cz[0, :]
14301470

14311471
# Step 1.2: lpa, rpa and cz1 to determine coronal reference curve, update cz1
1432-
curves["cm"] = slicesurf(node, face, initpoints[[2, 3, 4], :])
1472+
curves["cm"], curveloop, _ = slicesurf(
1473+
node, face, initpoints[[2, 3, 4], :], full=True
1474+
)
1475+
curves["cm"] = curves["cm"][maxloop(curveloop) - 1, :]
1476+
14331477
len_cm, curves["cm"], _ = polylinelen(
14341478
curves["cm"], initpoints[2, :], initpoints[3, :], initpoints[4, :]
14351479
)
@@ -1458,7 +1502,11 @@ def brain1020(
14581502
)
14591503
landmarks["cm"] = coro # t7, c3, cz, c4, t8
14601504

1461-
curves["sm"] = slicesurf(node, face, initpoints[[0, 1, 4], :])
1505+
curves["sm"], curveloop, _ = slicesurf(
1506+
node, face, initpoints[[0, 1, 4], :], full=True
1507+
)
1508+
curves["sm"] = curves["sm"][maxloop(curveloop) - 1, :]
1509+
14621510
slen, curves["sm"], _ = polylinelen(
14631511
curves["sm"], initpoints[0, :], initpoints[1, :], initpoints[4, :]
14641512
)
@@ -1475,6 +1523,8 @@ def brain1020(
14751523
landmarks["cm"][0, :],
14761524
landmarks["sm"][-1, :],
14771525
perc2 * 2,
1526+
0,
1527+
maxloop=1,
14781528
)
14791529

14801530
# Step 4: fpz, t8 and oz to determine right 10% axial reference curve
@@ -1485,6 +1535,8 @@ def brain1020(
14851535
landmarks["cm"][-1, :],
14861536
landmarks["sm"][-1, :],
14871537
perc2 * 2,
1538+
0,
1539+
maxloop=1,
14881540
)
14891541

14901542
# Show plots of the landmarks
@@ -1611,6 +1663,8 @@ def brain1020(
16111663
landmarks["sm"][idxcz - 1 - i, :],
16121664
landmarks["aar"][i - 1, :],
16131665
step,
1666+
0,
1667+
maxloop=1,
16141668
)
16151669

16161670
landmarks[f"cal_{i}"] = cal_landmarks
@@ -1661,6 +1715,8 @@ def brain1020(
16611715
landmarks["sm"][idxcz - 1 + i, :],
16621716
landmarks["apr"][i - 1, :],
16631717
step,
1718+
0,
1719+
maxloop=1,
16641720
)
16651721

16661722
landmarks[f"cpl_{i}"] = cpl_landmarks
@@ -1696,15 +1752,29 @@ def brain1020(
16961752
landmarks["papl"],
16971753
curves["papl"],
16981754
) = slicesurf3(
1699-
node, face, landmarks["nz"], landmarks["lpa"], landmarks["iz"], perc2 * 2
1755+
node,
1756+
face,
1757+
landmarks["nz"],
1758+
landmarks["lpa"],
1759+
landmarks["iz"],
1760+
perc2 * 2,
1761+
0,
1762+
maxloop=1,
17001763
)
17011764
(
17021765
landmarks["paar"],
17031766
curves["paar"],
17041767
landmarks["papr"],
17051768
curves["papr"],
17061769
) = slicesurf3(
1707-
node, face, landmarks["nz"], landmarks["rpa"], landmarks["iz"], perc2 * 2
1770+
node,
1771+
face,
1772+
landmarks["nz"],
1773+
landmarks["rpa"],
1774+
landmarks["iz"],
1775+
perc2 * 2,
1776+
0,
1777+
maxloop=1,
17081778
)
17091779

17101780
if showplot:

iso2mesh/line.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"closestnode",
1212
"polylineinterp",
1313
"polylinesimplify",
14+
"maxloop",
1415
]
1516
##====================================================================================
1617
## dependent libraries
@@ -349,3 +350,69 @@ def segvec(n1, n2):
349350
length = np.array([])
350351

351352
return newnodes, length
353+
354+
355+
def maxloop(curveloop: np.ndarray) -> np.ndarray:
356+
"""
357+
Return the curve segment that has the largest number of nodes
358+
359+
Author: Qianqian Fang, <q.fang at neu.edu>
360+
Python conversion: Preserves exact algorithm from MATLAB version
361+
362+
Parameters:
363+
-----------
364+
curveloop : ndarray (1D)
365+
Curves defined by 1-based node indices, separated by nan.
366+
The values in this array are node indices (1-based from MATLAB),
367+
not Python array positions. NaN values serve as segment separators.
368+
369+
Returns:
370+
--------
371+
newloop : ndarray (1D)
372+
The 1-based node indices defining the longest segment.
373+
These are the actual node index values, maintaining 1-based indexing.
374+
375+
Notes:
376+
------
377+
This function is part of iso2mesh toolbox (http://iso2mesh.sf.net)
378+
379+
Important: curveloop and newloop contain 1-based node indices as data values.
380+
The indexing for array access (loopend positions) uses Python's 0-based indexing,
381+
but the actual node index values remain 1-based throughout.
382+
"""
383+
384+
newloop = curveloop.copy()
385+
386+
# Find array positions where nan occurs - equivalent to find(isnan(curveloop))
387+
# loopend contains Python 0-based positions in the curveloop array where NaN appears
388+
loopend = np.where(np.isnan(curveloop))[0]
389+
390+
if len(loopend) > 1: # length(loopend) > 1
391+
# Calculate segment lengths - equivalent to [loopend(1), diff(loopend)]
392+
# MATLAB: seglen = [loopend(1), diff(loopend)];
393+
# seglen[0] = number of elements before first NaN
394+
# seglen[i] = number of elements between consecutive NaNs
395+
seglen = np.concatenate(([loopend[0]], np.diff(loopend)))
396+
397+
# Find maximum segment length and its location
398+
# MATLAB: [maxlen, maxloc] = max(seglen);
399+
maxloc = np.argmax(seglen)
400+
maxlen = seglen[maxloc]
401+
402+
# Prepend 0 to loopend - equivalent to loopend = [0 loopend];
403+
# This represents a virtual NaN at position -1 for easier indexing
404+
loopend = np.concatenate(([-1], loopend))
405+
406+
# Extract the longest segment
407+
# MATLAB: newloop = curveloop((loopend(maxloc)+1):(loopend(maxloc+1)-maxloc));
408+
# We're extracting array elements, so we use Python 0-based array indexing
409+
# But the VALUES we extract are 1-based node indices that we keep as-is
410+
start_idx = loopend[maxloc] + 1 # Position after the NaN (or start)
411+
end_idx = loopend[maxloc + 1] # Position of the next NaN
412+
newloop = curveloop[start_idx:end_idx]
413+
414+
# Remove any remaining nan values - equivalent to newloop(isnan(newloop)) = [];
415+
# The node index values in newloop remain 1-based
416+
newloop = newloop[~np.isnan(newloop)]
417+
418+
return newloop.astype(int)

iso2mesh/modify.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@
4444
surfedge,
4545
extractloops,
4646
)
47-
from iso2mesh.line import getplanefrom3pt, polylinesimplify, polylinelen, polylineinterp
47+
from iso2mesh.line import (
48+
getplanefrom3pt,
49+
polylinesimplify,
50+
polylinelen,
51+
polylineinterp,
52+
maxloop,
53+
)
4854

4955
##====================================================================================
5056
## implementations
@@ -1156,7 +1162,7 @@ def slicesurf(node, face, *args, **kwargs):
11561162
return bcutpos, bcutloop, bcutvalue
11571163

11581164

1159-
def slicesurf3(node, elem, p1, p2, p3, step=None, minangle=None):
1165+
def slicesurf3(node, elem, p1, p2, p3, step=None, minangle=None, **kwargs):
11601166
"""
11611167
slicesurf3(node, elem, p1, p2, p3, step=None, minangle=None)
11621168
@@ -1187,7 +1193,9 @@ def slicesurf3(node, elem, p1, p2, p3, step=None, minangle=None):
11871193
"""
11881194

11891195
# Slice full curve through p1-p2-p3
1190-
fullcurve = slicesurf(node, elem, np.vstack((p1, p2, p3)))
1196+
fullcurve, curveloop, _ = slicesurf(node, elem, np.vstack((p1, p2, p3)), full=True)
1197+
if kwargs.get("maxloop", 0):
1198+
fullcurve = fullcurve[maxloop(curveloop) - 1, :]
11911199

11921200
# Optional simplification
11931201
if minangle is not None and minangle > 0:

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
setup(
77
name="iso2mesh",
88
packages=["iso2mesh"],
9-
version="0.5.1",
9+
version="0.5.2",
1010
license="GPLv3+",
1111
description="One-liner 3D Surface and Tetrahedral Mesh Generation Toolbox",
1212
long_description=readme,

0 commit comments

Comments
 (0)