"""Parse config JSON and CLI arguments to set global settings in ``gui_settings.py`` and ``cli_settings.py``."""
# TODO: (Eric) I think most of this should be refactored into the CLI/GUI packages respectively,
# and we should just keep the general parse functions here
# Agree - Jesse
import argparse
import json
from pathlib import Path
import string
from typing import Union
import NeuroRuler.utils.cli_settings as cli_settings
import NeuroRuler.utils.gui_settings as gui_settings
import NeuroRuler.utils.constants as constants
import NeuroRuler.utils.exceptions as exceptions
JSON_SETTINGS: dict = dict()
"""Dict of settings resulting from JSON file parsing. Global within this file."""
[docs]
def parse_cli() -> None:
"""Parse CLI (non-GUI) args and set settings in ``cli_settings.py``.
:return: None"""
parser = argparse.ArgumentParser(
description="A program that calculates head circumference from MRI data (``.nii``, ``.nii.gz``, ``.nrrd``).",
)
parser.add_argument("-d", "--debug", help="print debug info", action="store_true")
parser.add_argument(
"-r", "--raw", help='print just the "raw" circumference', action="store_true"
)
parser.add_argument("-x", "--x", type=int, help="x rotation (in degrees)")
parser.add_argument("-y", "--y", type=int, help="y rotation (in degrees)")
parser.add_argument("-z", "--z", type=int, help="z rotation (in degrees)")
parser.add_argument("-s", "--slice", type=int, help="slice (Z slice, 0-indexed)")
parser.add_argument(
"-c", "--conductance", type=float, help="conductance smoothing parameter"
)
parser.add_argument("-i", "--iterations", type=int, help="smoothing iterations")
parser.add_argument(
"-t", "--step", type=float, help="time step (smoothing parameter)"
)
parser.add_argument(
"-f", "--filter", help="which filter to use (Otsu or binary), default is Otsu"
)
parser.add_argument(
"-l", "--lower", type=float, help="lower threshold for binary threshold"
)
parser.add_argument(
"-u", "--upper", type=float, help="upper threshold for binary threshold"
)
parser.add_argument(
"file",
help=f"file to compute circumference from, file format must be {iterable_of_str_to_str(constants.SUPPORTED_IMAGE_EXTENSIONS)}",
)
args = parser.parse_args()
# store_true option is True or False, never None
# Don't do `if args.debug is not None`
if args.debug:
cli_settings.DEBUG = True
print("Debug CLI option supplied.")
cli_settings.RAW = args.raw
# bool(0) is False
# If we use `if args.x`, then x=0 would cause the if condition to be False (not what we want)
if args.x is not None:
cli_settings.THETA_X = args.x
if args.y is not None:
cli_settings.THETA_Y = args.y
if args.z is not None:
cli_settings.THETA_Z = args.z
if args.slice is not None:
cli_settings.SLICE = args.slice
if args.conductance is not None:
cli_settings.CONDUCTANCE_PARAMETER = args.conductance
if args.iterations is not None:
cli_settings.SMOOTHING_ITERATIONS = args.iterations
if args.step is not None:
cli_settings.TIME_STEP = args.step
if args.filter is not None:
if args.filter.lower() == "otsu":
if args.lower is not None or args.upper is not None:
print(
"Otsu threshold filter automatically calculates threshold values. The values you specified would not be used. Exiting."
)
exit(1)
cli_settings.THRESHOLD_FILTER = constants.ThresholdFilter.Otsu
elif args.filter.lower() == "binary":
if args.lower is None or args.upper is None:
print(
"Must specify lower and upper binary thresholds with CLI option if using binary threshold"
)
exit(1)
cli_settings.THRESHOLD_FILTER = constants.ThresholdFilter.Binary
cli_settings.LOWER_BINARY_THRESHOLD = args.lower
cli_settings.UPPER_BINARY_THRESHOLD = args.upper
else:
print("Invalid setting entered for CLI filter option.")
exit(1)
if not any(
[
pattern.match(args.file)
for pattern in constants.SUPPORTED_IMAGE_EXTENSIONS_REGEX
]
):
print(
f"Invalid file extension. Supported file formats are {iterable_of_str_to_str(constants.SUPPORTED_IMAGE_EXTENSIONS)}"
)
exit(1)
cli_settings.FILE = args.file
[docs]
def parse_gui_cli() -> None:
"""Parse GUI CLI args and set settings in ``gui_settings.py``.
:return: None"""
parser = argparse.ArgumentParser()
parser.add_argument("-d", "--debug", help="print debug info", action="store_true")
parser.add_argument(
"-t",
"--theme",
help="configure theme, options are " + iterable_of_str_to_str(constants.THEMES),
)
parser.add_argument(
"-c",
"--color",
help="contour color as name (e.g. red) or hex color code rrggbb",
)
args = parser.parse_args()
if args.debug:
gui_settings.DEBUG = True
print("Debug CLI option supplied.")
if args.theme is not None:
if args.theme not in constants.THEMES:
print(
f"Invalid theme specified. Options are {iterable_of_str_to_str(constants.THEMES)}"
)
exit(1)
gui_settings.THEME_NAME = args.theme
gui_settings.CONTOUR_COLOR = parse_main_color_from_theme_json()
print(f"Theme {args.theme} specified.")
if args.color is not None:
gui_settings.CONTOUR_COLOR = args.color
print(
f"Contour color is {'#' if not args.color.isalpha() else ''}{args.color}."
)
[docs]
def parse_cli_config() -> None:
"""Parse CLI JSON config and set user settings in gui_settings.py.
load_json will load constants.JSON_CLI_CONFIG_PATH."""
global JSON_SETTINGS
JSON_SETTINGS = load_json(constants.JSON_CLI_CONFIG_PATH)
cli_settings.DEBUG = parse_bool("DEBUG")
if cli_settings.DEBUG:
print("Printing debug messages.")
cli_settings.RAW = parse_bool("RAW")
cli_settings.THETA_X = parse_int("X")
cli_settings.THETA_Y = parse_int("Y")
cli_settings.THETA_Z = parse_int("Z")
cli_settings.SLICE = parse_int("SLICE")
cli_settings.CONDUCTANCE_PARAMETER = parse_float("CONDUCTANCE")
cli_settings.SMOOTHING_ITERATIONS = parse_int("SMOOTHING")
cli_settings.TIME_STEP = parse_float("TIME_STEP")
if parse_str("THRESHOLD_FILTER").lower() == "otsu":
cli_settings.THRESHOLD_FILTER = constants.ThresholdFilter.Otsu
# Don't print anything if lower and upper thresholds are in the JSON.
# Not a serious error and would mess up parsing of results.
# Lastly, JSON and code documentation specify that Otsu doesn't use these fields.
# Users and developers should know.
# if "LOWER_BINARY_THRESHOLD" in JSON_SETTINGS or "UPPER_BINARY_THRESHOLD" in JSON_SETTINGS:
# print(
# "NOTE: Otsu threshold filter automatically computes lower and upper threshold values. The values you entered in the JSON will be ignored.", file=sys.stderr)
elif parse_str("THRESHOLD_FILTER").lower() == "binary":
cli_settings.THRESHOLD_FILTER = constants.ThresholdFilter.Binary
if (
"LOWER_BINARY_THRESHOLD" not in JSON_SETTINGS
or "UPPER_BINARY_THRESHOLD" not in JSON_SETTINGS
):
print(
"Must specify LOWER_BINARY_THRESHOLD and UPPER_BINARY_THRESHOLD values if using Binary threshold filter. Exiting."
)
exit(1)
cli_settings.LOWER_BINARY_THRESHOLD = parse_float("LOWER_BINARY_THRESHOLD")
cli_settings.UPPER_BINARY_THRESHOLD = parse_float("UPPER_BINARY_THRESHOLD")
else:
raise exceptions.InvalidJSONField("THRESHOLD_FILTER", "Otsu or Binary")
[docs]
def parse_gui_config() -> None:
"""Parse GUI JSON config and set user settings in gui_settings.py.
load_json will load constants.JSON_GUI_CONFIG_PATH."""
global JSON_SETTINGS
JSON_SETTINGS = load_json(constants.JSON_GUI_CONFIG_PATH)
gui_settings.DEBUG = parse_bool("DEBUG")
if gui_settings.DEBUG:
print("Printing debug messages.")
gui_settings.FILE_BROWSER_START_DIR = parse_path("FILE_BROWSER_START_DIR")
gui_settings.THEME_NAME = JSON_SETTINGS["THEME_NAME"]
if gui_settings.THEME_NAME not in constants.THEMES:
raise exceptions.InvalidJSONField(
"THEME_NAME", iterable_of_str_to_str(constants.THEMES)
)
contour_color: str = JSON_SETTINGS["CONTOUR_COLOR"]
if contour_color == "":
gui_settings.CONTOUR_COLOR = parse_main_color_from_theme_json()
# A name, e.g. red, green, blue. etc., which can be converted to a QColor
elif contour_color.isalpha():
gui_settings.CONTOUR_COLOR = contour_color
elif len(contour_color) == 6 and all(
char in string.hexdigits for char in contour_color
):
gui_settings.CONTOUR_COLOR = contour_color
else:
raise exceptions.InvalidJSONField(
"CONTOUR_COLOR",
'Name (e.g., "red", "blue") or 6-hexit color code "RRGGBB" or "" (empty string) '
"to set a default color based on theme",
)
gui_settings.STARTUP_WIDTH_RATIO = parse_float("STARTUP_WIDTH_RATIO")
gui_settings.STARTUP_HEIGHT_RATIO = parse_float("STARTUP_HEIGHT_RATIO")
gui_settings.DISPLAY_ADVANCED_MENU_MESSAGES_IN_TERMINAL = parse_bool(
"DISPLAY_ADVANCED_MENU_MESSAGES_IN_TERMINAL"
)
gui_settings.GROUP_MAX_SPACING_DIFF = parse_float("GROUP_MAX_SPACING_DIFF")
[docs]
def parse_main_color_from_theme_json() -> str:
"""Parse the main color from the theme JSON file (user_settings.THEME_NAME) in the highlight field.
Uses user_settings.THEME_NAME so must be called after parse_gui_config sets user_settings.THEME_NAME
(i.e. can be called within parse_gui_config).
:return: main color rrggbb (hexits)
:rtype: str"""
path_to_theme_json: Path = (
constants.THEME_DIR
/ gui_settings.THEME_NAME
/ (gui_settings.THEME_NAME + ".json")
)
theme_json: dict = load_json(path_to_theme_json)
color: str = theme_json["highlight"]
if len(color) == 7:
return color[1:]
else:
if "rgba(" not in color:
raise Exception(
f"{path_to_theme_json} has an invalid highlight field. Must be #rrggbb or rgba(r, g, b, a) (decimal)"
)
color = color.replace("rgba(", "").replace(")", "")
channels: list[str] = color.split(",")
r, g, b = int(channels[0]), int(channels[1]), int(channels[2])
r, g, b = hex(r)[2:].zfill(2), hex(g)[2:].zfill(2), hex(b)[2:].zfill(2)
return r + g + b
# Source: https://github.com/Alexhuszagh/BreezeStyleSheets/blob/main/configure.py#L82
[docs]
def load_json(path: Path) -> dict:
"""Load config.json file, ignoring comments //.
Source: https://github.com/Alexhuszagh/BreezeStyleSheets/blob/main/configure.py#L82
:param path: path to JSON configuration file
:type path: Path
:return: JSON represented as dict
:rtype: dict
"""
with open(path) as f:
lines = f.read().splitlines()
lines = [i for i in lines if not i.strip().startswith("//")]
return json.loads("\n".join(lines))
[docs]
def parse_str(field: str) -> str:
"""For str field, return the str.
:param field: JSON field
:type field: ````str````
:return: string
:rtype: ````str````"""
try:
return JSON_SETTINGS[field]
except:
raise exceptions.InvalidJSONField(field, "str")
[docs]
def parse_bool(field: str) -> bool:
"""For bool field "True" or "False", return the bool.
:param field: JSON field
:type field: str
:raise: exceptions.InvalidJSONField if s is not "True" or "False"
:return: True or False
:rtype: bool"""
if JSON_SETTINGS[field] != "True" and JSON_SETTINGS[field] != "False":
raise exceptions.InvalidJSONField(
field, 'bool ("True" or "False", with quotation marks)'
)
return True if JSON_SETTINGS[field] == "True" else False
[docs]
def parse_path(field: str) -> Path:
"""For path field, return a Path.
:param field: JSON field
:type field: str
:raise: exceptions.InvalidJSONField
:return:
:rtype: Path"""
try:
return Path(JSON_SETTINGS[field])
except:
raise exceptions.InvalidJSONField(field, "path")
[docs]
def parse_int(field: str) -> int:
"""For int field, return int.
:param field: JSON field
:type field: str
:raise: exceptions.InvalidJSONField
:return:
:rtype: int"""
try:
return int(JSON_SETTINGS[field])
except:
raise exceptions.InvalidJSONField(field, "int")
[docs]
def parse_float(field: str) -> float:
"""For float field, return float.
:param field: JSON field
:type field: str
:raise: exceptions.InvalidJSONField
:return:
:rtype: float"""
try:
return float(JSON_SETTINGS[field])
except:
raise exceptions.InvalidJSONField(field, "float")
[docs]
def iterable_of_str_to_str(iterable: Union[list[str], tuple[str]]) -> str:
"""``', '.join(iterable)``
For example, ('.nii.gz', '.nii', '.nrrd') becomes '.nii.gz, .nii, .nrrd'.
:param iterable:
:type iterable: ``Union[list[str], tuple[str]]``
:return: ``str`` representation of iterable of ``str`` with commas and spaces
:rtype: ``str``"""
return ", ".join(iterable)