How to use my own bitmap font in PIL.ImageFont? - python

I created a bitmap font, basically a 256x256 png image where each character occupies 8x8 tile. I want to use it with Pillow as ImageFont but there's no info on this in Pillow docs. It says I can load bitmap fonts like this
font = ImageFont.load("arial.pil")
but "PIL uses its own font file format to store bitmap fonts." so I guess png file won't work. How can I tell PIL to use said bitmap and where each character is on it?

Not a complete answer, but too much for a comment, and it may be useful or spur someone else to work out the other 60% :-)
I may delete it if anyone else comes up with something better...
You can go to the Pillow repository on Github and download a ZIP file of the code.
If you go in there and nose around you will find two things that appear to work hand-in-hand, namely a .PIL file and a .PBM file.
In Tests/fonts there is a file called 10x20.pbm which is actually a PNG file if you look inside it. So, if you change its name to 10x20.png you can view it and it looks like this:
By the way, if you want to split that into 10x20 size chunks with one letter in each, you can use ImageMagick in Terminal like this:
convert 10x20.pbm -crop 10x20 char_%d.png
and you will get a bunch of files called char_0.png, char_1.png etc. The first 4 look like this:
If you look in src/PIL/FontFile.py there is this code that seems to know how to access/generate the metrics for a font:
#
# The Python Imaging Library
# $Id$
#
# base class for raster font file parsers
#
# history:
# 1997-06-05 fl created
# 1997-08-19 fl restrict image width
#
# Copyright (c) 1997-1998 by Secret Labs AB
# Copyright (c) 1997-1998 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import print_function
import os
from . import Image, _binary
WIDTH = 800
def puti16(fp, values):
# write network order (big-endian) 16-bit sequence
for v in values:
if v < 0:
v += 65536
fp.write(_binary.o16be(v))
##
# Base class for raster font file handlers.
class FontFile(object):
bitmap = None
def __init__(self):
self.info = {}
self.glyph = [None] * 256
def __getitem__(self, ix):
return self.glyph[ix]
def compile(self):
"Create metrics and bitmap"
if self.bitmap:
return
# create bitmap large enough to hold all data
h = w = maxwidth = 0
lines = 1
for glyph in self:
if glyph:
d, dst, src, im = glyph
h = max(h, src[3] - src[1])
w = w + (src[2] - src[0])
if w > WIDTH:
lines += 1
w = (src[2] - src[0])
maxwidth = max(maxwidth, w)
xsize = maxwidth
ysize = lines * h
if xsize == 0 and ysize == 0:
return ""
self.ysize = h
# paste glyphs into bitmap
self.bitmap = Image.new("1", (xsize, ysize))
self.metrics = [None] * 256
x = y = 0
for i in range(256):
glyph = self[i]
if glyph:
d, dst, src, im = glyph
xx = src[2] - src[0]
# yy = src[3] - src[1]
x0, y0 = x, y
x = x + xx
if x > WIDTH:
x, y = 0, y + h
x0, y0 = x, y
x = xx
s = src[0] + x0, src[1] + y0, src[2] + x0, src[3] + y0
self.bitmap.paste(im.crop(src), s)
self.metrics[i] = d, dst, s
def save(self, filename):
"Save font"
self.compile()
# font data
self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG")
# font metrics
with open(os.path.splitext(filename)[0] + ".pil", "wb") as fp:
fp.write(b"PILfont\n")
fp.write((";;;;;;%d;\n" % self.ysize).encode('ascii')) # HACK!!!
fp.write(b"DATA\n")
for id in range(256):
m = self.metrics[id]
if not m:
puti16(fp, [0] * 10)
else:
puti16(fp, m[0] + m[1] + m[2])
So hopefully someone has time/knowledge of how to put those two together to enable you to generate the metrics file for your PNG. I think you just need something that does the last 10 lines of that code for your PNG.
There appear to be 23 bytes of header which you can simply replicate, and then there are 256 "entries", i.e. 1 for each of 256 glyphs. Each entry has 10 numbers in it, and each number is 16-bit big endian.
Let's look at the header:
dd if=10x20.pil bs=23 count=1| xxd -c23 | more
00000000: 5049 4c66 6f6e 740a 3b3b 3b3b 3b3b 3230 3b0a 4441 5441 0a PILfont.;;;;;;20;.DATA.
Then you can see the entries using the command below to skip the header and group nicely:
dd if=10x20.pil bs=23 iseek=1| xxd -g2 -c20
which gives:
Column 1 appears to be the width of the glyph.
Column 7 is the x-offset of the left edge of the glyph in the image and column 9 is the x-offset of the right edge of the glyph in the image. So you will see that column 7 on each line is the same as column 9 on the previous line, i.e. that the glyphs abutt each other going across the image.
If you look at this extract from further down the file, you can see it starts a new row of glyphs in the output image in the middle of the extract (marked in red). That tells us that the bitmap should be no more than 800 pixels wide and that column 8 is the y-offset of the top of the glyph in the bitmap file and column 10 is the y-offset of the bottom of the glyph in the bitmap. You should see that when a new line row of glyphs starts in the bitmap file that x goes to zero and column 8 takes the previous value from column 10.

Related

Creating PDF with Python using FPDF char by char

I'm trying to create a pdf with python and I want to put a text in pdf char by char.
I can't find out how to do it and when it saves output pdf all of the characters are on each other.
this is my code snippet:
from fpdf import FPDF
pdf = FPDF('P', 'mm', (100,100))
# Add a page
pdf.add_page()
# set style and size of font
# that you want in the pdf
pdf.add_font('ariblk', '', "ArialBlack.ttf", uni=True)
pdf.set_font("ariblk",size = int(50*0.8))
text = [['a','b','c','d','e','w','q'],['f','g','h','i','j','k','l']]
print("creating pdf...")
line = 0
for w in range(0,len(text)):
for h in range(0,len(text[w])):
# create a cell
r = int (50)
g = int (100)
b = int (10)
pdf.set_text_color(r, g, b)
text_out = text[w][h]
pdf.cell(0,line, txt = text_out, ln = 2)
# save the pdf with name .pdf
pdf.output(name = "img/output.pdf", dest='F')
print("pdf created!")
and this is what my code output is:
(this is copy-paste from the output pdf): iljfbeqaghdckw
(this is a screenshot of the output):
I don't know fpdf module but I think that your problem only comes from the fact that you don't change the X, Y coordinates of printing of each character.
You have to use 'pdf.set_xy()` to set the X and Y coordinates of each of your characters
I made small changes to the font and colors for my tests.
from fpdf import FPDF
import random
pdf = FPDF('P', 'mm', (100,100))
# Add a page
pdf.add_page()
# set style and size of font
# that you want in the pdf
#pdf.add_font('ariblk', '', "ArialBlack.ttf", uni=True)
pdf.set_font("Arial",size = int(24))
text = [['a','b','c','d','e','w','q'],['f','g','h','i','j','k','l']]
print("creating pdf...")
line = 10
for w in range(len(text)):
for h in range(len(text[w])):
# create a cell
r = random.randint(1, 255)
g = random.randint(1, 255)
b = random.randint(1, 255)
pdf.set_text_color(r, g, b)
text_out = text[w][h]
pdf.set_xy(10*w, 10*h)
pdf.cell(10, 10, txt=text_out, ln=0, align='C')
# save the pdf with name .pdf
pdf.output(name = "output.pdf", dest='F')
print("pdf created!")
Then, you have to adapt the offset of X and/or Y according to the display you want to obtain in print.
Remark: As you don't change the values of r, g, b in your for loops, the best is to go up the assignment of variables r, g and b before the for loops
Output in the PDF:
a f
b g
c h
d i
e j
w k
q l

Scanning lists more efficiently in python

I have some code, which works as intended, however takes about 4 and a half hours to run, I understand that there are about 50 billion calculations my poor pc needs to do but I thought it would be worth asking!
This code gets an image, and wants to find every possible region of 331*331 pixels in the given image, and find how many black pixels there are in each, I will use this data to create a heatmap of black pixel density, and also a list of all of the values found:
image = Image.open(self.selectedFile)
pixels = list(image.getdata())
width, height = image.size
pixels = [pixels[i * width:(i+1) * width] for i in range(height)]
#print(pixels)
rightShifts = width - 331
downShifts = height - 331
self.totalRegionsLabel['text'] = f'Total Regions: {rightShifts * downShifts}'
self.blackList = [0 for i in range(0, rightShifts*downShifts)]
self.heatMap = [[] for i in range(0, downShifts)]
for x in range(len(self.heatMap)):
self.heatMap[x] = [0 for i in range(0, rightShifts)]
for x in range(rightShifts):
for y in range(downShifts):
blackCount = 0
for z in range(x + 331):
for w in range(y + 331):
if pixels[z][w] == 0:
blackCount += 1
self.blackList[x+1*y] = blackCount
self.heatMap[x][y] = blackCount
print(self.blackList)
You have several problems here, as I pointed out. Your z/w loops are always starting at the upper left, so by the time you get towards the end, you're summing the entire image, not just a 331x331 subset. You also have much confusion in your axes. In an image, [y] is first, [x] is second. An image is rows of columns. You need to remember that.
Here's an implementation as I suggested above. For each column, I do a full sum on the top 331x331 block. Then, for every row below, I just subtract the top row and add the next row below.
self.heatMap = [[0]*rightShifts for i in range(downShifts)]
for x in range(rightShifts):
# Sum up the block at the top.
blackCount = 0
for row in range(331):
for col in range(331):
if pixels[row][x+col] == 0:
blackCount += 1
self.heatMap[0][x] = blackCount
for y in range(1,downShifts):
# To do the next block down, we subtract the top row and
# add the bottom.
for col in range(331):
blackCount += pixels[y+330][x+col] - pixels[y-1][x+col]
self.heatMap[y][x] = blackCount
You could tweak this even more by alternating the columns. So, at the bottom of the first column, scoot to the right by subtracting the first column and adding the next new column. then scoot back up to the top. That's a lot more trouble.
The two innermost for-loops seem to be transformable to some numpy code if using this package is not an issue. It would give something like:
pixels = image.get_data() # it is probably already a numpy array
# Get an array filled with either True or False, with True whenever pixel is black:
pixel_is_black = (pixels[x:(x+331), y:(y+331)] == 0)
pixel_is_black *= 1 # Transform True and False to respectively 1 and 0. Maybe not needed
self.blackList[x+y] = pixel_is_black.sum() # self explanatory
This is the simplest optimization I can think of, you probably can do much better with clever numpy tricks.
I would recommend using some efficient vector computations through the numpy and opencv libraries.
First, binarize your image so that black pixels are set to zero, and any other color pixels (gray to white) are set to 1. Then, apply a 2D filter to the image of shape 331 x 331 where each value in the filter kernel is (1 / (331 x 331) - this will take the average of all the values in each 331x331 area and assign it to the center pixel.
This gives you a heatmap, where each pixel value is the proportion of non-black pixels in the surrounding 331 x 331 region. A darker pixel (value closer to zero) means more pixels in that region are black.
For some background, this approach uses image processing techniques called image binarization and box blur
Example code:
import cv2
import numpy as np
# setting up a fake image, with some white spaces, gray spaces, and black spaces
img_dim = 10000
fake_img = np.full(shape=(img_dim, img_dim), fill_value=255, dtype=np.uint8) # white
fake_img[: img_dim // 3, : img_dim // 3] = 0 # top left black
fake_img[2 * img_dim // 3 :, 2 * img_dim // 3 :] = 0 # bottom right black
fake_img[img_dim // 3 : 2 * img_dim // 3, img_dim // 3 : 2 * img_dim // 3] = 127 # center gray
# show the fake image
cv2.imshow("", fake_img)
cv2.waitKey()
cv2.destroyAllWindows()
# solution to your problem
binarized = np.where(fake_img == 0, 0, 1) # have 0 values where black, 1 values else
my_filter = np.full(shape=(331, 331), fill_value=(1 / (331 * 331))) # set up filter
heatmap = cv2.filter2D(fake_img, 1, my_filter) # apply filter, which takes average of values in 331x331 block
# show the heatmap
cv2.imshow("", heatmap)
cv2.waitKey()
cv2.destroyAllWindows()
I ran this on my laptop, with a huge (fake) image of 10000 x 10000 pixels, almost instantly.
Sorry I should have deleted this post before you all put the effort in, however, some of these workarounds are really smart and interesting, I ended up coming up with a solution independently that is the same as what Tim Robbers first suggested, I used the array I had and built a second one on which every item in a row is the number of black cells preceding it, and then for each row in a region instead of scanning every item, just scan the preceding value and the final value and you are good:
image = Image.open(self.selectedFile).convert('L') #convert to luminance mode as RGB information is irrelevant
pixels = list(image.getdata()) #get the value of every pixel in the image
width, height = image.size
pixels = [pixels[i * width:(i+1) * width] for i in range(height)] #split the pixels array into a two dimensional array with the dimensions to match the image
#This program scans every possible 331*331 square starting from the top left, so it will move right width - 331 pixels and down height - 331 pixels
rightShifts = width - 331
downShifts = height - 331
self.totalRegionsLabel['text'] = f'Total Regions: {rightShifts * downShifts}' #This wont update till the function has completed running
#The process of asigning new values to values in an array is faster than appending them so this is why I prefilled the arrays:
self.heatMap = [[] for i in range(0, downShifts)]
for x in range(len(self.heatMap)):
self.heatMap[x] = [0 for i in range(0, rightShifts)]
cumulativeMatrix = [] #The cumulative matrix replaces each value in each row with how many zeros precede it
for y in range(len(pixels)):
cumulativeMatrix.append([])
cumulativeMatrix[y].append(0)
count = 0
for x in range(len(pixels[y])):
if pixels[y][x] == 0:
count += 1
cumulativeMatrix[y].append(count)
regionCount = 0
maxValue = 0 #this is the lowest possible maximum value
minValue = 109561 #this is the largest possible minimum value
self.blackList = []
#loop through all possible regions
for y in range(downShifts):
for x in range(rightShifts):
blackPixels = 0
for regionY in range(y, y + 331):
lowerLimit = cumulativeMatrix[regionY][x]
upperLimit = cumulativeMatrix[regionY][x+332]
blackPixels += (upperLimit - lowerLimit)
if blackPixels > maxValue:
maxValue = blackPixels
if blackPixels < minValue:
minValue = blackPixels
self.blackList.append(blackPixels)
self.heatMap[y][x] = blackPixels
regionCount += 1
This brought run time to under a minute and thus solved my problem, however, thank you for your contributions I have learned a lot from reading them!
Try to look into the map() function. It uses C to streamline iterations.
You can speed up your for loops like this:
pixels = list(map(lambda i: x[i*width:(i+1)*width], range(height)))

How do I measure the bounds of a string in wand?

I'm using Wand to generate a JPG with custom variable text inside it.
I have an array of strings all with the same width but different heights.
Is there a method to word wrap a long text inside a boundary or calculate the height needed for the text so when drawing the texts from the array they don't overlap.
with Drawing() as ctx:
with Image(width=1080, height=1080, background=Color("WHITE")) as img:
with Drawing() as draw:
for i,line in enumerate(lines):
metrics = draw.get_font_metrics(img, line, multiline=True)
draw.text(x=150, y=120+(i*35)+int(metrics.text_height), body=line)
draw(img)
img.sample(1080, 1080)
img.save(filename="output.png")
This may not be the answer(s) your looking for, but will hopefully get you on the right path.
How do I measure the bounds of a string in wand?
Your already doing it. Rather than a "smart-n-quick" one-liner approach, I would suggest a more classic offset & accumulator approach to map positions that update with each iteration.
top_margin = 120
line_offset = 0
line_padding = 35
with Drawing() as ctx:
with Image(width=1080, height=1080, background=Color("WHITE")) as img:
with Drawing() as draw:
for i,line in enumerate(lines):
metrics = draw.get_font_metrics(img, line, multiline=True)
draw.text(x=150, y=y=top_margin + line_offset, body=line)
line_offset += int(metrics.text_height) + line_padding
Is there a method to word wrap a long text inside a boundary or calculate the height needed for the text so when drawing the texts from the array they don't overlap.
The short answer is no. You would be responsible for implementing the algorithm. Luckily the internet is full of examples & research articles that can be referenced. It can be as basic as find-the-last-space-before-overflow...
lines = [
'I\'m using Wand to generate a JPG with custom variable text inside it.',
'I have an array of strings all with the same width but different heights',
'Is there a method to word wrap a long text inside a boundary or calculate the height needed for the text so when drawing the texts from the array they don\'t overlap',
]
image_width = 540
image_height = 540
left_margin = 150
right_margin = image_width - left_margin * 2
top_margin = 120
line_padding = 35
line_offset = 0
with Drawing() as ctx:
with Image(width=image_width, height=image_height, background=Color("LIGHTCYAN")) as img:
with Drawing() as draw:
for i,line in enumerate(lines):
metrics = draw.get_font_metrics(img, line, multiline=True)
last_idx = 1
# Do we need to do work?
while metrics.text_width > right_margin:
last_breakpoint=0
# Scan text for possible breakpoints.
for idx in range(last_idx, len(line)):
if line[idx] == ' ':
last_breakpoint = idx
else:
# Determine if we need to insert a breakpoint.
metrics = draw.get_font_metrics(img, line[:idx], multiline=True)
if metrics.text_width >= right_margin:
line = line[:last_breakpoint].strip() + '\n' + line[last_breakpoint:].strip()
last_idx = last_breakpoint
break
# Double check any modifications to text was successful enough.
metrics = draw.get_font_metrics(img, line, multiline=True)
draw.text(x=left_margin, y=top_margin + line_offset, body=line)
line_offset += int(metrics.text_height) + line_padding
draw(img)
img.save(filename="output.png")
The above code could be optimized, and Python might already include some better methods.
Further reading...
The source code ImageMagick's CAPTION: protocol is a good example. The algorithm repeatedly calls GetMultilineTypeMetrics as well as FormatMagickCaption to adjust pointsize & insert line-breaks.
The wand library doesn't really support the caption protocol, but you can play-around with it by using the following workaround.
from wand.api import library
# ...
with Image(width=image_width, height=image_height, background=Color("LIGHTCYAN")) as img:
for i,line in enumerate(lines):
# Create a tempory image for each bounding box
with Image() as throwaway:
library.MagickSetSize(throwaway.wand, right_margin, line_padding)
throwaway.read(filename='CAPTION:'+line)
img.composite(throwaway, left_margin, top_margin + line_offset)
line_offset += line_padding + throwaway.height
img.save(filename="output.png")

Increase extent of vector layer with OGR or GDAL?

When using the OGR library or GDAL library with Python script, is it possible to increase the extent of a vector layer without actually adding new data points? In my specific case, I would like to increase the extent of vector layers associated with gpx files so that when I convert them to rasters they all have the same pixel matrix.
EDIT: An attempt of mine to use gdal.Rasterize does not produce a "tiff" file, nor does it cause an error to be reported:
import os
import gdal
import ogr
import math
os.chdir(r'C:\Users\pipi\Documents\Rogaine\Tarlo\gpx') #folder containing gpx files
vector_fn = '6_hour_Autumngaine_w_Tom_Elle.gpx' #filename of input gpxfile
pixel_size = 20 #units are in m if gpx file is left in wgs84
raster_fn = '0011a.tif' # Filename of the raster Tiff that will be created
driver = ogr.GetDriverByName('GPX')
source_ds = driver.Open(vector_fn, 0)
source_layer = source_ds.GetLayer('track_points') #returns the 'track points' layer of the data source
SR = source_layer.GetSpatialRef().ExportToWkt()
#_______USING VALUES FROM THE FILE___________
x_min1, x_max1, y_min1, y_max1 = source_layer.GetExtent()
pixel_sizey = pixel_size/(111.2*math.pow(10,3)) #determines an approximate x and y size because of geographic coordinates.
pixel_sizex = pixel_size/(math.cos(((y_max1 + y_min1)/2)*(math.pi/180))*111.2*math.pow(10,3))
print (pixel_sizey, pixel_sizex)
x_res = int((x_max1 - x_min1) / pixel_sizex)
y_res = int((y_max1 - y_min1) / pixel_sizey)
print (x_res, y_res)
layer_list = ['track_points']
gdal.Rasterize(raster_fn, vector_fn, format='GTiff', outputBounds=[x_min1, y_min1, x_max1, y_max1], outputSRS=SR, xRes=x_res, yRes=y_res, burnValues=[1], layers=layer_list)
target_ds = None
vector_fn = None
source_layer = None
source_ds = None
You need to pass options=gdal.RasterizeOptions(format='GTiff', outputBounds=[x_min1, y_min1, x_max1, y_max1], outputSRS=SR, xRes=x_res, yRes=y_res, burnValues=[1], layers=layer_list) instead of passing the individual kwargs directly. Otherwise, they will be ignored, and the command won't do what you intend. See Link and Link for details and links to the source code (often useful given the terse documentation).
I was unable to find a method to change the extent of the vector layer. However, I was able to write a python Function that uses gdal.RasterizeLayer() to produce a raster with an extent much larger than the original vector layer. The code for this function is:
import os
import gdal
import ogr
def RasterizeLarge(name, layer, extent, pixel_size):
"""Used to rasterize a layer where the raster extent is much larger than the layer extent
Arguments:
name -- (string) filename without extension of raster to be produced
layer -- (vector layer object) vector layer containing the data to be rasterized (tested with point data)
extent -- (list: x_min, x_max, y_min, y_max) extent of raster to be produced
pixel_size -- (list: x_pixel_size, y_pixel_size) 1 or 2 pixel different pixel sizes may be sent
"""
if isinstance(pixel_size, (list, tuple)):
x_pixel_size = pixel_size[0]
y_pixel_size = pixel_size[1]
else:
x_pixel_size = y_pixel_size = pixel_size
x_min, x_max, y_min, y_max = extent
# determines the x and y resolution of the file (lg = large)
x_res_lg = int((x_max - x_min) / x_pixel_size)+2
y_res_lg = int((y_max - y_min) / y_pixel_size)+2
if x_res_lg > 1 and y_res_lg > 1:
pass
else:
print ('Your pixel size is larger than the extent in one dimension or more')
return
x_min_sm, x_max_sm, y_min_sm, y_max_sm = layer.GetExtent()
if x_min_sm > x_min and x_max_sm < x_max and y_min_sm > y_min and y_max_sm < y_max:
pass
else:
print ('The extent of the layer is in one or more parts outside of the extent provided')
return
nx = int((x_min_sm - x_min)/x_pixel_size) #(number of pixels between main raster origin and minor raster)
ny = int((y_max - y_max_sm)/y_pixel_size)
x_res_sm = int((x_max_sm - x_min_sm) / x_pixel_size)+2
y_res_sm = int((y_max_sm - y_min_sm) / y_pixel_size)+2
#determines upper left corner of small layer raster
x_min_sm = x_min + nx * x_pixel_size
y_max_sm = y_max - ny * y_pixel_size
#______Creates a temporary raster file for the small raster__________
try:
# create the target raster file with 1 band
sm_ds = gdal.GetDriverByName('GTiff').Create('tempsmall.tif', x_res_sm, y_res_sm, 1, gdal.GDT_Byte)
sm_ds.SetGeoTransform((x_min_sm, x_pixel_size, 0, y_max_sm, 0, -y_pixel_size))
sm_ds.SetProjection(layer.GetSpatialRef().ExportToWkt())
gdal.RasterizeLayer(sm_ds, [1], layer, burn_values=[1])
sm_ds.FlushCache()
#______Gets data from the new raster in the form of an array________
in_band = sm_ds.GetRasterBand(1)
in_band.SetNoDataValue(0)
sm_data = in_band.ReadAsArray()
finally:
sm_ds = None #flushes data from memory. Without this you often get an empty raster.
#_____Creates an output file with the provided name and extent that contains the small raster.
name = name + '.tif'
try:
lg_ds = gdal.GetDriverByName('GTiff').Create(name, x_res_lg, y_res_lg, 1, gdal.GDT_Byte)
if lg_ds is None:
print 'Could not create tif'
return
else:
pass
lg_ds.SetProjection(layer.GetSpatialRef().ExportToWkt())
lg_ds.SetGeoTransform((x_min, x_pixel_size, 0.0, y_max, 0.0, -y_pixel_size))
lg_band = lg_ds.GetRasterBand(1)
lg_data = in_band.ReadAsArray()
lg_band.WriteArray(sm_data, xoff = nx, yoff = ny)
lg_band.SetNoDataValue(0)
lg_band.FlushCache()
lg_band.ComputeStatistics(False)
lg_band = None
finally:
del lg_ds, lg_band, in_band
os.remove('tempsmall.tif')
return

3d skeleton from segmentation

I want to create a skeleton based on an existing segmentation, similar to what is done here (from sk-image):
However I want to do this on 3D data. Is there code for that somewhere out there? Preferably in python but any language helps.
I am aware of this great site, however I think they don't offer any code.
I am planning on using that on volumes of about 500x500x500 Pixels, so it should scale well...
I am developing this tools in this link below. The function getSkeletonize3D in program of the name convOptimize.py lets you thin your 3D data. It took about 30 minutes to give the result for the 512 cube I have. Let me know if you have any problems. https://github.com/3Scan/3scan-skeleton. The paper I used for implementing is in the comments in the code below
Basically how this 3D skeletonization algorithm works is, in each pass it has 12 subiterations in which it removes boundaries in specific directions iteratively, until you get a skeleton in the center.
The main python code that is needed for skeletonizing your data is as below. As it requires imports from different other porgrams rotationalOperators which has an import from another file called Thin3dtemplates. I recommend you downlaod rotationalOperators, Thin3dtemplates, convoptimize python scripting files and also download lookuparray.npy which is a file that is used as a lookup table in an numpy array format pre-calculated for validating a voxel for marking to be deleted or not. You need python > 3 version, scipy, numpy and pyeda modules installed to run these codes.
import numpy as np
import time
from scipy import ndimage
from scipy.ndimage.filters import convolve
"""
the following subiteration functions are how each image is rotated to the next direction for removing
boundary voxels in the order described in the reference paper
us, ne, wd,..
"""
from rotationalOperators import firstSubiteration, secondSubiteration, thirdSubiteration, fourthSubiteration, fifthSubiteration, sixthSubiteration, seventhSubiteration, eighthSubiteration, ninthSubiteration, tenthSubiteration, eleventhSubiteration, twelvethSubiteration
"""
reference paper
http://web.inf.u-szeged.hu/ipcg/publications/papers/PalagyiKuba_GMIP1999.pdf
input should be a binary image/ already segmented
"""
"""
array that has calculated the validity of the 14 templates beforehand and stored each index which is
decimal number of the binary string of 26 values (sqrt(3) connectivity) that are around a single voxel
"""
lookUpTablearray = np.load('lookupTablearray.npy')
def _convolveImage(arr, flippedKernel):
arr = np.ascontiguousarray(arr, dtype=np.uint64)
result = convolve(arr, flippedKernel, mode='constant', cval=0)
result[arr == 0] = 0
return result
"""
each of the 12 iterations corresponds to each of the following
directions - us, ne, wd, es, uw, nd, sw, un, ed, nw, ue, sd
imported from template expressions
evaluated in advance using pyeda
https://pyeda.readthedocs.org/en/latest/expr.html
"""
sElement = ndimage.generate_binary_structure(3, 1)
def _getBouondariesOfimage(image):
"""
function to find boundaries/border/edges of the array/image
"""
erode_im = ndimage.morphology.binary_erosion(image, sElement)
boundaryIm = image - erode_im
return boundaryIm
"""
each of the 12 iterations corresponds to each of the following
directions - us, ne, wd, es, uw, nd, sw, un, ed, nw, ue, sd
imported from template expressions
evaluated in advance using pyeda
https://pyeda.readthedocs.org/en/latest/expr.html
"""
directionList = [firstSubiteration, secondSubiteration, thirdSubiteration, fourthSubiteration,
fifthSubiteration, sixthSubiteration, seventhSubiteration, eighthSubiteration,
ninthSubiteration, tenthSubiteration, eleventhSubiteration, twelvethSubiteration]
def _skeletonPass(image):
"""
each pass consists of 12 serial subiterations and finding the
boundaries of the padded image/array
"""
boundaryIm = _getBouondariesOfimage(image)
numPixelsremovedList = [] * 12
boundaryIndices = list(set(map(tuple, list(np.transpose(np.nonzero(boundaryIm))))))
for i in range(0, 12):
convImage = _convolveImage(image, directionList[i])
totalPixels, image = _applySubiter(image, boundaryIndices, convImage)
print("number of pixels removed in the {} direction is {}". format(i, totalPixels))
numPixelsremovedList.append(totalPixels)
numPixelsremoved = sum(numPixelsremovedList)
return numPixelsremoved, image
def _applySubiter(image, boundaryIndices, convImage):
"""
each subiteration paralleley reduces the border voxels in 12 directions
going through each voxel and marking if it can be deleted or not in a
different image named temp_del and finally multiply it with the original
image to delete the voxels so marked
"""
temp_del = np.zeros_like(image)
# boundaryIndicesCopy = copy.deepcopy(boundaryIndices)
lenB = len(boundaryIndices)
for k in range(0, lenB):
temp_del[boundaryIndices[k]] = lookUpTablearray[convImage[boundaryIndices[k]]]
numpixel_removed = np.einsum('ijk->', image * temp_del, dtype=int)
image[temp_del == 1] = 0
return numpixel_removed, image
def getSkeletonize3D(image):
"""
function to skeletonize a 3D binary image with object in brighter contrast than background.
In other words, 1 = object, 0 = background
"""
assert np.max(image) in [0, 1]
zOrig, yOrig, xOrig = np.shape(image)
padImage = np.lib.pad(image, 1, 'constant', constant_values=0)
start_skeleton = time.time()
pass_no = 0
numpixel_removed = 0
while pass_no == 0 or numpixel_removed > 0:
numpixel_removed, padImage = _skeletonPass(padImage)
print("number of pixels removed in pass {} is {}".format(pass_no, numpixel_removed))
pass_no += 1
print("done %i number of pixels in %f seconds" % (np.sum(image), time.time() - start_skeleton))
return padImage[1: zOrig + 1, 1: yOrig + 1, 1: xOrig + 1]
if __name__ == '__main__':
sample = np.ones((5, 5, 5), dtype=np.uint8)
resultSkel = getSkeletonize3D(sample)
# gives a single voxel at the center
print("resultSkel", resultSkel)

Categories

Resources