ARTICLE AD BOX
I am building an OMR (Optical Mark Recognition) system in Python using OpenCV. My answer sheet has 180 bubbles arranged in 4 columns of 45 questions, with 4 options (A, B, C, D) per question. Each bubble is an empty circle with a colored outline and white fill, and when a student marks an answer it becomes a dark filled circle. I need to first detect all bubble positions whether they are filled or not, and then measure which ones are actually filled.
My current approach is to threshold the image and find contours. I convert the image to grayscale, apply CLAHE for contrast enhancement, then use Otsu thresholding with THRESH_BINARY_INV, and finally filter contours by area and circularity like this:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) gray = clahe.apply(gray) _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) bubbles = [] for cnt in contours: area = cv2.contourArea(cnt) if not (60 <= area <= 3000): continue peri = cv2.arcLength(cnt, True) circularity = 4 * math.pi * area / (peri ** 2) if circularity < 0.45: continue bubbles.append(cnt)The problem is that on a digital screenshot of the PDF, the bubbles are just thin circle outlines with white inside. After thresholding, the empty bubbles have almost no filled pixel area, so cv2.contourArea() returns a very small value and they all get filtered out, giving me zero detected bubbles. I also tried adaptive thresholding with cv2.ADAPTIVE_THRESH_GAUSSIAN_C but it still fails because the bubble outline is only 1 to 2 pixels wide after thresholding.
The sheet is generated using ReportLab where bubbles are drawn as canvas.circle(x, y, r, fill=1, stroke=1) with white fill and a red stroke, giving a bubble radius of about 3.6pt in the PDF which is roughly 5 to 8 pixels in a typical screenshot. The same sheet is also printed and filled by students with a pencil, then photographed, so the solution needs to work for both digital screenshots and real photos.
I have read about cv2.HoughCircles which seems like it could work since it detects circular shapes based on gradients rather than filled regions, but I am unsure how to tune param1, param2, minRadius, and maxRadius for this specific case. I also considered template matching using a programmatically generated circle template, but I am not sure which approach is more reliable here. What is the correct method to detect all bubble positions regardless of whether they are filled, and once the positions are known, what is the best way to measure the fill ratio inside each circle?
Environment: Python 3.11, OpenCV 4.8, Windows 11. Input is either a PNG screenshot of the PDF or a JPG photo of the printed and filled sheet.
