Source code for scopexr.arg_parser_fs

# 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.")