3D binary image analysis¶

This tutorial explains how to analyze a 2D binary image by persistent homology. The basic idea is the same as 2D binary image analysis, so please try this tutorial after you have finished that tutorial.

Here, 50x50x50 size image is analyzed. There are various ways to represent 3D images, but here we use cross-sectional images for each layer. In other words, prepare 50x50x50 images. The file is located in the 'data/' directory. Name the file in the order of the sections.

In [1]:
ls data/
0000.png  0007.png  0014.png  0021.png  0028.png  0035.png  0042.png  0049.png
0001.png  0008.png  0015.png  0022.png  0029.png  0036.png  0043.png
0002.png  0009.png  0016.png  0023.png  0030.png  0037.png  0044.png
0003.png  0010.png  0017.png  0024.png  0031.png  0038.png  0045.png
0004.png  0011.png  0018.png  0025.png  0032.png  0039.png  0046.png
0005.png  0012.png  0019.png  0026.png  0033.png  0040.png  0047.png
0006.png  0013.png  0020.png  0027.png  0034.png  0041.png  0048.png

Read data file¶

First, read this 50x50x50 image and make a 50x50x50 black and white array.

In [2]:
# Loading required libraries
import imageio
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

Before creating a 3D array, load 0000.png and check what image it is.

In [3]:
image_0000 = imageio.v3.imread("data/0000.png")
In [4]:
image_0000.shape, image_0000.dtype, np.max(image_0000), np.min(image_0000)
Out[4]:
((50, 50), dtype('uint8'), 255, 0)

This is a 50x50 array with values from 0 to 255. So this image is like a grayscale image. Let's look at the contents and actually display them.

In [5]:
image_0000
Out[5]:
array([[255, 255, 255, ..., 255, 255, 255],
       [255, 255, 255, ..., 255, 255, 255],
       [255, 255, 255, ..., 255, 255, 255],
       ...,
       [255, 255, 255, ...,   0,   0,   0],
       [255, 255, 255, ...,   0,   0,   0],
       [255, 255, 255, ...,   0,   0,   0]], dtype=uint8)
In [6]:
plt.imshow(image_0000, "gray")
Out[6]:
<matplotlib.image.AxesImage at 0x7ff23affa530>
No description has been provided for this image

Now load 50 images and stack them.

In [7]:
pict = np.stack([
    imageio.v3.imread("data/{:04d}.png".format(n)) > 128
    for n in range(50) 
], axis=0)
In [8]:
pict.shape, pict.dtype
Out[8]:
((50, 50, 50), dtype('bool'))

The 50x50x50 image has been completed. Let's visualize this in 3D. We use pyvista. A translucent representation is realized by opacity=0.7.

In [9]:
import pyvista as pv  # Load pyvsta module
import homcloud.pyvistahelper as pvhelper  # HomCloud's pyvista helpers

pv.set_jupyter_backend('trame') 
In [10]:
pl = pv.Plotter()
pl.add_mesh(pvhelper.Bitmap3D(pict).threshold(0.5), show_scalar_bar=False, opacity=0.7)
pl.show()
Widget(value="<iframe src='http://localhost:35151/index.html?ui=P_0x7ff2b41a7730_0&reconnect=auto' style='widt…

Calculation of persistence diagram¶

Calculate persistence diagram. Use hc.PDList.from_bitmap_distance_function. Same as 2D.

In [11]:
import homcloud.interface as hc
In [12]:
pdlist = hc.PDList.from_bitmap_levelset(hc.distance_transform(pict, signed=True), save_to="binary-3d.pdgm")

You can also load the file as follows.

In [13]:
pdlist = hc.PDList("binary-3d.pdgm")

Analysis of persistence diagram (1st)¶

Let's look at the first persistence diagram.

In [14]:
pd1 = pdlist.dth_diagram(1)
In [15]:
pd1.histogram((-15.5, 10.5), 26).plot(colorbar={"type": "log"})
No description has been provided for this image

There seems to be two birth-death pairs at (-4, 4). Let's check this.

In [16]:
pairs = pd1.pairs_in_rectangle(-4, -4, 4, 4)
pairs
Out[16]:
[Pair(-4.0, 4.0), Pair(-4.0, 4.0)]

There are certainly two.

Inverse analysis(1st)¶

Let's take a look at what the two pairs represent. Because you are looking at the first-order persistence homology, you should be looking at some kind of ring structure. Let's calculate it.

Here you use the optimal cycle. In point cloud analysis, we used optimal volume and volume-optimal cycle. It's similar (slightly different) Calculate with Pair.optimal_1_cycle.

In [17]:
optimal_1_cycles = [pair.optimal_1_cycle() for pair in pairs]

Let's visualize this first. Displays the original voxel data superimposed.

In [18]:
pl = pv.Plotter()
pl.add_mesh(pvhelper.Bitmap3D(pict).threshold(0.5), show_scalar_bar=False, opacity=0.7)
for cycle in optimal_1_cycles:
    pl.add_mesh(cycle.to_pyvista_mesh(), color="red")
pl.show()
Widget(value="<iframe src='http://localhost:35151/index.html?ui=P_0x7ff1c78cf3d0_1&reconnect=auto' style='widt…

Please operate Paraview in various ways. Change the Opacity of voxel data in various ways, increase the thickness of optimal 1-cycles line, etc. Explore settings that are easy to see. As you can see from visualization, in the case of voxel data, the tunnel structure corresponds to the first-order birth-death pair.

Next, let's see what path this ring structure is taking. Use Optimal1CycleForBitmap.path. You can get the coordinates of pixel at the passing point.

In [19]:
optimal_1_cycles[0].path()
Out[19]:
[(49, 4, 44),
 (49, 4, 43),
 (49, 4, 42),
 (49, 4, 41),
 (49, 4, 40),
 (49, 4, 39),
 (49, 4, 38),
 (48, 4, 38),
 (48, 4, 37),
 (48, 4, 36),
 (47, 4, 36),
 (47, 4, 35),
 (47, 4, 34),
 (46, 4, 34),
 (45, 4, 34),
 (44, 4, 34),
 (43, 4, 34),
 (43, 4, 33),
 (42, 4, 33),
 (41, 4, 33),
 (40, 4, 33),
 (39, 4, 33),
 (38, 4, 33),
 (37, 4, 33),
 (36, 4, 33),
 (36, 5, 33),
 (35, 5, 33),
 (34, 5, 33),
 (34, 6, 33),
 (34, 7, 33),
 (34, 8, 33),
 (34, 9, 33),
 (34, 10, 33),
 (34, 11, 33),
 (34, 11, 34),
 (34, 11, 35),
 (34, 11, 36),
 (34, 11, 37),
 (34, 11, 38),
 (34, 11, 39),
 (34, 11, 40),
 (34, 11, 41),
 (34, 11, 42),
 (34, 11, 43),
 (34, 11, 44),
 (34, 11, 45),
 (35, 11, 45),
 (35, 11, 46),
 (36, 11, 46),
 (37, 11, 46),
 (37, 11, 47),
 (37, 11, 48),
 (38, 11, 48),
 (39, 11, 48),
 (40, 11, 48),
 (40, 10, 48),
 (41, 10, 48),
 (42, 10, 48),
 (43, 10, 48),
 (43, 9, 48),
 (44, 9, 48),
 (45, 9, 48),
 (45, 8, 48),
 (45, 8, 47),
 (46, 8, 47),
 (46, 7, 47),
 (46, 7, 46),
 (47, 7, 46),
 (47, 7, 45),
 (48, 7, 45),
 (48, 7, 44),
 (48, 6, 44),
 (48, 5, 44),
 (49, 5, 44),
 (49, 4, 44)]
In [20]:
optimal_1_cycles[1].path()
Out[20]:
[(16, 42, 45),
 (16, 41, 45),
 (16, 41, 46),
 (15, 41, 46),
 (15, 40, 46),
 (15, 40, 47),
 (14, 40, 47),
 (14, 39, 47),
 (14, 39, 48),
 (13, 39, 48),
 (13, 39, 49),
 (12, 39, 49),
 (11, 39, 49),
 (11, 38, 49),
 (11, 37, 49),
 (10, 37, 49),
 (10, 36, 49),
 (10, 35, 49),
 (10, 34, 49),
 (9, 34, 49),
 (9, 33, 49),
 (9, 32, 49),
 (8, 32, 49),
 (8, 31, 49),
 (8, 30, 49),
 (8, 29, 49),
 (8, 29, 48),
 (8, 29, 47),
 (8, 29, 46),
 (8, 29, 45),
 (8, 29, 44),
 (8, 29, 43),
 (8, 29, 42),
 (8, 29, 41),
 (9, 29, 41),
 (9, 29, 40),
 (9, 29, 39),
 (10, 29, 39),
 (10, 29, 38),
 (10, 29, 37),
 (10, 29, 36),
 (10, 29, 35),
 (10, 30, 35),
 (10, 31, 35),
 (10, 32, 35),
 (10, 33, 35),
 (11, 33, 35),
 (11, 34, 35),
 (12, 34, 35),
 (13, 34, 35),
 (13, 35, 35),
 (14, 35, 35),
 (14, 36, 35),
 (14, 37, 35),
 (14, 38, 35),
 (14, 39, 35),
 (14, 40, 35),
 (14, 41, 35),
 (14, 41, 36),
 (15, 41, 36),
 (15, 41, 37),
 (15, 42, 37),
 (15, 42, 38),
 (16, 42, 38),
 (16, 42, 39),
 (16, 42, 40),
 (17, 42, 40),
 (18, 42, 40),
 (18, 42, 41),
 (18, 42, 42),
 (18, 42, 43),
 (18, 42, 44),
 (18, 42, 45),
 (17, 42, 45),
 (16, 42, 45)]

Analysis of persistence diagram(0th) and its inverse analysis¶

Next, let's analyze the 0th-order persistence diagram.

First, plot a 0th-order persistence diagram.

In [21]:
pd0 = pdlist.dth_diagram(0)
pd0.histogram((-20.5, 20.5), 41).plot(colorbar={"type": "log"})
No description has been provided for this image

Let's take a closer look at the two birth-death pairs, (-16.0, -10.0), (-15.0, -10.0). Use a class called BitmapPHTrees for the 0th and 2nd order inverse analysis of voxel data(it cannot be used for the 1st order, so we used the optimal_1_cycle described above instead). BitmapPHTrees.for_bitmap_levelset has an interface similar to PDList.from_bitmap_levelset, calculate information for back analysis.

The name PHTrees means that what is computed here is a tree structure. BitmapPHTrees.for_bitmap_levelset simulateneously computes two forests for the information of 0th and (n-1)th PH of an n dimensional bitmap.

In [22]:
hc.BitmapPHTrees.for_bitmap_levelset(hc.distance_transform(pict, signed=True), 
                                     save_to="binary-3d-tree.pdgm")
Out[22]:
PDList(path=binary-3d-tree.pdgm)

Load the data calculated as follows.

In [23]:
pdlist_with_tree = hc.PDList("binary-3d-tree.pdgm")

Use bitmap_phtrees(0) to retrieve the 0th PH's information.

In [24]:
phtree_0 = pdlist_with_tree.bitmap_phtrees(0)

Use pair_nodes_in_rectangle to retrieve information on the two birth-death pairs (-16.0, -10.0), (-15.0, -10.0). This retrieves all birth-death pairs (with corresponding tree nodes) within the specified range.

In [25]:
nodes_0 = phtree_0.pair_nodes_in_rectangle(-16, -15, -10, -10)
nodes_0
Out[25]:
[BitmapPHTrees.Node(-15.0, -10.0), BitmapPHTrees.Node(-16.0, -10.0)]

The extracted data is an instance of the class called BitmapPHTrees.Node. Next, let's visualize this information.

In [26]:
pl = pv.Plotter()
pl.add_mesh(pvhelper.Bitmap3D(pict).threshold(0.5), show_scalar_bar=False, opacity=0.2)
pl.add_mesh(nodes_0[0].to_pyvista_mesh(), color="green")
pl.add_mesh(nodes_0[1].to_pyvista_mesh(), color="red", opacity=0.4)
pl.show()
Widget(value="<iframe src='http://localhost:35151/index.html?ui=P_0x7ff1c78f32e0_2&reconnect=auto' style='widt…

Since it is the 0th PH, it corresponds to the area inside the voxel. Actually, these two birth-death pairs are in a parent-child relationship on a tree structure, and one is a subset of the other.

The green area looks jagged, and something unnatural. In this example, we can solve the problem using stable volume instead. In many cases, it looks more natural if you use stable_volume(1) to shrink the volume by 1 voxel, as shown below.

In [27]:
pl = pv.Plotter()
pl.add_mesh(pvhelper.Bitmap3D(pict).threshold(0.5), show_scalar_bar=False, opacity=0.2)
pl.add_mesh(nodes_0[0].stable_volume(1).to_pyvista_mesh(), color="green")
pl.add_mesh(nodes_0[1].stable_volume(1).to_pyvista_mesh(), color="red", opacity=0.4)
pl.show()
Widget(value="<iframe src='http://localhost:35151/index.html?ui=P_0x7ff1c894ace0_3&reconnect=auto' style='widt…

Each clump looks better than before, and the two regions are now separated. This phenomenon means that the parent-child relationship between two birth-death pairs is easily corrupted by noise since the mechanism of stable volumes removes the noise-sensitive part of the volume.

Analysis of persistence diagram (secondary) and its inverse analysis¶

Finally, let's analyze the second-order persistence diagram.

Plot a second-order persistence diagram.

In [28]:
pd2 = pdlist.dth_diagram(2)
pd2.histogram((-20.5, 20.5), 41).plot(colorbar={"type": "log"})
No description has been provided for this image

Examine the birth-death pair called (4, 6). For inverse analysis, use bitmap_phtrees(2).

In [29]:
phtree_2 = pdlist_with_tree.bitmap_phtrees(2)

Let's look at the birth-death pair called (4, 6). First, extract all birth-death pairs (4, 6)(nodes of the tree corresponding to) with pair_nodes_in_rectangle.

In [30]:
nodes_2 = phtree_2.pair_nodes_in_rectangle(4, 4, 6, 6)
nodes_2
Out[30]:
[BitmapPHTrees.Node(4.0, 6.0), BitmapPHTrees.Node(4.0, 6.0)]

Two the following is the result of visualization. You can see that the deeper part of the hollow area corresponds to this birth-death pair.

In [31]:
pl = pv.Plotter()
pl.add_mesh(pvhelper.Bitmap3D(pict).threshold(0.5), show_scalar_bar=False, opacity=0.2)
pl.add_mesh(nodes_2[0].to_pyvista_mesh())
pl.add_mesh(nodes_2[1].to_pyvista_mesh())
pl.show()
Widget(value="<iframe src='http://localhost:35151/index.html?ui=P_0x7ff1c78ce470_4&reconnect=auto' style='widt…

You can also get each coordinate of this area by using the volume method.

In [32]:
nodes_2[0].volume()
Out[32]:
[[27, 24, 25],
 [26, 24, 25],
 [27, 23, 23],
 [26, 24, 23],
 [27, 24, 23],
 [26, 23, 23],
 [26, 23, 24],
 [27, 24, 24],
 [26, 24, 24],
 [27, 23, 24],
 [27, 23, 25],
 [26, 24, 22],
 [27, 23, 26],
 [26, 24, 26],
 [27, 24, 26],
 [28, 25, 25],
 [28, 24, 25],
 [25, 25, 25],
 [26, 25, 25],
 [27, 25, 25],
 [28, 23, 25],
 [25, 23, 25],
 [26, 23, 25],
 [25, 24, 25],
 [27, 22, 25],
 [25, 22, 25],
 [26, 22, 25],
 [26, 25, 22],
 [27, 25, 22],
 [26, 22, 23],
 [25, 22, 23],
 [27, 22, 23],
 [25, 23, 23],
 [28, 24, 23],
 [25, 24, 23],
 [28, 23, 23],
 [28, 25, 23],
 [27, 25, 23],
 [26, 25, 23],
 [25, 25, 23],
 [27, 24, 21],
 [26, 24, 21],
 [27, 23, 21],
 [26, 23, 21],
 [27, 23, 22],
 [25, 24, 22],
 [26, 23, 22],
 [27, 24, 22],
 [28, 25, 24],
 [27, 25, 24],
 [26, 25, 24],
 [28, 23, 24],
 [25, 25, 24],
 [28, 24, 24],
 [25, 24, 24],
 [28, 22, 24],
 [25, 23, 24],
 [27, 22, 24],
 [26, 22, 24],
 [25, 22, 24],
 [29, 24, 25],
 [29, 25, 25],
 [24, 25, 25],
 [25, 26, 25],
 [26, 26, 25],
 [27, 26, 25],
 [28, 26, 25],
 [26, 24, 20],
 [26, 23, 20],
 [27, 23, 20],
 [27, 24, 20],
 [27, 22, 21],
 [28, 22, 21],
 [26, 22, 21],
 [25, 22, 21],
 [24, 25, 24],
 [29, 24, 24],
 [29, 25, 24],
 [25, 26, 24],
 [26, 26, 24],
 [27, 26, 24],
 [28, 26, 24],
 [28, 23, 21],
 [28, 25, 21],
 [25, 23, 21],
 [28, 24, 21],
 [25, 24, 21],
 [25, 25, 21],
 [26, 25, 21],
 [27, 25, 21],
 [29, 22, 24],
 [24, 23, 24],
 [27, 21, 24],
 [26, 21, 24],
 [25, 21, 24],
 [24, 22, 24],
 [28, 21, 24],
 [24, 24, 24],
 [26, 22, 19],
 [29, 23, 24],
 [26, 23, 19]]
In [ ]: