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 +1 -0
- scopexr/arg_parser_fs.py +221 -0
- scopexr/arg_parser_psf.py +276 -0
- scopexr/circle_detection.py +178 -0
- scopexr/fs_main.py +25 -0
- scopexr/fs_runner.py +357 -0
- scopexr/gui.py +1401 -0
- scopexr/image_opening.py +145 -0
- scopexr/mtf_calc.py +270 -0
- scopexr/plotters.py +541 -0
- scopexr/psf_main.py +25 -0
- scopexr/psf_runner.py +601 -0
- scopexr/scopexr_logo.png +0 -0
- scopexr/sinogram_extraction.py +571 -0
- scopexr/sinogram_reconstruction.py +104 -0
- scopexr/utils.py +271 -0
- scopexr/widths_calculator.py +395 -0
- scopexr-1.3.1.dist-info/METADATA +317 -0
- scopexr-1.3.1.dist-info/RECORD +23 -0
- scopexr-1.3.1.dist-info/WHEEL +5 -0
- scopexr-1.3.1.dist-info/entry_points.txt +4 -0
- scopexr-1.3.1.dist-info/licenses/LICENSE.txt +674 -0
- scopexr-1.3.1.dist-info/top_level.txt +1 -0
scopexr/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.1"
|
scopexr/arg_parser_fs.py
ADDED
|
@@ -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)
|