Measuring Size and Shape
From pixels to millimetres.
Supermarkets pay more for large apples. So every fruit has to be graded by size. And a misshapen fruit goes to the juice line. The camera sees pixels, but the customer pays for millimetres. So we need to convert.

- Large apples earn more
- Misshapen ones go to juice
- Pixels to millimetres
Contours and grades
We've calibrated the camera, so we know that one pixel covers about point nine of a millimetre. Find contours gives the outline of each apple. The smallest enclosing circle gives a diameter. And roundness compares area with perimeter. A perfect circle scores one. Run it. Each apple gets a size in millimetres, a roundness, and a grade. Roundness sits at about point nine, because real outlines made of pixels are slightly jagged. So a limit of about point seven five would catch the misshapen ones. And that's a working fruit grader, with no neural network at all.
import cv2
import numpy as np
from belt import belt
img, boxes, _ = belt(n=6)
mask = cv2.inRange(cv2.cvtColor(img, cv2.COLOR_BGR2HSV), (0, 90, 40), (12, 255, 255))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((15, 15), np.uint8))
mm_per_pixel = 0.9
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for c in sorted(contours, key=cv2.contourArea, reverse=True)[:4]:
(x, y), r = cv2.minEnclosingCircle(c)
area, perimeter = cv2.contourArea(c), cv2.arcLength(c, True)
roundness = 4 * np.pi * area / perimeter ** 2
grade = "Large" if r * 2 * mm_per_pixel >= 75 else "Medium"
print(f"{r * 2 * mm_per_pixel:5.1f} mm roundness {roundness:.2f} {grade}")85.6 mm roundness 0.90 Large 85.6 mm roundness 0.90 Large 85.3 mm roundness 0.90 Large 74.6 mm roundness 0.90 Medium
Also uses belt.py
import numpy as np
import cv2
def belt(n=7, seed=1, size=(480, 640), bruises=2, light=1.0):
"""Draw a simulated conveyor belt of apples. Returns the image and the true boxes."""
rng = np.random.default_rng(seed)
h, w = size
img = np.full((h, w, 3), (70, 75, 80), np.uint8)
img = cv2.add(img, rng.normal(0, 6, img.shape).clip(0, 30).astype(np.uint8))
boxes, spots, taken = [], [], []
while len(boxes) < n:
r = int(rng.integers(32, 48))
x, y = int(rng.integers(r + 5, w - r - 5)), int(rng.integers(r + 5, h - r - 5))
if any((x - a) ** 2 + (y - b) ** 2 < (r + c + 8) ** 2 for a, b, c in taken):
continue
taken.append((x, y, r))
red = int(rng.integers(150, 225))
cv2.circle(img, (x, y), r, (35, 40, red), -1, cv2.LINE_AA)
cv2.circle(img, (x - r // 3, y - r // 3), r // 4, (90, 90, min(255, red + 30)), -1, cv2.LINE_AA)
boxes.append((x - r, y - r, x + r, y + r))
for x, y, r in taken[:bruises]:
cv2.ellipse(img, (x + r // 3, y + r // 4), (r // 3, r // 4), 20, 0, 360, (25, 45, 70), -1, cv2.LINE_AA)
spots.append((x, y, r))
img = cv2.GaussianBlur(img, (3, 3), 0)
return np.clip(img * light, 0, 255).astype(np.uint8), boxes, spotsGetting measurements right
Accuracy depends on calibration. Keep the camera at a fixed height, or a moved camera will change every measurement. Lens distortion bends straight lines near the edges, so correct it first. Objects at different heights appear at different sizes. And always check your system against a real object, of a known size, every day.
Summary
Calibrated pixels give you real measurements. Next, a classic algorithm for finding perfect shapes.
Key takeaways- One pixel equals a known distance
- Roundness measures shape
- Check with a reference object