Code Library
Quality & InspectionPythonPython 3.8+ · opencv-pythonMIT licenseIntermediate

Presence/absence part check with OpenCV (Python)

"Are all four parts actually in the tray" is the most common inspection question in a shop, and it doesn't need a trained model to answer — a threshold and a contour count get you most of the way. This script converts a photo to grayscale, blurs it slightly to kill sensor noise, thresholds it to black-and-white, counts the blobs above a minimum area, and reports PASS or FAIL against how many you told it to expect. Tested here against a synthetic tray image with a known, deliberately-missing part before this ever needed a real camera.

Before you run it

  • pip install opencv-python
  • A reasonably consistent, evenly lit photo — a phone camera on a tripod over a tray works

The code

GitHub
"""Presence/absence check: does the image contain the expected number of
parts (by contour count), within an area range that filters out noise?

Usage:  python presence_check.py photo.jpg --expect 4 --min-area 500
"""

import argparse
import sys

import cv2


def count_parts(image_path, min_area, thresh):
    img = cv2.imread(image_path)
    if img is None:
        sys.exit(f"Could not read {image_path}")

    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    _, binary = cv2.threshold(blurred, thresh, 255, cv2.THRESH_BINARY_INV)

    contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    parts = [c for c in contours if cv2.contourArea(c) >= min_area]
    return parts, img


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("image", help="photo to check")
    ap.add_argument("--expect", type=int, required=True, help="expected part count")
    ap.add_argument("--min-area", type=float, default=500,
                     help="contours smaller than this (px^2) are noise, not parts")
    ap.add_argument("--thresh", type=int, default=127,
                     help="binary threshold, 0-255 - tune to your lighting")
    ap.add_argument("--annotated", help="save an annotated copy here")
    args = ap.parse_args()

    parts, img = count_parts(args.image, args.min_area, args.thresh)
    found = len(parts)
    status = "PASS" if found == args.expect else "FAIL"

    print(f"{status}: found {found} part(s), expected {args.expect}")

    if args.annotated:
        cv2.drawContours(img, parts, -1, (0, 255, 0), 3)
        for i, c in enumerate(parts):
            x, y, w, h = cv2.boundingRect(c)
            cv2.putText(img, str(i + 1), (x, y - 8),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2)
        cv2.imwrite(args.annotated, img)
        print(f"Annotated image saved to {args.annotated}")

    sys.exit(0 if status == "PASS" else 1)


if __name__ == "__main__":
    main()

What you get

$ python presence_check.py tray.jpg --expect 4 --annotated checked.jpg
PASS: found 4 part(s), expected 4
Annotated image saved to checked.jpg
 
$ python presence_check.py tray_missing_one.jpg --expect 4
FAIL: found 3 part(s), expected 4

How it works

  • GaussianBlur before thresholding is the single cheapest improvement over a naive threshold — it smooths out sensor noise and small reflections that would otherwise count as tiny, spurious contours.
  • THRESH_BINARY_INV inverts the threshold so dark parts on a light tray become white blobs — flip back to plain THRESH_BINARY if your setup is the opposite (light parts on a dark mat).
  • min_area is the whole noise filter: a stray shadow or a scratch on the tray produces a contour too, but almost always a much smaller one than an actual part — filtering by area is simpler and more robust than trying to threshold noise away perfectly.
  • The script exits with status code 0 on PASS and 1 on FAIL — that's on purpose, so it drops straight into a CI-style pass/fail check or a shell script without any extra parsing.

Gotchas & honest limits

  • Lighting is the whole project, same as every vision task: a fixed threshold that works under one light will fail under another. Tune --thresh for your actual setup, or move to adaptive thresholding (cv2.adaptiveThreshold) if lighting varies.
  • This counts blobs, not verified part identity — four blobs that happen to be four screws satisfies the count even if one should have been a bolt. Presence/absence is not the same claim as "the right parts."
  • Touching or overlapping parts can merge into one contour and undercount — good part separation on the tray matters as much as the code.
  • --min-area and --thresh are photo-specific; recalibrate both when the camera, distance, or lighting changes, not just when the part changes.

Goes deeper

Want this adapted to your shop — or built into a real tool?

Samples are the free 80%. The last 20% is the part I do for a living.

Get in touch
Home
Blog
Tools
Code
Email
LinkedIn
Résumé