python-pillow / Pillow

Python Imaging Library (Fork)
https://python-pillow.org
Other
12.13k stars 2.21k forks source link

Better morphological operations #4762

Open asudyn opened 4 years ago

asudyn commented 4 years ago

As far as I understand, right now we're limited to only use 3x3 kernels and the set of operations is pretty limited. I'd really want a more powerful ImageMorph class with ability to set any kernel and ability to work with non-binary images.

nulano commented 4 years ago

I think you can use an arbitrary 3x3 or 5x5 kernel with ImageFilter.Kernel: https://pillow.readthedocs.io/en/stable/reference/ImageFilter.html#PIL.ImageFilter.Kernel

asudyn commented 4 years ago

Thanks, I didn't see that. 5x5 is good. Still I want to be able to apply 7x7, 9x9 and so on. Right now I've written a function to do that but it's extremely slow. It takes 2.5 seconds to apply it to 250x250 pixel image. The function adds an outline of a configurable size to a PNG with alpha channel. I'm trying to speed it up.

nostroke stroke

def addStroke(image,strokeSize=1,color=(0,0,0)):
    #Create a disc kernel
    kernelSize=math.ceil(strokeSize)*2+1 #Should always be odd
    kernelExtent=int(kernelSize/2)
    kernelRadius=strokeSize+0.5
    kernelCenter=kernelSize/2-1
    pixelRadius=1/math.sqrt(math.pi)
    kernel=[]
    for x in range(kernelSize):
        for y in range(kernelSize):
            distanceToCenter=math.sqrt((kernelCenter-x+0.5)**2+(kernelCenter-y+0.5)**2)
            if(distanceToCenter<=kernelRadius-pixelRadius):
                value=1 #This pixel is fully inside the circle
            elif(distanceToCenter<=kernelRadius):
                value=min(1,(kernelRadius-distanceToCenter+pixelRadius)/(pixelRadius*2)) #Mostly inside
            elif(distanceToCenter<=kernelRadius+pixelRadius):
                value=min(1,(pixelRadius-(distanceToCenter-kernelRadius))/(pixelRadius*2)) #Mostly outside
            else:
                value=0 #This pixel is fully outside the circle
            kernel.append((x-kernelExtent,y-kernelExtent,value))
    kernel=tuple(kernel)

    #Debug save kernel to image
    # image_out=Image.new("L",(kernelSize,kernelSize))
    # image_out.putdata([int(round((1-k[2])*255)) for k in kernel])
    # image_out.save('kernel.png')

    alphaPixels=image.getchannel("A").load()
    outlineValues=[]
    imageWidth,imageHeight=image.size

    start_time=time.time()

    #Morphological grayscale dilation
    for y in range(imageWidth):
        for x in range(imageHeight):
            values=[]
            ##############
            #This part slows everything down A LOT
            ##############
            for kx,ky,kval in kernel:
                mx,my=x+kx,y+ky
                if(0<=mx<imageWidth and 0<=my<imageHeight):
                    values.append(alphaPixels[mx,my]*kval)
            outlineValues.append(int(max(values)))

    print("--- Calculated outline in "+str((time.time()-start_time))+" seconds ---")

    outline=Image.new(mode='RGB',size=image.size,color=color)
    outlineAlpha=Image.new(mode='L',size=image.size,color=0)
    outlineAlpha.putdata(outlineValues)
    outline.putalpha(outlineAlpha)
    outline.paste(image,(0,0),image)
    return outline
AX-I commented 1 month ago

PIL (and Python loops) is not designed for these convolution-type operations, which are more suited for Numpy. You can utilise the np.array(image) and Image.fromarray(array) conversions and speed up your code as follows:

Add this function to convert the kernel into a Numpy array:

def convertKernelNP(k, ksize):
    k = np.array(k)
    k[:,0:2] -= np.min(k[:,0:2])
    out = np.zeros((ksize,ksize))
    out[k[:,0].astype('int'),k[:,1].astype('int')] = k[:,2]
    return out

Replace the big loop after #Morphological grayscale dilation with:

kernelNP = convertKernelNP(kernel, kernelSize)
imageNP = np.array(image)
alphaNP = np.pad(imageNP[:,:,3], strokeSize)
outlineAlphaNP = np.zeros_like(imageNP[:,:,0], dtype='float')

for y in range(kernelSize):
    for x in range(kernelSize):
        np.maximum(outlineAlphaNP,
                   kernelNP[y,x] * alphaNP[y:y+imageHeight,x:x+imageWidth],
                   out=outlineAlphaNP)

Replace these two lines

outlineAlpha=Image.new(mode='L',size=image.size,color=0)
outlineAlpha.putdata(outlineValues)

with:

outlineAlpha=Image.fromarray(outlineAlphaNP.astype('uint8'))

Result: on my computer calling with strokeSize = 8

Before: 3.5253 seconds
After: 0.0156 seconds