# 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 argparse
import sys
import yaml
[docs]
def get_merged_config() -> dict:
"""
Parse command-line arguments and YAML configuration file,
merging them with the following priority:
1. Code defaults (lowest priority)
2. YAML config file
3. Command-line arguments (highest priority)
Returns
-------
dict
Merged configuration dictionary
"""
parser = argparse.ArgumentParser()
parser.add_argument(
"--config", type=str, default=r".\fs_args.yaml", help="Path to YAML config file"
)
# CLI arguments (short flags)
parser.add_argument("--f", type=str, help="Path to the image file (.raw/.png/.tif)")
parser.add_argument("--o", type=str, help="Output directory")
parser.add_argument("--p", type=float, help="Pixel size in mm")
parser.add_argument("--d", type=float, help="Circle diameter in mm")
parser.add_argument(
"--no_hough",
action="store_true",
default=None,
help="Skip Hough transform detection",
)
parser.add_argument("--m", type=float, help="Magnification")
parser.add_argument("--n", type=int, help="Minimum pixel count")
parser.add_argument("--nangles", type=int, help="Number of angles")
parser.add_argument("--hl", type=int, help="Half profile length")
parser.add_argument("--ds", type=int, help="Derivative step size")
parser.add_argument("--axis_shifts", type=int, help="Number of axis shift steps")
parser.add_argument("--filter", type=str, help="Reconstruction filter name")
parser.add_argument(
"--sym", action="store_true", default=None, help="Symmetrize the sinogram"
)
parser.add_argument("--show", action="store_true", default=None, help="Show plots")
shift_group = parser.add_mutually_exclusive_group()
shift_group.add_argument(
"--auto_shift",
action="store_true",
default=None,
help="Enable automatic sinogram centering.",
)
shift_group.add_argument(
"--manual_shift",
type=int,
default=None,
help="Provide a specific manual shift value (in pixels).",
)
shift_group.add_argument(
"--no_shift",
action="store_true",
default=None,
help="Disable all sinogram shifting.",
)
args, unknown = parser.parse_known_args()
# 1. Set code defaults (lowest priority)
config = {
"img_path": None,
"out_dir": "./output_fs",
"pixel_size": None,
"circle_diameter": None,
"magnification": None, # Will be auto-calculated if None or 0
"min_n": 6,
"n_angles": 360,
"profile_half_length": 50,
"derivative_step": 1,
"axis_shifts": 10,
"filter_name": "ramp",
"auto_shift": True,
"manual_shift": None,
"no_shift": False,
"no_hough": False,
"symmetrize": False,
"show_plots": False,
"hough_params": {
"dp": 1,
"min_dist": 100,
"param1": 100,
"param2": 30,
"min_radius": 10,
"max_radius": 500,
"debug": False,
},
}
# 2. Load YAML config (overwrites code defaults)
try:
with open(args.config, "r") as f:
yaml_config = yaml.safe_load(f)
if yaml_config:
config.update(yaml_config)
except FileNotFoundError:
print(
f"Warning: Config file not found at {args.config}. Using defaults.",
file=sys.stderr,
)
except Exception as e:
print(f"Error loading YAML config: {e}", file=sys.stderr)
# 3. Load CLI arguments (highest priority)
cli_to_config_keys = {
"f": "img_path",
"o": "out_dir",
"p": "pixel_size",
"d": "circle_diameter",
"no_hough": "no_hough",
"m": "magnification",
"n": "min_n",
"nangles": "n_angles",
"hl": "profile_half_length",
"ds": "derivative_step",
"axis_shifts": "axis_shifts",
"filter": "filter_name",
"sym": "symmetrize",
"show": "show_plots",
"auto_shift": "auto_shift",
"manual_shift": "manual_shift",
"no_shift": "no_shift",
}
cli_dict = vars(args)
for cli_key, config_key in cli_to_config_keys.items():
cli_value = cli_dict.get(cli_key)
# Only update if the CLI argument was *actually* given
if cli_value is not None:
config[config_key] = cli_value
# ---
# Check which CLI flag was *actually passed* (from cli_dict)
# and enforce priority.
# ---
if cli_dict.get("manual_shift") is not None:
# CLI --manual_shift was used
config["auto_shift"] = False
config["no_shift"] = False
config["manual_shift"] = cli_dict.get("manual_shift")
elif cli_dict.get("auto_shift") is True:
# CLI --auto_shift was used
config["auto_shift"] = True
config["no_shift"] = False
config["manual_shift"] = None
elif cli_dict.get("no_shift") is True:
# CLI --no_shift was used
config["auto_shift"] = False
config["no_shift"] = True
config["manual_shift"] = None
# If no CLI shift flag was passed, the config (from YAML or default) is used as-is.
return config
[docs]
def validate_args(args: dict) -> None:
"""
Validate the merged configuration arguments.
Parameters
----------
args
Merged configuration dictionary
Raises
------
ValueError
If any argument is invalid
"""
if not args.get("img_path"):
raise ValueError("Image path is required. Use --f to specify the image file.")
if args.get("pixel_size") is None or args["pixel_size"] <= 0:
raise ValueError("Pixel size must be a positive number.")
if args.get("circle_diameter") is None or args["circle_diameter"] <= 0:
raise ValueError("Circle diameter must be a positive number.")
if args.get("magnification") is not None and args["magnification"] < 0:
raise ValueError(
"Magnification must be a positive number or 0 for automatic calculation."
)
if args.get("min_n") is None or args["min_n"] <= 0:
raise ValueError("Minimum pixel count must be a positive integer.")
if args.get("n_angles") is None or args["n_angles"] <= 0:
raise ValueError("Number of angles must be a positive integer.")
if args.get("profile_half_length") is None or args["profile_half_length"] <= 0:
raise ValueError("Half profile length must be a positive integer.")
if args.get("derivative_step") is None or args["derivative_step"] <= 0:
raise ValueError("Derivative step size must be a positive integer.")
if args.get("axis_shifts") is None or args["axis_shifts"] < 0:
raise ValueError("Axis shifts must be a non-negative integer.")
if args.get("manual_shift") is not None and not isinstance(
args["manual_shift"], int
):
raise ValueError("Manual shift must be an integer.")