Joe B
Joe B

Reputation: 1022

How can I select all black pixels that are contiguous with an edge of the image in PIL?

I have a set of images petri dishes which unfortunately are not the highest quality (example below, axes aren't part of the images). dish1 I'm trying to select the background and calculate its area in pixels with the following:

image = Image.open(path)
black_image = 1 * (np.asarray(image.convert('L')) < 12)
black_region = black_image.sum()

This yields the below:

enter image description here

If I am more stringent with my selection of black pixels, I miss pixels in other images, and if I am looser I end up selecting too much of the petri dish itself. Is there a way I can only select the pixels have a luma value less than 12 AND are contiguous with an edge? I'm open to openCV solutions too.

Upvotes: 5

Views: 3923

Answers (4)

HansHirse
HansHirse

Reputation: 18915

Hopefully, I'm not oversimplifying the problem, but from my point of view, using OpenCV with simple thresholding, morphological operations, and findContours should do the job.

Please, see the following code:

import cv2
import numpy as np

# Input
input = cv2.imread('images/x0ziO.png', cv2.IMREAD_COLOR)

# Input to grayscale
gray = cv2.cvtColor(input, cv2.COLOR_BGR2GRAY)

# Binary threshold
_, gray = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)

# Morphological improvements of the mask
gray = cv2.morphologyEx(gray, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
gray = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11)))

# Find contours
cnts, _ = cv2.findContours(gray, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)

# Filter large size contours; at the end, there should only be one left
largeCnts = []
for cnt in cnts:
    if (cv2.contourArea(cnt) > 10000):
        largeCnts.append(cnt)

# Draw (filled) contour(s)
gray = np.uint8(np.zeros(gray.shape))
gray = cv2.drawContours(gray, largeCnts, -1, 255, cv2.FILLED)

# Calculate background pixel area
bgArea = input.shape[0] * input.shape[1] - cv2.countNonZero(gray)

# Put result on input image
input = cv2.putText(input, 'Background area: ' + str(bgArea), (20, 30), cv2.FONT_HERSHEY_COMPLEX_SMALL, 1.0, (255, 255, 255))

cv2.imwrite('images/output.png', input)

The intermediate "mask" image looks like this:

Mask

And, the final output looks like this:

Output

Upvotes: 5

Mark Setchell
Mark Setchell

Reputation: 207540

If you take the very top line/row of your image and the very bottom line/row and threshold them you will get this diagram where I have placed the top row at the top and the bottom row at the bottom just outside the limits of the original image - there is no need for you to do that, I am just illustrating the technique.

enter image description here

Now look where the lines change from black to white and then white to black (circled in red at the top). Unfortunately, your images have annotations and axes which I had to trim off so your number will not be identically the same. On the top line/row, my image changes from black to white at column 319 and back to black at column 648. If I add those together I get 966 and divide by 2, the image centre on the x-axis is at column 483.

Looking at the bottom line/row the transitions (circled in red) are at columns 234 and 736 which add up to 970 which makes 485 when averaged, so we know the circle centre is on vertical image column 483-485 or say 484.

Then you should now be able to work out the image centre and radius and mask the image to accurately calculate the background.

Upvotes: 3

joeforker
joeforker

Reputation: 41757

Try the experimental floodfill() method. https://pillow.readthedocs.io/en/5.1.x/reference/ImageDraw.html?highlight=floodfill#PIL.ImageDraw.PIL.ImageDraw.floodfill

If all your images are like the example, just pick two or four corners of your image to fill with, say, hot pink and count that.

See also Image Segmentation with Watershed Algorithm which is much like flood fill but without relying on a single unique color.

Upvotes: 2

mrk
mrk

Reputation: 10386

Since you are open to OpenCV approaches you could use a SimpleBlobDetector

Obviously the result I got is also not perfect, since there are a lot of hyperparameters to set. The hyperparameters make it pretty flexible, so it is a decent place to start from.

This is what the Detector does (see details here):

  1. Thresholding: Convert the source images to several binary images by thresholding the source image with thresholds starting at minThreshold. These thresholds are incremented by thresholdStep until maxThreshold. So the first threshold is minThreshold, the second is minThreshold + thresholdStep, the third is minThreshold + 2 x thresholdStep, and so on.
  2. Grouping: In each binary image, connected white pixels are grouped together. Let’s call these binary blobs.
  3. Merging: The centers of the binary blobs in the binary images are computed, and blobs located closer than minDistBetweenBlobs are merged.

  4. Center & Radius Calculation: The centers and radii of the new merged blobs are computed and returned.

Find the code bellow the image.

Output Image

# Standard imports
import cv2
import numpy as np

# Read image
im = cv2.imread("petri.png", cv2.IMREAD_COLOR)

# Setup SimpleBlobDetector parameters.
params = cv2.SimpleBlobDetector_Params()

# Change thresholds
params.minThreshold = 0
params.maxThreshold = 255

# Set edge gradient
params.thresholdStep = 5

# Filter by Area.
params.filterByArea = True
params.minArea = 10

# Set up the detector with default parameters.
detector = cv2.SimpleBlobDetector_create(params)

# Detect blobs.
keypoints = detector.detect(im)

# Draw detected blobs as red circles.
# cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS ensures the size of the circle corresponds to the size of blob
im_with_keypoints = cv2.drawKeypoints(im, keypoints, np.array([]), (0, 0, 255),
                                      cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

# Show keypoints
cv2.imshow("Keypoints", im_with_keypoints)
cv2.waitKey(0)

Upvotes: 3

Related Questions