How to detect transparent glass using a reference object and OpenCV

Based on contributions by YoussefO.

Transparent glass is very hard to detect directly with a camera: it has almost no visible contour of its own. The workaround is to not detect the glass directly. Instead, detect a reference object mounted next to or around it (a dark gasket, border or alignment mark) to find where the glass should be, then use controlled lighting to trigger a measurable optical signature — a specular reflection — that confirms the glass is actually there.

What you need

  • Python with OpenCV: pip install opencv-python

  • NumPy: pip install numpy

  • A camera and a controllable light source (something you can switch on and off in software or manually between the two shots).

  • A high-contrast reference object attached to or framing the glass, for example:

    • a dark rubber or foam gasket ring around a circular glass lens
    • a black adhesive border around a rectangular glass panel
    • a coloured plastic housing that frames the glass
    • a printed alignment mark on a carrier substrate

    The reference object needs to have high contrast against its surroundings under normal ambient light, so it can be detected without any special illumination.

Steps

1. Capture two images instead of one

Capture two images of the same item in the same position, with different lighting:

  • Image A — reference detection. Direct illumination off. The reference object has high contrast and is used only to locate the region of interest (ROI).
  • Image B — glass detection. Direct illumination on. Used only to count reflection dots inside the ROI found in Image A. Glass produces one or more bright specular reflection spots under direct light; the reference object does not need to be visible in this image.

Why not just one image? With the light on, a dark gasket ring loses contrast and its contour becomes unreliable to detect. With the light off, the glass produces no reflection and can’t be detected at all. Splitting the two checks into two images, each under the lighting that suits it, avoids both failure modes.

2. Phase 1 — find the reference object (Image A, light off)

Suppress bright spots. Before thresholding, replace any pixel brighter than a threshold with a neutral mid-gray value. This keeps the dark reference object intact while neutralizing stray highlights that would otherwise interfere with the next step.

gray_suppressed = gray.copy()
gray_suppressed[gray > REFLECTION_THRESH] = 128

Threshold for the dark reference object. Apply an inverse binary threshold to isolate pixels darker than a chosen value, then clean the mask up with morphological closing (fills small gaps in the outline) followed by opening (removes small noise blobs).

_, mask = cv2.threshold(gray_suppressed, BLACK_THRESH, 255, cv2.THRESH_BINARY_INV)

kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=3)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=2)

Find and filter the contour. Extract contours from the mask and filter by how round they are. A perfect circle scores 1.0; adjust the minimum circularity threshold to match how round your actual reference object is. If your reference object is rectangular rather than circular/ring-shaped, use a different shape filter here instead of circularity.

def circularity(contour) -> float:
    area = cv2.contourArea(contour)
    perim = cv2.arcLength(contour, True)
    return (4 * np.pi * area / perim ** 2) if perim else 0


3. Phase 2 — find reflection dots (Image B, light on)

Apply the ROI mask found in Phase 1 to Image B, then threshold for bright pixels within that masked region only. These bright spots are the specular reflections produced by the glass surface under direct light. A small morphological closing operation connects dots that are nearly touching.

roi_region = cv2.bitwise_and(gray_light, gray_light, mask=roi_mask)
_, dots = cv2.threshold(roi_region, REFLECTION_THRESH, 255, cv2.THRESH_BINARY)
dots = cv2.morphologyEx(dots, cv2.MORPH_CLOSE, kernel_small, iterations=2)

Count qualifying dots and decide. Find connected components in the reflection-dot mask, discard any below a minimum area (noise), and count what’s left. If the count meets or exceeds the minimum required number of dots, glass is declared present; otherwise it’s declared absent.

The minimum dot count is typically 1. Some glass types or lighting setups consistently produce 2 or 3 dots — in that case, raising the minimum can improve robustness against noise.

4. Full code

:warning: Check: the threshold and area constants below (BLACK_THRESH, REFLECTION_THRESH, MIN_CIRCULARITY, MIN_RING_AREA, MIN_REFL_AREA) are tuned for one specific camera, lighting rig and reference object. Treat them as a starting point and re-tune for your own setup, they will need adjusting for a different camera resolution, working distance or lighting intensity.

import logging
import numpy as np
import cv2

log = logging.getLogger(__name__)

BLACK_THRESH      = 100    # px darker than this = black tape / lens (dark img)
REFLECTION_THRESH = 230    # px brighter than this = reflection dot (light img)
MIN_REFL_AREA     = 5      # px² minimum per qualifying reflection dot
MIN_CIRCULARITY   = 0.75   # how round the glue-strip ring must be
MIN_RING_AREA      = 500   # px² minimum for the ring contour itself
MIN_DOT_COUNT      = 1     # reflection dots needed to declare glass present

DEBUG = False   # set True during commissioning to show imshow windows


def circularity(contour) -> float:
    area = cv2.contourArea(contour)
    perim = cv2.arcLength(contour, True)
    return (4 * np.pi * area / perim ** 2) if perim else 0


def _detect_glass_plate(frame_dark: np.ndarray, frame_light: np.ndarray) -> tuple[bool, int, str]:
    gray_dark = cv2.cvtColor(frame_dark, cv2.COLOR_BGR2GRAY)
    gray_light = cv2.cvtColor(frame_light, cv2.COLOR_BGR2GRAY)
    debug_dark = frame_dark.copy() if DEBUG else None
    debug_light = frame_light.copy() if DEBUG else None

    # Step 1: find the reference ring on the dark image
    gray_dark_sup = gray_dark.copy()
    gray_dark_sup[gray_dark > REFLECTION_THRESH] = 128
    if DEBUG:
        cv2.imshow("0 - Dark suppressed", gray_dark_sup)

    _, black_mask = cv2.threshold(gray_dark_sup, BLACK_THRESH, 255, cv2.THRESH_BINARY_INV)
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
    black_mask = cv2.morphologyEx(black_mask, cv2.MORPH_CLOSE, kernel, iterations=3)
    black_mask = cv2.morphologyEx(black_mask, cv2.MORPH_OPEN, kernel, iterations=2)
    if DEBUG:
        cv2.imshow("1 - Black mask (dark image)", black_mask)

    contours, _ = cv2.findContours(black_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    candidates = sorted(
        [c for c in contours if cv2.contourArea(c) > MIN_RING_AREA and circularity(c) > MIN_CIRCULARITY],
        key=cv2.contourArea,
        reverse=True,
    )
    if not candidates:
        log.warning("No reference ring found in dark image. Try lowering MIN_CIRCULARITY or BLACK_THRESH.")
        return False, 0, "NO_RING"

    glue_strip = candidates[0]
    log.debug(f"Glue strip: area={cv2.contourArea(glue_strip):.0f}  circ={circularity(glue_strip):.2f}")

    # Step 2: build the ROI from the ring
    roi_mask = np.zeros(gray_dark.shape, dtype=np.uint8)
    if len(glue_strip) >= 5:
        ellipse = cv2.fitEllipse(glue_strip)
        (cx, cy), (ma, mb), angle = ellipse
        inner_axes = (max(1, int(ma * 0.92 / 2)), max(1, int(mb * 0.92 / 2)))
        cv2.ellipse(roi_mask, (int(cx), int(cy)), inner_axes, angle, 0, 360, 255, -1)
        if DEBUG and debug_dark is not None:
            cv2.ellipse(debug_dark, ellipse, (255, 80, 0), 2)
            cv2.ellipse(debug_dark, (int(cx), int(cy)), inner_axes, angle, 0, 360, (0, 220, 255), 1)
    else:
        (cx, cy), r = cv2.minEnclosingCircle(glue_strip)
        cv2.circle(roi_mask, (int(cx), int(cy)), max(1, int(r * 0.92)), 255, -1)
    if DEBUG:
        cv2.imshow("2 - ROI mask", roi_mask)

    # Step 3: punch out the camera lens from the ROI, if it also matched the ring filter
    def centre_in_roi(c):
        (px, py), _ = cv2.minEnclosingCircle(c)
        y_idx = min(int(py), roi_mask.shape[0] - 1)
        x_idx = min(int(px), roi_mask.shape[1] - 1)
        return roi_mask[y_idx, x_idx] == 255

    inner = [c for c in candidates[1:] if centre_in_roi(c)]
    if inner:
        lens = inner[0]
        log.debug(f"Camera lens: area={cv2.contourArea(lens):.0f}  circ={circularity(lens):.2f}")
        if len(lens) >= 5:
            le = cv2.fitEllipse(lens)
            (lx, ly), (lma, lmb), la = le
            punch = (max(1, int(lma * 1.15 / 2)), max(1, int(lmb * 1.15 / 2)))
            cv2.ellipse(roi_mask, (int(lx), int(ly)), punch, la, 0, 360, 0, -1)
            if DEBUG and debug_dark is not None:
                cv2.ellipse(debug_dark, (int(lx), int(ly)), punch, la, 0, 360, (0, 80, 255), 2)
        else:
            (lx, ly), lr = cv2.minEnclosingCircle(lens)
            cv2.circle(roi_mask, (int(lx), int(ly)), max(1, int(lr * 1.15)), 0, -1)

    # Step 4: detect reflection dots on the LIGHT image inside the ROI
    gray_light_roi = cv2.bitwise_and(gray_light, gray_light, mask=roi_mask)
    _, refl_raw = cv2.threshold(gray_light_roi, REFLECTION_THRESH, 255, cv2.THRESH_BINARY)
    refl_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
    reflections = cv2.morphologyEx(refl_raw, cv2.MORPH_CLOSE, refl_kernel, iterations=2)
    if DEBUG:
        cv2.imshow("3 - Reflection dots (light image in ROI)", reflections)

    # Step 5: count qualifying dots
    refl_contours, _ = cv2.findContours(reflections, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    dot_count = 0
    for c in refl_contours:
        area = cv2.contourArea(c)
        if area < MIN_REFL_AREA:
            continue
        dot_count += 1
        log.debug(f"  Dot {dot_count}: area={area:.0f}")
        if DEBUG and debug_light is not None:
            x, y, w, h = cv2.boundingRect(c)
            cv2.rectangle(debug_light, (x, y), (x + w, y + h), (0, 255, 0), 2)
            cv2.putText(debug_light, f"{area:.0f}px", (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 0), 1)

    glass_present = dot_count >= MIN_DOT_COUNT

    if DEBUG:
        label_dark = f"Ring found  circ={circularity(glue_strip):.2f}"
        cv2.putText(debug_dark, label_dark, (10, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 80, 0), 2)
        cv2.imshow("4a - Dark: ring detection", debug_dark)

        label_light = f"GLASS PRESENT ({dot_count} dot{'s' if dot_count != 1 else ''})" if glass_present else "NO GLASS"
        color = (0, 255, 0) if glass_present else (0, 0, 255)
        if debug_light is not None:
            cv2.putText(debug_light, label_light, (10, 35), cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2)
            cv2.imshow("4b - Light: dot detection", debug_light)

        cv2.waitKey(0)
        cv2.destroyAllWindows()

    return glass_present, dot_count, "OK"


def check_back(img_dark: np.ndarray, img_light: np.ndarray) -> tuple[bool, list[str]]:
    """Public entry point: pass the dark-lit and light-lit images, get a pass/fail verdict."""
    faults = []
    try:
        glass_present, dot_count, detail = _detect_glass_plate(img_dark, img_light)
    except Exception as e:
        log.error(f"cv_check internal error: {e}")
        faults.append("CV_INTERNAL_ERROR")
        return False, faults

    if detail == "NO_RING":
        faults.append("GLASS_RING_NOT_FOUND")
        log.warning("Back: GLASS_RING_NOT_FOUND — reference ring contour missing in dark image.")
        return False, faults

    if not glass_present:
        faults.append("GLASS_MISSING")
        log.warning(
            f"Back: GLASS_MISSING — {dot_count} reflection dot(s) found "
            f"in light image (need >= {MIN_DOT_COUNT})."
        )
        return False, faults

    log.info(f"Back: GLASS_PRESENT — {dot_count} reflection dot(s) detected.")
    return True, []

Troubleshooting

  • NO_RING result: lower MIN_CIRCULARITY or BLACK_THRESH, or check that the light is genuinely off in Image A and the reference object still has enough contrast.
  • GLASS_MISSING even though glass is present: raise REFLECTION_THRESH if the light is very bright and washing out too much of the image, or check that the ROI (from the reference object) is actually positioned over the glass surface, not offset.
  • Inconsistent dot counts between otherwise-identical parts: consider raising MIN_DOT_COUNT above 1, since some glass/lighting combinations reliably produce 2–3 dots.

Related


Rewritten and consolidated (Sept 2026) from the original student how-to’s: HOW TO DETECT Transparent glass using a reference object and OpenCV.