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
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_RINGresult: lowerMIN_CIRCULARITYorBLACK_THRESH, or check that the light is genuinely off in Image A and the reference object still has enough contrast.GLASS_MISSINGeven though glass is present: raiseREFLECTION_THRESHif 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_COUNTabove 1, since some glass/lighting combinations reliably produce 2–3 dots.
Related
- How to zoom into an image or video frame with OpenCV using array slicing
- How to plan a basic vision-based quality control system
Rewritten and consolidated (Sept 2026) from the original student how-to’s: HOW TO DETECT Transparent glass using a reference object and OpenCV.





