Source code for scopexr.circle_detection

# SCOPE-XR (Single-image Characterization Of PErformance in X-Ray systems)
# Copyright (C) 2026  Jacopo Altieri
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

import cv2
import numpy as np
from scipy.ndimage import center_of_mass


[docs] def detect_circle_hough( img: np.ndarray, dp: float, min_dist: float, param1: int, param2: int, min_radius: int, max_radius: int, output_path: str = None, debug: bool = False, ) -> tuple[float, float, float] | None: """ Detect a single circle in a grayscale image using the Hough Circle Transform. Parameters ---------- img 2D array representing the input grayscale image. dp Inverse ratio of the accumulator resolution to the image resolution. For example, dp=1 means the accumulator has the same resolution as the image. min_dist Minimum distance between the centers of detected circles (in pixels). param1 Higher threshold for the internal Canny edge detector (lower is half). param2 Accumulator threshold for the circle centers at the detection stage. Smaller values will detect more circles (including false ones). min_radius Minimum circle radius (in pixels) to search for. max_radius Maximum circle radius (in pixels) to search for. If <= 0, no upper limit is applied. debug If True, display the detected circle overlaid on the image in a pop-up window. Defaults to False. Returns ------- x: float x coordinate of the detected circle center. y: float y coordinate of the detected circle center. r: float radius of the detected circle. None If no circle is found. Raises ------ FileNotFoundError If the input `img` is None. """ if img is None: raise FileNotFoundError(f"Could not open '{img}'") p1 = np.percentile(img, 1) p99 = np.percentile(img, 99) img_clipped = np.clip(img, p1, p99) # Normalize to 0-255 img_8bit = ((img_clipped - p1) / (p99 - p1) * 255).astype(np.uint8) blurred = cv2.medianBlur(img_8bit, 5) # Perform Hough Circle Transform circles = cv2.HoughCircles( blurred, cv2.HOUGH_GRADIENT, dp=dp, minDist=min_dist, param1=param1, param2=param2, minRadius=min_radius, maxRadius=max_radius if max_radius > 0 else None, ) if circles is None: print("No circles found") return None # Round and pick the strongest circle (first one) circles = np.uint16(np.around(circles)) x, y, r = circles[0][0] output = cv2.cvtColor(img_8bit, cv2.COLOR_GRAY2BGR) cv2.circle(output, (x, y), r, (0, 255, 0), 2) cv2.circle(output, (x, y), 2, (0, 0, 255), 3) cv2.imwrite(f"{output_path}/detected_circle.png", output) if debug: display_scale = 0.5 output_resized = cv2.resize(output, (0, 0), fx=display_scale, fy=display_scale) cv2.imshow("Detected Circle", output_resized) cv2.waitKey(0) cv2.destroyAllWindows() return x, y, r
[docs] def estimate_circle(cropped: np.ndarray) -> tuple[float, float, float]: """ Estimate circle parameters using Center of Mass and Equivalent Area. Methods: - Center (cx, cy): Calculated via center_of_mass on the binary mask. - Radius: Calculated from the area (Area = pi * r^2). """ h, w = cropped.shape # 1. Thresholding to create a binary mask # We use a float cast to avoid overflow during min/max calc img_float = cropped.astype(np.float32) threshold = (np.min(img_float) + np.max(img_float)) / 2.0 mask = img_float >= threshold # Handle empty image case if not np.any(mask): return w / 2.0, h / 2.0, 0.0 # 2. Calculate Center (y, x) # This uses all pixels in the mask to find the geometric centroid cy, cx = center_of_mass(mask) # 3. Calculate Radius from Area (i.e., number of pixels in the mask) # Area = pi * r^2 -> r = sqrt(Area / pi) area = np.sum(mask) radius_estimate = np.sqrt(area / np.pi) return float(cx), float(cy), float(radius_estimate)
[docs] def is_circle_centered( cropped: np.ndarray, cx: float, cy: float, margin: float = 0.1 ) -> bool: """ Check if the estimated circle center is within `margin` of the cropped image center in both the x- and y-directions. Returns True if it is, False otherwise. Parameters ---------- cropped cropped image containing the circle cx x coordinate of the estimated circle center cy y coordinate of the estimated circle center margin allowable fraction of width/height (default 0.1) Returns ------- bool True if the circle center is within the margin from the image center """ h, w = cropped.shape[:2] center_x, center_y = w / 2, h / 2 return (abs(cx - center_x) < margin * w) and (abs(cy - center_y) < margin * h)