Source code for scopexr.arg_parser_psf

# 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".\psf_args.yaml", help="Path to YAML config file", ) # CLI arguments 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("--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( "--dtheta", type=float, help="Angle of circular sector for oversampling in degrees", ) parser.add_argument( "--resample2", type=float, help="Resample factor for oversampling (final grid spacing).", ) parser.add_argument( "--gaussian_sigma", type=float, 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.", ) 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.", ) oversample_group = parser.add_mutually_exclusive_group() oversample_group.add_argument( "--oversample", dest="oversample", action="store_true", default=None, help="Enable oversampling", ) oversample_group.add_argument( "--no_oversample", dest="oversample", action="store_false", default=None, help="Disable oversampling", ) args, unknown = parser.parse_known_args() # 1. Set code defaults (lowest priority) config = { "img_path": None, "out_dir": "./output_psf", "pixel_size": 0.1, "circle_diameter": 100, "no_hough": False, "n_angles": 360, "profile_half_length": 50, "derivative_step": 1, "axis_shifts": 10, "manual_shift": None, "filter_name": "ramp", "symmetrize": False, "auto_shift": True, "no_shift": False, "oversample": False, "dtheta": 2, "resample2": 4, "gaussian_sigma": 0.0, "show_plots": False, "hough_params": { "dp": 1, "min_dist": 100, "param1": 100, "param2": 40, "min_radius": 100, "max_radius": 0, "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", "nangles": "n_angles", "hl": "profile_half_length", "ds": "derivative_step", "axis_shifts": "axis_shifts", "filter": "filter_name", "sym": "symmetrize", "show": "show_plots", "oversample": "oversample", "dtheta": "dtheta", "resample2": "resample2", "gaussian_sigma": "gaussian_sigma", "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 flags were *actually passed* cli_manual_shift = cli_dict.get("manual_shift") cli_auto_shift = cli_dict.get("auto_shift") cli_no_shift = cli_dict.get("no_shift") if cli_manual_shift is not None: # CLI --manual_shift was used config["auto_shift"] = False config["no_shift"] = False config["manual_shift"] = cli_manual_shift elif cli_auto_shift is True: # CLI --auto_shift was used config["auto_shift"] = True config["no_shift"] = False config["manual_shift"] = None elif cli_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("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("manual_shift") is not None and not isinstance( args["manual_shift"], int ): raise ValueError("Manual shift must be an integer.") # Validation for oversampling args if args.get("oversample"): if args.get("dtheta") is None or args["dtheta"] <= 0: raise ValueError("dtheta must be a positive number for oversampling.") if args.get("resample2") is None or args["resample2"] <= 0: raise ValueError("resample2 must be a positive number for oversampling.") # gaussian_sigma can be None (treated as 0) or any non-negative number if args.get("gaussian_sigma") is not None and args["gaussian_sigma"] < 0: raise ValueError("gaussian_sigma must be non-negative for oversampling.")