scopexr 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scopexr/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.3.1"
@@ -0,0 +1,221 @@
1
+ # SCOPE-XR (Single-image Characterization Of PErformance in X-Ray systems)
2
+ # Copyright (C) 2026 Jacopo Altieri
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import argparse
18
+ import sys
19
+ import yaml
20
+
21
+
22
+ def get_merged_config() -> dict:
23
+ """
24
+ Parse command-line arguments and YAML configuration file,
25
+ merging them with the following priority:
26
+
27
+ 1. Code defaults (lowest priority)
28
+ 2. YAML config file
29
+ 3. Command-line arguments (highest priority)
30
+
31
+ Returns
32
+ -------
33
+ dict
34
+ Merged configuration dictionary
35
+ """
36
+ parser = argparse.ArgumentParser()
37
+
38
+ parser.add_argument(
39
+ "--config", type=str, default=r".\fs_args.yaml", help="Path to YAML config file"
40
+ )
41
+
42
+ # CLI arguments (short flags)
43
+ parser.add_argument("--f", type=str, help="Path to the image file (.raw/.png/.tif)")
44
+ parser.add_argument("--o", type=str, help="Output directory")
45
+ parser.add_argument("--p", type=float, help="Pixel size in mm")
46
+ parser.add_argument("--d", type=float, help="Circle diameter in mm")
47
+ parser.add_argument(
48
+ "--no_hough",
49
+ action="store_true",
50
+ default=None,
51
+ help="Skip Hough transform detection",
52
+ )
53
+ parser.add_argument("--m", type=float, help="Magnification")
54
+ parser.add_argument("--n", type=int, help="Minimum pixel count")
55
+ parser.add_argument("--nangles", type=int, help="Number of angles")
56
+ parser.add_argument("--hl", type=int, help="Half profile length")
57
+ parser.add_argument("--ds", type=int, help="Derivative step size")
58
+ parser.add_argument("--axis_shifts", type=int, help="Number of axis shift steps")
59
+ parser.add_argument("--filter", type=str, help="Reconstruction filter name")
60
+ parser.add_argument(
61
+ "--sym", action="store_true", default=None, help="Symmetrize the sinogram"
62
+ )
63
+ parser.add_argument("--show", action="store_true", default=None, help="Show plots")
64
+
65
+ shift_group = parser.add_mutually_exclusive_group()
66
+ shift_group.add_argument(
67
+ "--auto_shift",
68
+ action="store_true",
69
+ default=None,
70
+ help="Enable automatic sinogram centering.",
71
+ )
72
+ shift_group.add_argument(
73
+ "--manual_shift",
74
+ type=int,
75
+ default=None,
76
+ help="Provide a specific manual shift value (in pixels).",
77
+ )
78
+ shift_group.add_argument(
79
+ "--no_shift",
80
+ action="store_true",
81
+ default=None,
82
+ help="Disable all sinogram shifting.",
83
+ )
84
+
85
+ args, unknown = parser.parse_known_args()
86
+
87
+ # 1. Set code defaults (lowest priority)
88
+ config = {
89
+ "img_path": None,
90
+ "out_dir": "./output_fs",
91
+ "pixel_size": None,
92
+ "circle_diameter": None,
93
+ "magnification": None, # Will be auto-calculated if None or 0
94
+ "min_n": 6,
95
+ "n_angles": 360,
96
+ "profile_half_length": 50,
97
+ "derivative_step": 1,
98
+ "axis_shifts": 10,
99
+ "filter_name": "ramp",
100
+ "auto_shift": True,
101
+ "manual_shift": None,
102
+ "no_shift": False,
103
+ "no_hough": False,
104
+ "symmetrize": False,
105
+ "show_plots": False,
106
+ "hough_params": {
107
+ "dp": 1,
108
+ "min_dist": 100,
109
+ "param1": 100,
110
+ "param2": 30,
111
+ "min_radius": 10,
112
+ "max_radius": 500,
113
+ "debug": False,
114
+ },
115
+ }
116
+
117
+ # 2. Load YAML config (overwrites code defaults)
118
+ try:
119
+ with open(args.config, "r") as f:
120
+ yaml_config = yaml.safe_load(f)
121
+ if yaml_config:
122
+ config.update(yaml_config)
123
+ except FileNotFoundError:
124
+ print(
125
+ f"Warning: Config file not found at {args.config}. Using defaults.",
126
+ file=sys.stderr,
127
+ )
128
+ except Exception as e:
129
+ print(f"Error loading YAML config: {e}", file=sys.stderr)
130
+
131
+ # 3. Load CLI arguments (highest priority)
132
+ cli_to_config_keys = {
133
+ "f": "img_path",
134
+ "o": "out_dir",
135
+ "p": "pixel_size",
136
+ "d": "circle_diameter",
137
+ "no_hough": "no_hough",
138
+ "m": "magnification",
139
+ "n": "min_n",
140
+ "nangles": "n_angles",
141
+ "hl": "profile_half_length",
142
+ "ds": "derivative_step",
143
+ "axis_shifts": "axis_shifts",
144
+ "filter": "filter_name",
145
+ "sym": "symmetrize",
146
+ "show": "show_plots",
147
+ "auto_shift": "auto_shift",
148
+ "manual_shift": "manual_shift",
149
+ "no_shift": "no_shift",
150
+ }
151
+
152
+ cli_dict = vars(args)
153
+
154
+ for cli_key, config_key in cli_to_config_keys.items():
155
+ cli_value = cli_dict.get(cli_key)
156
+ # Only update if the CLI argument was *actually* given
157
+ if cli_value is not None:
158
+ config[config_key] = cli_value
159
+
160
+ # ---
161
+ # Check which CLI flag was *actually passed* (from cli_dict)
162
+ # and enforce priority.
163
+ # ---
164
+ if cli_dict.get("manual_shift") is not None:
165
+ # CLI --manual_shift was used
166
+ config["auto_shift"] = False
167
+ config["no_shift"] = False
168
+ config["manual_shift"] = cli_dict.get("manual_shift")
169
+ elif cli_dict.get("auto_shift") is True:
170
+ # CLI --auto_shift was used
171
+ config["auto_shift"] = True
172
+ config["no_shift"] = False
173
+ config["manual_shift"] = None
174
+ elif cli_dict.get("no_shift") is True:
175
+ # CLI --no_shift was used
176
+ config["auto_shift"] = False
177
+ config["no_shift"] = True
178
+ config["manual_shift"] = None
179
+ # If no CLI shift flag was passed, the config (from YAML or default) is used as-is.
180
+
181
+ return config
182
+
183
+
184
+ def validate_args(args: dict) -> None:
185
+ """
186
+ Validate the merged configuration arguments.
187
+ Parameters
188
+ ----------
189
+ args
190
+ Merged configuration dictionary
191
+
192
+ Raises
193
+ ------
194
+ ValueError
195
+ If any argument is invalid
196
+ """
197
+ if not args.get("img_path"):
198
+ raise ValueError("Image path is required. Use --f to specify the image file.")
199
+ if args.get("pixel_size") is None or args["pixel_size"] <= 0:
200
+ raise ValueError("Pixel size must be a positive number.")
201
+ if args.get("circle_diameter") is None or args["circle_diameter"] <= 0:
202
+ raise ValueError("Circle diameter must be a positive number.")
203
+ if args.get("magnification") is not None and args["magnification"] < 0:
204
+ raise ValueError(
205
+ "Magnification must be a positive number or 0 for automatic calculation."
206
+ )
207
+ if args.get("min_n") is None or args["min_n"] <= 0:
208
+ raise ValueError("Minimum pixel count must be a positive integer.")
209
+ if args.get("n_angles") is None or args["n_angles"] <= 0:
210
+ raise ValueError("Number of angles must be a positive integer.")
211
+ if args.get("profile_half_length") is None or args["profile_half_length"] <= 0:
212
+ raise ValueError("Half profile length must be a positive integer.")
213
+ if args.get("derivative_step") is None or args["derivative_step"] <= 0:
214
+ raise ValueError("Derivative step size must be a positive integer.")
215
+ if args.get("axis_shifts") is None or args["axis_shifts"] < 0:
216
+ raise ValueError("Axis shifts must be a non-negative integer.")
217
+
218
+ if args.get("manual_shift") is not None and not isinstance(
219
+ args["manual_shift"], int
220
+ ):
221
+ raise ValueError("Manual shift must be an integer.")
@@ -0,0 +1,276 @@
1
+ # SCOPE-XR (Single-image Characterization Of PErformance in X-Ray systems)
2
+ # Copyright (C) 2026 Jacopo Altieri
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import argparse
18
+ import sys
19
+ import yaml
20
+
21
+
22
+ def get_merged_config() -> dict:
23
+ """
24
+ Parse command-line arguments and YAML configuration file,
25
+ merging them with the following priority:
26
+
27
+ 1. Code defaults (lowest priority)
28
+ 2. YAML config file
29
+ 3. Command-line arguments (highest priority)
30
+
31
+ Returns
32
+ -------
33
+ dict
34
+ Merged configuration dictionary
35
+ """
36
+ parser = argparse.ArgumentParser()
37
+
38
+ parser.add_argument(
39
+ "--config",
40
+ type=str,
41
+ default=r".\psf_args.yaml",
42
+ help="Path to YAML config file",
43
+ )
44
+
45
+ # CLI arguments
46
+ parser.add_argument("--f", type=str, help="Path to the image file (.raw/.png/.tif)")
47
+ parser.add_argument("--o", type=str, help="Output directory")
48
+ parser.add_argument("--p", type=float, help="Pixel size in mm")
49
+ parser.add_argument("--d", type=float, help="Circle diameter in mm")
50
+ parser.add_argument(
51
+ "--no_hough",
52
+ action="store_true",
53
+ default=None,
54
+ help="Skip Hough transform detection",
55
+ )
56
+ parser.add_argument("--nangles", type=int, help="Number of angles")
57
+ parser.add_argument("--hl", type=int, help="Half profile length")
58
+ parser.add_argument("--ds", type=int, help="Derivative step size")
59
+ parser.add_argument("--axis_shifts", type=int, help="Number of axis shift steps")
60
+ parser.add_argument("--filter", type=str, help="Reconstruction filter name")
61
+ parser.add_argument(
62
+ "--sym", action="store_true", default=None, help="Symmetrize the sinogram"
63
+ )
64
+ parser.add_argument(
65
+ "--dtheta",
66
+ type=float,
67
+ help="Angle of circular sector for oversampling in degrees",
68
+ )
69
+ parser.add_argument(
70
+ "--oversampling_factor",
71
+ type=float,
72
+ help="Resample factor for oversampling (final grid spacing).",
73
+ )
74
+ parser.add_argument(
75
+ "--gaussian_sigma",
76
+ type=float,
77
+ help="Standard deviation of the Gaussian blur for oversampling. Set to 0 for traditional binned statistics (no blur), >0 for 3-step with Gaussian smoothing.",
78
+ )
79
+ parser.add_argument("--show", action="store_true", default=None, help="Show plots")
80
+
81
+ shift_group = parser.add_mutually_exclusive_group()
82
+ shift_group.add_argument(
83
+ "--auto_shift",
84
+ action="store_true",
85
+ default=None,
86
+ help="Enable automatic sinogram centering.",
87
+ )
88
+ shift_group.add_argument(
89
+ "--manual_shift",
90
+ type=int,
91
+ default=None,
92
+ help="Provide a specific manual shift value (in pixels).",
93
+ )
94
+ shift_group.add_argument(
95
+ "--no_shift",
96
+ action="store_true",
97
+ default=None,
98
+ help="Disable all sinogram shifting.",
99
+ )
100
+
101
+ oversample_group = parser.add_mutually_exclusive_group()
102
+ oversample_group.add_argument(
103
+ "--oversample",
104
+ dest="oversample",
105
+ action="store_true",
106
+ default=None,
107
+ help="Enable oversampling",
108
+ )
109
+ oversample_group.add_argument(
110
+ "--no_oversample",
111
+ dest="oversample",
112
+ action="store_false",
113
+ default=None,
114
+ help="Disable oversampling",
115
+ )
116
+
117
+ args, unknown = parser.parse_known_args()
118
+
119
+ # 1. Set code defaults (lowest priority)
120
+ config = {
121
+ "img_path": None,
122
+ "out_dir": "./output_psf",
123
+ "pixel_size": 0.1,
124
+ "circle_diameter": 100,
125
+ "no_hough": False,
126
+ "n_angles": 360,
127
+ "profile_half_length": 50,
128
+ "derivative_step": 1,
129
+ "axis_shifts": 10,
130
+ "manual_shift": None,
131
+ "filter_name": "ramp",
132
+ "symmetrize": False,
133
+ "auto_shift": True,
134
+ "no_shift": False,
135
+ "oversample": False,
136
+ "dtheta": 2,
137
+ "oversampling_factor": 4,
138
+ "gaussian_sigma": 0.0,
139
+ "show_plots": False,
140
+ "hough_params": {
141
+ "dp": 1,
142
+ "min_dist": 100,
143
+ "param1": 100,
144
+ "param2": 40,
145
+ "min_radius": 100,
146
+ "max_radius": 0,
147
+ "debug": False,
148
+ },
149
+ }
150
+
151
+ # 2. Load YAML config (overwrites code defaults)
152
+ try:
153
+ with open(args.config, "r") as f:
154
+ yaml_config = yaml.safe_load(f)
155
+ if yaml_config:
156
+ config.update(yaml_config)
157
+
158
+ # === BACKWARDS COMPATIBILITY FOR RESAMPLE2 ===
159
+ if "resample2" in yaml_config:
160
+ print(
161
+ f"Notice: Config file '{args.config}' uses the deprecated key 'resample2'.\n"
162
+ f" Please update it to 'oversampling_factor' when possible.\n",
163
+ file=sys.stderr,
164
+ )
165
+ # If they didn't explicitly provide the new key, map the old key's value to it
166
+ if "oversampling_factor" not in yaml_config:
167
+ yaml_config["oversampling_factor"] = yaml_config["resample2"]
168
+
169
+ config.update(yaml_config)
170
+
171
+ except FileNotFoundError:
172
+ print(
173
+ f"Warning: Config file not found at {args.config}. Using defaults.",
174
+ file=sys.stderr,
175
+ )
176
+ except Exception as e:
177
+ print(f"Error loading YAML config: {e}", file=sys.stderr)
178
+
179
+ # 3. Load CLI arguments (highest priority)
180
+ cli_to_config_keys = {
181
+ "f": "img_path",
182
+ "o": "out_dir",
183
+ "p": "pixel_size",
184
+ "d": "circle_diameter",
185
+ "no_hough": "no_hough",
186
+ "nangles": "n_angles",
187
+ "hl": "profile_half_length",
188
+ "ds": "derivative_step",
189
+ "axis_shifts": "axis_shifts",
190
+ "filter": "filter_name",
191
+ "sym": "symmetrize",
192
+ "show": "show_plots",
193
+ "oversample": "oversample",
194
+ "dtheta": "dtheta",
195
+ "oversampling_factor": "oversampling_factor",
196
+ "gaussian_sigma": "gaussian_sigma",
197
+ "auto_shift": "auto_shift",
198
+ "manual_shift": "manual_shift",
199
+ "no_shift": "no_shift",
200
+ }
201
+
202
+ cli_dict = vars(args)
203
+
204
+ for cli_key, config_key in cli_to_config_keys.items():
205
+ cli_value = cli_dict.get(cli_key)
206
+ # Only update if the CLI argument was *actually* given
207
+ if cli_value is not None:
208
+ config[config_key] = cli_value
209
+
210
+ # Check which CLI flags were *actually passed*
211
+ cli_manual_shift = cli_dict.get("manual_shift")
212
+ cli_auto_shift = cli_dict.get("auto_shift")
213
+ cli_no_shift = cli_dict.get("no_shift")
214
+
215
+ if cli_manual_shift is not None:
216
+ # CLI --manual_shift was used
217
+ config["auto_shift"] = False
218
+ config["no_shift"] = False
219
+ config["manual_shift"] = cli_manual_shift
220
+ elif cli_auto_shift is True:
221
+ # CLI --auto_shift was used
222
+ config["auto_shift"] = True
223
+ config["no_shift"] = False
224
+ config["manual_shift"] = None
225
+ elif cli_no_shift is True:
226
+ # CLI --no_shift was used
227
+ config["auto_shift"] = False
228
+ config["no_shift"] = True
229
+ config["manual_shift"] = None
230
+ # If no CLI shift flag was passed, the config (from YAML or default) is used as-is.
231
+
232
+ return config
233
+
234
+
235
+ def validate_args(args: dict) -> None:
236
+ """
237
+ Validate the merged configuration arguments.
238
+ Parameters
239
+ ----------
240
+ args
241
+ Merged configuration dictionary
242
+
243
+ Raises
244
+ ------
245
+ ValueError
246
+ If any argument is invalid
247
+ """
248
+ if not args.get("img_path"):
249
+ raise ValueError("Image path is required. Use --f to specify the image file.")
250
+ if args.get("pixel_size") is None or args["pixel_size"] <= 0:
251
+ raise ValueError("Pixel size must be a positive number.")
252
+ if args.get("circle_diameter") is None or args["circle_diameter"] <= 0:
253
+ raise ValueError("Circle diameter must be a positive number.")
254
+ if args.get("n_angles") is None or args["n_angles"] <= 0:
255
+ raise ValueError("Number of angles must be a positive integer.")
256
+ if args.get("profile_half_length") is None or args["profile_half_length"] <= 0:
257
+ raise ValueError("Half profile length must be a positive integer.")
258
+ if args.get("derivative_step") is None or args["derivative_step"] <= 0:
259
+ raise ValueError("Derivative step size must be a positive integer.")
260
+
261
+ if args.get("manual_shift") is not None and not isinstance(
262
+ args["manual_shift"], int
263
+ ):
264
+ raise ValueError("Manual shift must be an integer.")
265
+
266
+ # Validation for oversampling args
267
+ if args.get("oversample"):
268
+ if args.get("dtheta") is None or args["dtheta"] <= 0:
269
+ raise ValueError("dtheta must be a positive number for oversampling.")
270
+ if args.get("oversampling_factor") is None or args["oversampling_factor"] <= 0:
271
+ raise ValueError(
272
+ "oversampling_factor must be a positive number for oversampling."
273
+ )
274
+ # gaussian_sigma can be None (treated as 0) or any non-negative number
275
+ if args.get("gaussian_sigma") is not None and args["gaussian_sigma"] < 0:
276
+ raise ValueError("gaussian_sigma must be non-negative for oversampling.")
@@ -0,0 +1,178 @@
1
+ # SCOPE-XR (Single-image Characterization Of PErformance in X-Ray systems)
2
+ # Copyright (C) 2026 Jacopo Altieri
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import cv2
18
+ import numpy as np
19
+ from scipy.ndimage import center_of_mass
20
+
21
+
22
+ def detect_circle_hough(
23
+ img: np.ndarray,
24
+ dp: float,
25
+ min_dist: float,
26
+ param1: int,
27
+ param2: int,
28
+ min_radius: int,
29
+ max_radius: int,
30
+ output_path: str = None,
31
+ debug: bool = False,
32
+ ) -> tuple[float, float, float] | None:
33
+ """
34
+ Detect a single circle in a grayscale image using the Hough Circle Transform.
35
+
36
+ Parameters
37
+ ----------
38
+ img
39
+ 2D array representing the input grayscale image.
40
+ dp
41
+ Inverse ratio of the accumulator resolution to the image resolution.
42
+ For example, dp=1 means the accumulator has the same resolution as the image.
43
+ min_dist
44
+ Minimum distance between the centers of detected circles (in pixels).
45
+ param1
46
+ Higher threshold for the internal Canny edge detector (lower is half).
47
+ param2
48
+ Accumulator threshold for the circle centers at the detection stage.
49
+ Smaller values will detect more circles (including false ones).
50
+ min_radius
51
+ Minimum circle radius (in pixels) to search for.
52
+ max_radius
53
+ Maximum circle radius (in pixels) to search for. If <= 0, no upper limit is applied.
54
+ debug
55
+ If True, display the detected circle overlaid on the image in a pop-up window.
56
+ Defaults to False.
57
+
58
+ Returns
59
+ -------
60
+ x: float
61
+ x coordinate of the detected circle center.
62
+ y: float
63
+ y coordinate of the detected circle center.
64
+ r: float
65
+ radius of the detected circle.
66
+ None
67
+ If no circle is found.
68
+
69
+ Raises
70
+ ------
71
+ FileNotFoundError
72
+ If the input `img` is None.
73
+ """
74
+ if img is None:
75
+ raise FileNotFoundError(f"Could not open '{img}'")
76
+ p1 = np.percentile(img, 1)
77
+ p99 = np.percentile(img, 99)
78
+ img_clipped = np.clip(img, p1, p99)
79
+
80
+ # Normalize to 0-255
81
+ img_8bit = ((img_clipped - p1) / (p99 - p1) * 255).astype(np.uint8)
82
+ blurred = cv2.medianBlur(img_8bit, 5)
83
+
84
+ # Perform Hough Circle Transform
85
+ circles = cv2.HoughCircles(
86
+ blurred,
87
+ cv2.HOUGH_GRADIENT,
88
+ dp=dp,
89
+ minDist=min_dist,
90
+ param1=param1,
91
+ param2=param2,
92
+ minRadius=min_radius,
93
+ maxRadius=max_radius if max_radius > 0 else None,
94
+ )
95
+
96
+ if circles is None:
97
+ print("No circles found")
98
+ return None
99
+
100
+ # Round and pick the strongest circle (first one)
101
+ circles = np.uint16(np.around(circles))
102
+ x, y, r = circles[0][0]
103
+
104
+ output = cv2.cvtColor(img_8bit, cv2.COLOR_GRAY2BGR)
105
+ cv2.circle(output, (x, y), r, (0, 255, 0), 2)
106
+ cv2.circle(output, (x, y), 2, (0, 0, 255), 3)
107
+ cv2.imwrite(f"{output_path}/detected_circle.png", output)
108
+
109
+ if debug:
110
+ display_scale = 0.5
111
+ output_resized = cv2.resize(output, (0, 0), fx=display_scale, fy=display_scale)
112
+ cv2.imshow("Detected Circle", output_resized)
113
+ cv2.waitKey(0)
114
+ cv2.destroyAllWindows()
115
+
116
+ return x, y, r
117
+
118
+
119
+ def estimate_circle(cropped: np.ndarray) -> tuple[float, float, float]:
120
+ """
121
+ Estimate circle parameters using Center of Mass and Equivalent Area.
122
+
123
+ Methods:
124
+ - Center (cx, cy): Calculated via center_of_mass on the binary mask.
125
+ - Radius: Calculated from the area (Area = pi * r^2).
126
+ """
127
+ h, w = cropped.shape
128
+
129
+ # 1. Thresholding to create a binary mask
130
+ # We use a float cast to avoid overflow during min/max calc
131
+ img_float = cropped.astype(np.float32)
132
+ threshold = (np.min(img_float) + np.max(img_float)) / 2.0
133
+
134
+ mask = img_float >= threshold
135
+
136
+ # Handle empty image case
137
+ if not np.any(mask):
138
+ return w / 2.0, h / 2.0, 0.0
139
+
140
+ # 2. Calculate Center (y, x)
141
+ # This uses all pixels in the mask to find the geometric centroid
142
+ cy, cx = center_of_mass(mask)
143
+
144
+ # 3. Calculate Radius from Area (i.e., number of pixels in the mask)
145
+ # Area = pi * r^2 -> r = sqrt(Area / pi)
146
+ area = np.sum(mask)
147
+ radius_estimate = np.sqrt(area / np.pi)
148
+
149
+ return float(cx), float(cy), float(radius_estimate)
150
+
151
+
152
+ def is_circle_centered(
153
+ cropped: np.ndarray, cx: float, cy: float, margin: float = 0.1
154
+ ) -> bool:
155
+ """
156
+ Check if the estimated circle center is within `margin` of the cropped image center
157
+ in both the x- and y-directions. Returns True if it is, False otherwise.
158
+
159
+ Parameters
160
+ ----------
161
+ cropped
162
+ cropped image containing the circle
163
+ cx
164
+ x coordinate of the estimated circle center
165
+ cy
166
+ y coordinate of the estimated circle center
167
+ margin
168
+ allowable fraction of width/height (default 0.1)
169
+
170
+ Returns
171
+ -------
172
+ bool
173
+ True if the circle center is within the margin from the image center
174
+ """
175
+ h, w = cropped.shape[:2]
176
+ center_x, center_y = w / 2, h / 2
177
+
178
+ return (abs(cx - center_x) < margin * w) and (abs(cy - center_y) < margin * h)