I converted Joel's C++ to Java (since I'm working in Java). That is not an easy task since the OpenCV Java version is more than a little different from the C++ version.
I added what I wanted and what I think the OP is looking for: result values of each blob that are used for selection criteria. Not only do I get a list of blobs matching the criteria, but I can know what the values are for each blob.
The code does work ... except for convexity. It crashes hard for some reason.
I wasn't able to extend the SimpleBlobDetector. Might be possible, but I don't see much value in that. I did extend the param class which is not much value since it's a simple class of config values. But I used it as that is what Joel did and it does force the implementation to be similar to the SBD.
There are some utility classes/method that are not here, and I leave as an exercise. They are relatively easy things like calculating the hypotenuse of a right triangle.
package com.papapill.vision.detection;
import static com.papapill.utility.OpenCvUtil.getDistance;
import static com.papapill.utility.OpenCvUtil.roundToInt;
import androidx.annotation.NonNull;
import com.papapill.support.ImageLocation;
import org.opencv.core.KeyPoint;
import org.opencv.core.Mat;
import org.opencv.core.MatOfInt;
import org.opencv.core.MatOfPoint;
import org.opencv.core.MatOfPoint2f;
import org.opencv.core.Point;
import org.opencv.features2d.SimpleBlobDetector;
import org.opencv.features2d.SimpleBlobDetector_Params;
import org.opencv.imgproc.Imgproc;
import org.opencv.imgproc.Moments;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Provides similar capability as {@link SimpleBlobDetector} with richer results --
* properties of each blob that were used to select/filter.
*
* @implNote
* <a href="https://mcmap.net/q/1623299/-how-to-get-extra-information-of-blobs-with-simpleblobdetector">See</a>
* <p>
* Example code passes Point to norm() to get what it calls 'dist' (which I assume is distance),
* but in Java, norm() requires a Mat and I don't know how to convert a Point to a Mat :(
* Computing distance is easy enough using Pythagorean theorem; and probably faster than norm().
* <a href="https://mcmap.net/q/1633516/-using-opencv-norm-function-to-get-euclidean-distance-of-two-points">See</a>
*/
public final class BlobDetector {
/**
* Extends {@link SimpleBlobDetector} params class to include criteria of the C++ OpenV,
* but missing from the Java version.
*/
public static final class Criteria extends SimpleBlobDetector_Params {
/**
* Blob color filter.
* See {@link SimpleBlobDetector}
*
* @implNote
* This criteria seems to be excluded from the Java interface since the value is declared
* in C++ as uchar which is not supposed in Java. It's just a byte.
*/
private byte blobColor;
public byte get_blobColor() {
return blobColor;
}
public void set_blobColor(byte to) {
blobColor = to;
}
}
/**
* Describes a blob.
* Fields are immutable except for contour and keypoint since OpenCV types are mutable.
*/
public static final class Blob {
public Blob(
@NonNull MatOfPoint contour,
@NonNull KeyPoint keyPoint,
@NonNull ImageLocation center,
double area,
double circularity,
double inertia,
double convexity,
double size,
byte color) {
this.contour = contour;
this.keyPoint = keyPoint;
this.center = center;
this.size = size;
this.area = area;
this.circularity = circularity;
this.inertia = inertia;
this.convexity = convexity;
this.color = color;
}
/**
* Contour representation.
*/
public final MatOfPoint contour;
/**
* KeyPoint representation.
*/
public final KeyPoint keyPoint;
/**
* Area in square pixels.
*/
public final double area;
/**
* Point of the image that is the center of the shape.
*/
public final ImageLocation center;
/**
* Color.
*/
public final byte color;
/**
* Represents how convex the shape is from 0 to 1 where 1 is completely convex.
*/
public final double convexity;
/**
* Represents how circular the shape is from 0 to 1 where 1 is a circle.
*/
public final double circularity;
/**
* Represents how much the shape is close-loop like from 0 (line) to 1 (close-loop).
*/
public final double inertia;
/**
* Size a la simple blob detector which is not intended to be precise, but can be helpful
* to compare relative size of blobs.
*/
public final double size;
}
/**
* Internal class for collecting blob info.
*/
private static final class BlobInfo {
public BlobInfo(MatOfPoint contour) {
this.contour = contour;
}
public final MatOfPoint contour;
public double confidence = 1;
public double area;
public double circularity;
public double inertia;
public double convexity;
public byte color;
public Point center;
public double size;
public Blob build(Point keyPointLocation) {
var middleBlobInfo = this;
ImageLocation center = ImageLocation.fromCvPoint(middleBlobInfo.center);
double radius = middleBlobInfo.size;
MatOfPoint contour = middleBlobInfo.contour;
KeyPoint keyPoint = new KeyPoint((float)keyPointLocation.x, (float)keyPointLocation.y, (float) radius);
return new Blob(
contour,
keyPoint,
center,
radius,
area,
circularity,
inertia,
convexity,
color);
}
}
public BlobDetector() {
this.criteria = new Criteria();
}
private Criteria criteria;
public Criteria getCriteria() {
return criteria;
}
public void setCriteria(@NonNull Criteria to) {
criteria = to;
}
public List<Blob> detect(@NonNull Mat image) {
var blobs = new ArrayList<Blob>();
// convert to grayscale if not already
Mat grayscaleImage;
if (image.channels() == 3) {
grayscaleImage = new Mat();
Imgproc.cvtColor(image, grayscaleImage, Imgproc.COLOR_BGR2GRAY);
} else {
grayscaleImage = image;
}
List<List<BlobInfo>> blobsBySameThreshold = detectForEachThreshold(grayscaleImage);
// select blobs by repeatability -- number of times found over set of thresholds
for (var blobsForSomeThreshold : blobsBySameThreshold) {
if (blobsForSomeThreshold.size() >= criteria.get_minRepeatability()) {
var sumPoint = new Point(0, 0);
double normalizer = 0;
for (var blobForSomeThreshold : blobsForSomeThreshold) {
sumPoint = PointUtil.plus(sumPoint, PointUtil.times(blobForSomeThreshold.center, blobForSomeThreshold.confidence));
normalizer += blobForSomeThreshold.confidence;
}
sumPoint = PointUtil.times(sumPoint, 1.0 / normalizer);
BlobInfo middleBlobInfo = blobsForSomeThreshold.get(blobsForSomeThreshold.size() / 2);
blobs.add(middleBlobInfo.build(sumPoint));
}
}
return blobs;
}
/**
* Returns blob-info for each contour that satisfies the selection criteria -- for each
* threshold specified in the selection criteria.
*/
private List<List<BlobInfo>> detectForEachThreshold(Mat grayscaleImage) {
List<List<BlobInfo>> blobsBySameThreshold = new ArrayList<>();
for (double thresh = criteria.get_minThreshold(); thresh < criteria.get_maxThreshold(); thresh += criteria.get_thresholdStep()) {
Mat monochromeImage = new Mat();
Imgproc.threshold(grayscaleImage, monochromeImage, thresh, 255, Imgproc.THRESH_BINARY);
List<BlobInfo> blobsForThreshold = detectAndSelectBlobs(monochromeImage);
List<List<BlobInfo>> newBlobs = new ArrayList<>();
for (var blob : blobsForThreshold) {
boolean isNew = true;
for (var blobsForSomeOtherThreshold : blobsBySameThreshold) {
double distance = getDistance(blobsForSomeOtherThreshold.get(blobsForSomeOtherThreshold.size() / 2).center, blob.center);
isNew =
distance >= criteria.get_minDistBetweenBlobs() &&
distance >= blobsForSomeOtherThreshold.get(blobsForSomeOtherThreshold.size() / 2).size &&
distance >= blob.size;
if (!isNew) {
blobsForSomeOtherThreshold.add(blob);
int k = blobsForSomeOtherThreshold.size() - 1;
while (k > 0 && blobsForSomeOtherThreshold.get(k).size < blobsForSomeOtherThreshold.get(k-1).size) {
blobsForSomeOtherThreshold.set(k, blobsForSomeOtherThreshold.get(k-1));
k--;
}
blobsForSomeOtherThreshold.set(k, blob);
break;
}
}
if (isNew) {
var item = new ArrayList<BlobInfo>();
item.add(blob);
newBlobs.add(item);
}
}
blobsBySameThreshold.addAll(newBlobs);
}
return blobsBySameThreshold;
}
/**
* Finds contours in the image and adds a blob-info for each that satisfies the selection criteria.
*/
private List<BlobInfo> detectAndSelectBlobs(Mat monochromeImage) {
var blobInfos = new ArrayList<BlobInfo>();
List<MatOfPoint> contours = new ArrayList<>();
Mat tmpMonochromeImage = monochromeImage.clone(); // why clone??
Imgproc.findContours(tmpMonochromeImage, contours, new Mat(), Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_NONE);
for (var contour : contours) {
BlobInfo blobInfo = new BlobInfo(contour);
if (addBlobInfoIfSelected(blobInfo, contour, monochromeImage)) {
blobInfos.add(blobInfo);
}
}
return blobInfos;
}
/**
* Calculates the attributes of a contour, caches them into blob-info and returns true if all
* attributes satisfy the selection criteria.
* If not selected, then returns false when some of the blob-info attributes may not be loaded.
*/
private boolean addBlobInfoIfSelected(BlobInfo blobInfo, MatOfPoint contour, Mat monochromeImage) {
Moments moments = Imgproc.moments(contour);
// filter on area
if (criteria.get_filterByArea()) {
double area = moments.m00;
if (area < criteria.get_minArea() || area >= criteria.get_maxArea()) {
return false;
}
blobInfo.area = area;
}
// filter on circularity
if (criteria.get_filterByCircularity()) {
double area = moments.m00;
MatOfPoint2f contoursMatOfPoint2f = new MatOfPoint2f();
contoursMatOfPoint2f.fromArray(contour.toArray());
double perimeter = Imgproc.arcLength(contoursMatOfPoint2f, true);
double circularity = 4 * Math.PI * area / (perimeter * perimeter);
if (circularity < criteria.get_minCircularity() || circularity >= criteria.get_maxCircularity()) {
return false;
}
blobInfo.circularity = circularity;
}
// filter on inertia
if (criteria.get_filterByInertia()) {
double denominator = Math.sqrt(Math.pow(2 * moments.mu11, 2) + Math.pow(moments.mu20 - moments.mu02, 2));
double eps = 1e-2;
double inertia;
if (denominator > eps) {
double cosmin = (moments.mu20 - moments.mu02) / denominator;
double sinmin = 2 * moments.mu11 / denominator;
double cosmax = -cosmin;
double sinmax = -sinmin;
double imin = 0.5 * (moments.mu20 + moments.mu02) - 0.5 * (moments.mu20 - moments.mu02) * cosmin - moments.mu11 * sinmin;
double imax = 0.5 * (moments.mu20 + moments.mu02) - 0.5 * (moments.mu20 - moments.mu02) * cosmax - moments.mu11 * sinmax;
inertia = imin / imax;
} else {
inertia = 1;
}
if (inertia < criteria.get_minInertiaRatio() || inertia >= criteria.get_maxInertiaRatio()) {
return false;
}
blobInfo.confidence = inertia * inertia;
blobInfo.inertia = inertia;
}
// filter on convexity
if (criteria.get_filterByConvexity()) {
MatOfInt hull = new MatOfInt();
Imgproc.convexHull(contour, hull);
double area = Imgproc.contourArea(contour);
double hullArea = Imgproc.contourArea(hull);
double convexity = area / hullArea;
if (convexity < criteria.get_minConvexity() || convexity >= criteria.get_maxConvexity()) {
return false;
}
blobInfo.convexity = convexity;
}
blobInfo.center = new Point(moments.m10 / moments.m00, moments.m01 / moments.m00);
// filter on color
if (criteria.get_filterByColor()) {
byte blobColor = criteria.get_blobColor();
Mat.Atable<Byte> pixel = monochromeImage.at(byte.class, roundToInt(blobInfo.center.y), roundToInt(blobInfo.center.x));
byte pixelColor = pixel.getV();
if (pixelColor != blobColor) {
return false;
}
blobInfo.color = pixelColor;
}
// calculate a size
{
List<Double> distances = new ArrayList<>();
Point[] contourPoints = contour.toArray();
for (Point contourPoint : contourPoints) {
double distance = getDistance(blobInfo.center, contourPoint);
distances.add(distance);
}
Collections.sort(distances);
double medianA = distances.get((distances.size() - 1) / 2);
double medianB = distances.get(distances.size() / 2);
double average = (medianA + medianB) / 2.0;
blobInfo.size = average;
}
return true;
}
}