From 70cf48eaa37f04924152f608038ece7f3778e3df Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 3 Jun 2026 13:23:09 +0000 Subject: [PATCH 1/3] Redesign config validation --- docs/source/parameters_doc.py | 51 +-- tests/test_config_validation.py | 110 +++++ tests/test_configs.py | 7 +- zea/config.py | 4 +- zea/internal/config/create.py | 141 +++---- zea/internal/config/validation.py | 664 +++++++++++++++++++++++------- 6 files changed, 691 insertions(+), 286 deletions(-) create mode 100644 tests/test_config_validation.py diff --git a/docs/source/parameters_doc.py b/docs/source/parameters_doc.py index 887d1d410..7c19bb2e7 100644 --- a/docs/source/parameters_doc.py +++ b/docs/source/parameters_doc.py @@ -8,11 +8,13 @@ import sys from pathlib import Path -from schema import And, Optional, Or, Schema - from zea import log from zea.internal.config.parameters import PARAMETER_DESCRIPTIONS +# Keys documented in PARAMETER_DESCRIPTIONS that are deprecated aliases and thus +# intentionally absent from the config spec (see _migrate_legacy_config). +DEPRECATED_KEYS = {"scan"} + AUTOGEN_HEADER_TEMPLATE = "# {filename} - comments were autogenerated from PARAMETER_DESCRIPTIONS in zea/config/parameters.py\n" AUTOGEN_HEADER_KEYWORD = "autogenerated" @@ -59,37 +61,6 @@ def flatten_dict_keys(d, prefix=""): return keys -def flatten_schema_keys(schema, prefix=""): - """Recursively flatten schema keys into dot notation, handling Optional/And/Or.""" - keys = set() - if isinstance(schema, Schema): - schema = schema.schema - if isinstance(schema, dict): - for k, v in schema.items(): - if isinstance(k, Optional): - key = getattr(k, "schema", getattr(k, "key", k)) - else: - key = k - full = f"{prefix}.{key}" if prefix else str(key) - if isinstance(v, And): - if hasattr(v, "_validators") and v._validators: - last = v._validators[-1] - keys |= flatten_schema_keys(last, full) - else: - keys.add(full) - elif isinstance(v, Or): - if hasattr(v, "_options"): - for opt in v._options: - keys |= flatten_schema_keys(opt, full) - else: - keys.add(full) - elif isinstance(v, (Schema, dict)): - keys |= flatten_schema_keys(v, full) - else: - keys.add(full) - return keys - - def wrap_string_as_comment(input_string, indent_level=0, max_line_length=100, indent_size=2): """Limit the length of lines in a string and adds a comment prefix.""" if isinstance(input_string, dict): @@ -179,14 +150,16 @@ def add_comments_to_yaml(file_path, descriptions): file.writelines(modified_content) -def check_parameter_descriptions(descriptions, schema): +def check_parameter_descriptions(descriptions, spec): """Check for missing or extra parameter descriptions (ignoring 'description' keys). - Returns (missing, extra) as sets. + + ``spec`` is the top-level :class:`~zea.internal.config.validation.ConfigSpec` + subclass. Returns (missing, extra) as sorted lists. """ rst_keys = flatten_dict_keys(descriptions) - schema_keys = flatten_schema_keys(schema) + schema_keys = spec.all_field_paths() missing = sorted(schema_keys - rst_keys) - extra = sorted(rst_keys - schema_keys) + extra = sorted(rst_keys - schema_keys - DEPRECATED_KEYS) return missing, extra @@ -308,10 +281,10 @@ def update_configs(descriptions, configs_dir=CONFIGS_DIRNAME): if __name__ == "__main__": - from zea.internal.config.validation import config_schema + from zea.internal.config.validation import ConfigSchema # 1. Check parameter descriptions - missing, extra = check_parameter_descriptions(PARAMETER_DESCRIPTIONS, config_schema) + missing, extra = check_parameter_descriptions(PARAMETER_DESCRIPTIONS, ConfigSchema) if missing: log.warning( "The following config parameters are missing descriptions in PARAMETER_DESCRIPTIONS:" diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py new file mode 100644 index 000000000..36baab85d --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,110 @@ +"""Tests for the dataclass-based config validation (zea.internal.config.validation).""" + +import pytest + +from zea.config import Config, _migrate_legacy_config, check_config +from zea.internal.config.validation import ( + ConfigSchema, + ParametersConfig, + validate_config, +) + +MINIMAL = {"data": {"dtype": "image", "dataset_folder": "some/folder"}} + + +def test_defaults_are_filled(): + """Validation fills in defaults for all optional sections.""" + result = validate_config(MINIMAL) + + assert result["device"] == "auto:1" + assert result["git"] is None + # Nested sections get their own defaults. + assert result["plot"]["plot_lib"] == "opencv" + assert result["pipeline"]["operations"] == ["identity"] + assert result["parameters"]["lens_thickness"] == 1e-3 + # data defaults + assert result["data"]["to_dtype"] == "image" + assert result["data"]["dynamic_range"] == [-60, 0] + + +def test_validation_is_idempotent(): + """Validating an already-validated config yields the same dict.""" + once = validate_config(MINIMAL) + twice = validate_config(once) + assert once == twice + + +def test_missing_required_section_raises(): + with pytest.raises(ValueError, match="missing required keys"): + validate_config({}) + + +def test_missing_required_data_field_raises(): + with pytest.raises(ValueError, match="missing required keys"): + validate_config({"data": {"dtype": "image"}}) + + +@pytest.mark.parametrize( + "config", + [ + {**MINIMAL, "plot": {"plot_lib": "not_a_lib"}}, # enum + {**MINIMAL, "device": "tpu:0"}, # regex/enum + {**MINIMAL, "parameters": {"lens_thickness": -1.0}}, # positive float + {**MINIMAL, "parameters": {"grid_size_x": 0}}, # positive integer + {"data": {"dtype": "not_a_dtype", "dataset_folder": "x"}}, # enum + {**MINIMAL, "data": {**MINIMAL["data"], "dynamic_range": [1, 2, 3]}}, # list len + ], +) +def test_invalid_values_raise(config): + with pytest.raises(ValueError): + validate_config(config) + + +@pytest.mark.parametrize("device", ["cpu", "gpu", "cuda", "cuda:0", "gpu:1", "auto:1", "auto:-1"]) +def test_valid_devices(device): + result = validate_config({**MINIMAL, "device": device}) + assert result["device"] == device + + +def test_arbitrary_parameters_keys_pass_through(): + """The parameters section accepts and round-trips arbitrary custom keys.""" + config = {**MINIMAL, "parameters": {"grid_size_x": 128, "my_custom_param": 42}} + result = validate_config(config) + assert result["parameters"]["grid_size_x"] == 128 + assert result["parameters"]["my_custom_param"] == 42 + + +def test_arbitrary_top_level_keys_preserved(): + """Unknown top-level sections (e.g. model:) are preserved unchanged.""" + config = {**MINIMAL, "model": {"name": "diffusion", "steps": 100}} + result = validate_config(config) + assert result["model"] == {"name": "diffusion", "steps": 100} + + +def test_parameters_config_is_open(): + assert ParametersConfig.ALLOW_EXTRA is True + assert ConfigSchema.ALLOW_EXTRA is True + + +def test_all_field_paths_includes_nested(): + paths = ConfigSchema.all_field_paths() + assert "data.dtype" in paths + assert "plot.plot_lib" in paths + assert "parameters.grid_size_x" in paths + assert "pipeline.operations" in paths + assert "device" in paths + + +def test_scan_alias_migrated_to_parameters(): + """The deprecated scan: section is aliased to parameters: on load.""" + migrated = _migrate_legacy_config({**MINIMAL, "scan": {"grid_size_x": 64}}) + assert "scan" not in migrated + assert migrated["parameters"] == {"grid_size_x": 64} + + +def test_check_config_freezes_config_object(): + config = Config(MINIMAL) + checked = check_config(config) + assert isinstance(checked, Config) + assert checked.__frozen__ is True + assert checked.plot.plot_lib == "opencv" diff --git a/tests/test_configs.py b/tests/test_configs.py index 676e4f396..58abf2037 100644 --- a/tests/test_configs.py +++ b/tests/test_configs.py @@ -5,7 +5,6 @@ import pytest import yaml -from schema import SchemaError from zea.config import Config, check_config from zea.internal.setup_zea import setup_config @@ -70,11 +69,11 @@ def test_all_configs_valid(file): configuration = check_config(configuration) # check another time, since defaults are now set, which are not # checked by the first check_config. Basically this checks if the - # config_validation.py entries are correct. + # validation.py entries are correct. check_config(configuration) - except SchemaError as se: - raise ValueError(f"Error in config {file}") from se + except ValueError as ve: + raise ValueError(f"Error in config {file}") from ve def test_dot_indexing(): diff --git a/zea/config.py b/zea/config.py index 1cba326b5..fda7dca49 100644 --- a/zea/config.py +++ b/zea/config.py @@ -53,7 +53,7 @@ import yaml from zea import log -from zea.internal.config.validation import config_schema +from zea.internal.config.validation import validate_config from zea.internal.core import dict_to_tensor from zea.internal.preset_utils import HF_PREFIX, _hf_resolve_path from zea.internal.utils import deprecated @@ -508,7 +508,7 @@ def check_config(config: Union[dict, Config], verbose: bool = False): def _try_validate_config(config): try: - config = config_schema.validate(config) + config = validate_config(config) return config except Exception as e: log.error(f"Config is not valid: {e}") diff --git a/zea/internal/config/create.py b/zea/internal/config/create.py index 36ad84201..eb4354f71 100644 --- a/zea/internal/config/create.py +++ b/zea/internal/config/create.py @@ -3,69 +3,73 @@ import sys from pathlib import Path -import schema +import yaml -from zea.config import Config +from zea.config import Config, check_config from zea.internal.config.parameters import PARAMETER_DESCRIPTIONS -from zea.internal.config.validation import check_config, config_schema +from zea.internal.config.validation import ConfigSchema from zea.log import green, red from zea.utils import get_date_string, strtobool -def _get_input_value(config, schema_key, schema_value, descriptions): +def _get_input_value(config, key, validator, descriptions): + """Prompt for a value for ``key``, parse it as YAML, and validate it.""" while True: - input_val = input(f"Enter a value for {schema_key}: ") - if not isinstance(schema_key, str): - _key = schema_key.key - else: - _key = schema_key + input_val = input(f"Enter a value for {key}: ") if input_val == "help": - if _key not in descriptions: - print(red(f"No description available for {_key}")) - continue - print("\t" + green(descriptions[_key])) + desc = descriptions.get(key) if isinstance(descriptions, dict) else None + if not desc: + print(red(f"No description available for {key}")) + else: + print("\t" + green(desc)) continue try: - config[_key] = input_val - if isinstance(schema_value, schema.And): - for _type in schema_value.args: - try: - config[_key] = _type(config[_key]) - break - except Exception: - pass - schema_value.validate(config[_key]) - else: - schema_value(config[_key]) + # YAML parsing mirrors how config files are actually loaded, so e.g. + # "5" -> int, "true" -> bool, "[1, 2]" -> list, "all" -> str. + parsed = yaml.safe_load(input_val) + if validator is not None: + validator(parsed) + config[key] = parsed break - except Exception as e: + except Exception as e: # noqa: BLE001 - report any parse/validation error and retry print(f"Invalid input: {red(e)}") return config +def _resolve_spec_field(keys): + """Resolve a (slash separated) key path to its validator and descriptions. + + Unknown sections/keys resolve to ``None`` validator (extra keys are allowed). + """ + spec_cls = ConfigSchema + descriptions = PARAMETER_DESCRIPTIONS + for k in keys[:-1]: + spec_cls = spec_cls.NESTED.get(k) if spec_cls is not None else None + if isinstance(descriptions, dict): + descriptions = descriptions.get(k, {}) + validator = spec_cls.VALIDATORS.get(keys[-1]) if spec_cls is not None else None + return validator, descriptions + + def create_config(): """Create a new config file by asking the user for input.""" - def _ask_user_input(config, schema_obj, descriptions): - for key, value in schema_obj.schema.items(): - if isinstance(value, schema.Schema): - if not isinstance(key, str): - _key = key.key - else: - _key = key - if isinstance(key, schema.Optional): - # skip optional keys - continue - config[_key] = _ask_user_input( - config.setdefault(_key, {}), value, descriptions[_key] - ) - elif not isinstance(key, schema.Optional): - config = _get_input_value(config, key, value, descriptions) - + def _ask_user_input(config, spec_cls, descriptions): + for name in spec_cls.required_fields(): + nested = spec_cls.NESTED.get(name) + if nested is not None: + sub_desc = descriptions.get(name, {}) if isinstance(descriptions, dict) else {} + config[name] = _ask_user_input(config.setdefault(name, {}), nested, sub_desc) + else: + validator = spec_cls.VALIDATORS.get(name) + config = _get_input_value(config, name, validator, descriptions) return config config = {} - _ask_user_input(config, config_schema, PARAMETER_DESCRIPTIONS) + _ask_user_input(config, ConfigSchema, PARAMETER_DESCRIPTIONS) + + # Sections that are validated nested specs (cannot be set as a single value). + base_schemas = list(ConfigSchema.NESTED) # Ask user if they want to change any optional keys while True: @@ -75,52 +79,27 @@ def _ask_user_input(config, schema_obj, descriptions): change_optional = strtobool(input_val) if change_optional: - key = input("Enter the key name (e.g., 'model/beamformer/param'): ") + key = input("Enter the key name (e.g., 'parameters/grid_size_x'): ") keys = key.split("/") - base_schemas = [ - "data", - "plot", - "model", - "preprocess", - "postprocess", - "scan", - ] - - if len(keys) > 1: - if keys[0] not in base_schemas: - print(red(f"Invalid key {key}, please try again.")) - continue - - if len(keys) == 1: - if keys[0] in base_schemas: - print( - red( - f"Invalid key, cannot be part of base keys {base_schemas} " - "please try again." - ) + + if len(keys) > 1 and keys[0] not in base_schemas: + print(red(f"Invalid key {key}, please try again.")) + continue + if len(keys) == 1 and keys[0] in base_schemas: + print( + red( + f"Invalid key, cannot be part of base keys {base_schemas} " + "please try again." ) - continue + ) + continue nested_dict = config for k in keys[:-1]: nested_dict = nested_dict.setdefault(k, {}) - # retrieve schema value from the nested key - schema_obj = config_schema - for k in keys: - sub_keys = [ - s.key if not isinstance(s, str) else s for s in schema_obj.schema.keys() - ] - - schema_key = list(schema_obj.schema.keys())[sub_keys.index(k)] - - schema_obj = schema_obj.schema[schema_key] - - descriptions = PARAMETER_DESCRIPTIONS - for k in keys[:-1]: - descriptions = descriptions[k] - - nested_dict = _get_input_value(nested_dict, keys[-1], schema_obj, descriptions) + validator, descriptions = _resolve_spec_field(keys) + nested_dict = _get_input_value(nested_dict, keys[-1], validator, descriptions) else: print("No optional keys will be changed.") break diff --git a/zea/internal/config/validation.py b/zea/internal/config/validation.py index 43e32ed1a..5420a8924 100644 --- a/zea/internal/config/validation.py +++ b/zea/internal/config/validation.py @@ -1,178 +1,522 @@ -"""Validate configuration yaml files. +"""Validate configuration dictionaries. -https://github.com/keleshev/schema -https://www.andrewvillazon.com/validate-yaml-python-schema/ +Config validation follows the same dataclass-Spec pattern used elsewhere in zea +for :class:`~zea.data.spec.ProbeSpec` / :class:`~zea.data.spec.ScanSpec`: each +config section is a :func:`dataclasses.dataclass` with typed fields, default +values, and validation in ``__post_init__``. -This file specifies bare bone structure of the config files. -Furthermore it check the config file you create for validity and sets -missing (if optional) parameters to default values. When adding functionality -that needs parameters from the config file, make sure to add those paremeters here. -Also if that parameter is optional, add a default value. +Unlike the array Specs in :mod:`zea.data.spec` (which validate numpy +``dtype``/``shape`` and named-dimension consistency), config values are plain +Python scalars / lists / dicts, so validation here uses small validator +callables (enums, numeric ranges, regexes). +The ``parameters`` section and the top-level config are *open*: they accept +arbitrary extra keys, which are stored and re-emitted unchanged. This mirrors +:class:`zea.Parameters`, which keeps unknown keys as pass-through +``_custom_params``. When adding a documented parameter, add a field (with a +default if optional) to the relevant ``*Config`` dataclass below. """ +import re +from dataclasses import MISSING, dataclass, field, fields from pathlib import Path - -from schema import And, Optional, Or, Regex, Schema +from typing import Any, Callable, ClassVar, Optional, Type from zea.internal.checks import _DATA_TYPES from zea.metrics import metrics_registry -# predefined checks, later used in schema to check validity of parameter -any_number = Or( - int, - float, - error="Must be a number, scientific notation should be of form x.xe+xx, " - "otherwise interpreted as string", -) -list_of_size_two = And(list, lambda _list: len(_list) == 2) -positive_integer = And(int, lambda i: i > 0) -positive_integer_and_zero = And(int, lambda i: i >= 0) -positive_float = And(float, lambda f: f > 0) -list_of_floats = And(list, lambda _list: all(isinstance(_l, float) for _l in _list)) -list_of_positive_integers = And(list, lambda _list: all(_l >= 0 for _l in _list)) -percentage = And(any_number, lambda f: 0 <= f <= 100) - _ALLOWED_PLOT_LIBS = ("opencv", "matplotlib") -# pipeline / operations -pipeline_schema = Schema( - { - Optional("operations", default=["identity"]): Or( - None, [Or(str, {"name": str, "params": dict}, {"name": str})] - ), - Optional("with_batch_dim", default=True): bool, - Optional("jit_options", default="ops"): Or(None, "ops", "pipeline"), - Optional("jit_kwargs", default=None): Or(None, dict), - Optional("name", default="pipeline"): str, - Optional("validate", default=True): bool, + +# --------------------------------------------------------------------------- +# Validator helpers +# +# Each validator is a ``Callable[[Any], Any]`` that returns the (possibly +# coerced) value or raises ``ValueError`` with a human-readable message. These +# replace the ``schema`` library primitives (``And`` / ``Or`` / ``Regex`` / +# lambdas) that were previously used. +# --------------------------------------------------------------------------- + + +def boolean(value: Any) -> bool: + """Validate a boolean.""" + if not isinstance(value, bool): + raise ValueError(f"must be a boolean, got {type(value).__name__}") + return value + + +def string(value: Any) -> str: + """Validate a string.""" + if not isinstance(value, str): + raise ValueError(f"must be a string, got {type(value).__name__}") + return value + + +def integer(value: Any) -> int: + """Validate an integer (``bool`` is rejected).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"must be an integer, got {type(value).__name__}") + return value + + +def any_number(value: Any) -> Any: + """Validate a number (``int`` or ``float``, ``bool`` is rejected).""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError( + "must be a number, scientific notation should be of form x.xe+xx, " + "otherwise interpreted as string" + ) + return value + + +def positive_integer(value: Any) -> int: + """Validate a strictly positive integer.""" + integer(value) + if value <= 0: + raise ValueError(f"must be a positive integer, got {value}") + return value + + +def positive_integer_and_zero(value: Any) -> int: + """Validate a non-negative integer.""" + integer(value) + if value < 0: + raise ValueError(f"must be a non-negative integer, got {value}") + return value + + +def positive_float(value: Any) -> float: + """Validate a strictly positive float.""" + if isinstance(value, bool) or not isinstance(value, float): + raise ValueError(f"must be a float, got {type(value).__name__}") + if value <= 0: + raise ValueError(f"must be a positive float, got {value}") + return value + + +def mapping(value: Any) -> dict: + """Validate a dict (mapping).""" + if not isinstance(value, dict): + raise ValueError(f"must be a dict, got {type(value).__name__}") + return value + + +def list_of_size_two(value: Any) -> list: + """Validate a list of exactly two elements.""" + if not isinstance(value, list) or len(value) != 2: + raise ValueError(f"must be a list of length two, got {value!r}") + return value + + +def list_of_positive_integers(value: Any) -> list: + """Validate a list of non-negative integers.""" + if not isinstance(value, list) or not all( + isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value + ): + raise ValueError(f"must be a list of non-negative integers, got {value!r}") + return value + + +def string_or_path(value: Any) -> Any: + """Validate a string or :class:`pathlib.Path`.""" + if not isinstance(value, (str, Path)): + raise ValueError(f"must be a string or path, got {type(value).__name__}") + return value + + +def enum(*allowed: Any) -> Callable[[Any], Any]: + """Build a validator that accepts only one of ``allowed`` values.""" + + def validate(value: Any) -> Any: + if value not in allowed: + raise ValueError(f"must be one of {list(allowed)}, got {value!r}") + return value + + return validate + + +def regex(pattern: str) -> Callable[[Any], str]: + """Build a validator that fully matches ``pattern``.""" + compiled = re.compile(pattern) + + def validate(value: Any) -> str: + if not isinstance(value, str) or compiled.fullmatch(value) is None: + raise ValueError(f"must match pattern {pattern!r}, got {value!r}") + return value + + return validate + + +def any_of(*validators: Callable[[Any], Any]) -> Callable[[Any], Any]: + """Build a validator that passes if any of ``validators`` passes.""" + + def validate(value: Any) -> Any: + errors = [] + for validator in validators: + try: + return validator(value) + except ValueError as exc: + errors.append(str(exc)) + raise ValueError(" or ".join(errors)) + + return validate + + +def optional(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: + """Build a validator that also accepts ``None``.""" + + def validate(value: Any) -> Any: + if value is None: + return None + return validator(value) + + return validate + + +def operations_list(value: Any) -> list: + """Validate the pipeline ``operations`` list. + + Each element is either an operation name (str) or a mapping with a ``name`` + (str) and optional ``params`` (dict). + """ + if not isinstance(value, list): + raise ValueError(f"must be a list of operations, got {type(value).__name__}") + for op in value: + if isinstance(op, str): + continue + if isinstance(op, dict): + if not isinstance(op.get("name"), str): + raise ValueError(f"operation {op!r} must have a string 'name'") + unexpected = set(op) - {"name", "params"} + if unexpected: + raise ValueError(f"operation {op!r} has unexpected keys {sorted(unexpected)}") + if "params" in op and not isinstance(op["params"], dict): + raise ValueError(f"operation {op!r} 'params' must be a dict") + continue + raise ValueError(f"invalid operation {op!r}") + return value + + +# --------------------------------------------------------------------------- +# Config Spec base class +# --------------------------------------------------------------------------- + + +@dataclass +class ConfigSpec: + """Base class for config sections. + + Subclasses are dataclasses that declare their fields (with defaults for + optional fields) and the following class variables: + + - ``VALIDATORS``: maps a field name to a validator callable. + - ``NESTED``: maps a field name to a nested :class:`ConfigSpec` subclass. + - ``ALLOW_EXTRA``: when ``True`` arbitrary extra keys are accepted and + passed through unchanged (used for the open ``parameters`` section and the + top-level config). + """ + + VALIDATORS: ClassVar[dict[str, Callable[[Any], Any]]] = {} + NESTED: ClassVar[dict[str, Type["ConfigSpec"]]] = {} + ALLOW_EXTRA: ClassVar[bool] = False + + def __post_init__(self) -> None: + if not hasattr(self, "_extra"): + self._extra: dict[str, Any] = {} + for name in self.field_names(): + value = getattr(self, name) + nested = self.NESTED.get(name) + if nested is not None: + setattr(self, name, self._coerce_nested(name, nested, value)) + continue + validator = self.VALIDATORS.get(name) + if validator is not None: + try: + value = validator(value) + except ValueError as exc: + raise ValueError(f"{type(self).__name__}.{name}: {exc}") from exc + setattr(self, name, value) + + def _coerce_nested( + self, name: str, nested: Type["ConfigSpec"], value: Any + ) -> "ConfigSpec": + if value is None: + # Optional nested section: fall back to its defaults. + return nested.from_dict({}) + if isinstance(value, nested): + return value + if isinstance(value, dict): + try: + return nested.from_dict(value) + except ValueError as exc: + raise ValueError(f"{type(self).__name__}.{name}: {exc}") from exc + raise ValueError( + f"{type(self).__name__}.{name}: expected a mapping for " + f"{nested.__name__}, got {type(value).__name__}" + ) + + # -- construction / serialization -------------------------------------- + + @classmethod + def from_dict(cls, dictionary: Optional[dict]) -> "ConfigSpec": + """Validate ``dictionary`` and return a populated spec instance.""" + if dictionary is None: + dictionary = {} + if not isinstance(dictionary, dict): + raise ValueError( + f"{cls.__name__}: expected a mapping, got {type(dictionary).__name__}" + ) + + field_names = set(cls.field_names()) + known = {k: v for k, v in dictionary.items() if k in field_names} + extra = {k: v for k, v in dictionary.items() if k not in field_names} + + if extra and not cls.ALLOW_EXTRA: + raise ValueError(f"{cls.__name__}: unexpected keys {sorted(extra)}") + + missing = [name for name in cls.required_fields() if name not in known] + if missing: + raise ValueError(f"{cls.__name__}: missing required keys {missing}") + + obj = cls(**known) + if extra: + obj._extra.update(extra) + return obj + + def to_dict(self) -> dict[str, Any]: + """Return a plain dict with defaults filled and nested specs expanded.""" + result: dict[str, Any] = {} + for name in self.field_names(): + value = getattr(self, name) + if isinstance(value, ConfigSpec): + value = value.to_dict() + result[name] = value + result.update(self._extra) + return result + + # -- introspection (used by tooling / docs) ---------------------------- + + @classmethod + def field_names(cls) -> tuple[str, ...]: + """Return the names of all declared fields.""" + return tuple(f.name for f in fields(cls)) + + @classmethod + def required_fields(cls) -> tuple[str, ...]: + """Return the names of fields without a default value.""" + return tuple( + f.name + for f in fields(cls) + if f.default is MISSING and f.default_factory is MISSING + ) + + @classmethod + def optional_fields(cls) -> tuple[str, ...]: + """Return the names of fields with a default value.""" + required = set(cls.required_fields()) + return tuple(name for name in cls.field_names() if name not in required) + + @classmethod + def all_field_paths(cls, prefix: str = "") -> set[str]: + """Return all documented field paths in dot notation, recursing nested specs.""" + paths: set[str] = set() + for name in cls.field_names(): + full = f"{prefix}.{name}" if prefix else name + nested = cls.NESTED.get(name) + if nested is not None: + paths |= nested.all_field_paths(full) + else: + paths.add(full) + return paths + + +# --------------------------------------------------------------------------- +# Config sections +# --------------------------------------------------------------------------- + + +@dataclass +class DataConfig(ConfigSpec): + """The ``data:`` section: what data to load and how.""" + + dtype: Any + dataset_folder: Any + resolution: Any = None + to_dtype: Any = "image" + file_path: Any = None + local: Any = True + frame_no: Any = None + dynamic_range: Any = field(default_factory=lambda: [-60, 0]) + input_range: Any = None + output_range: Any = None + apodization: Any = None + user: Any = None + + VALIDATORS: ClassVar[dict] = { + "dtype": enum(*_DATA_TYPES), + "dataset_folder": string, + "resolution": optional(positive_float), + "to_dtype": enum(*_DATA_TYPES), + "file_path": optional(string_or_path), + "local": boolean, + "frame_no": optional(any_of(enum("all"), integer)), + "dynamic_range": list_of_size_two, + "input_range": optional(list_of_size_two), + "output_range": optional(list_of_size_two), + "apodization": optional(string), + "user": optional(mapping), } -) - -# postprocess DEPRECATED -postprocess_schema = Schema( - { - Optional("contrast_boost", default=None): Or( - None, - { - "k_p": float, - "k_n": float, - "threshold": float, - }, - ), - Optional("thresholding", default=None): Or( - None, - { - Optional("percentile", default=None): Or(None, percentage), - Optional("threshold", default=None): Or(None, any_number), - Optional("fill_value", default="min"): Or("min", "max", "threshold", any_number), - Optional("below_threshold", default=True): bool, - Optional("threshold_type", default="hard"): Or("hard", "soft"), - }, - ), - Optional("lista", default=None): Or(bool, None), + + +@dataclass +class PlotConfig(ConfigSpec): + """The ``plot:`` section: UI / plotting settings.""" + + save: Any = False + plot_lib: Any = "opencv" + fps: Any = 20 + tag: Any = None + headless: Any = False + selector: Any = None + selector_metric: Any = "gcnr" + fliplr: Any = False + image_extension: Any = "png" + video_extension: Any = "gif" + + VALIDATORS: ClassVar[dict] = { + "save": boolean, + "plot_lib": enum(*_ALLOWED_PLOT_LIBS), + "fps": integer, + "tag": optional(string), + "headless": boolean, + "selector": optional(enum("rectangle", "lasso")), + "selector_metric": enum(*metrics_registry.registered_names()), + "fliplr": boolean, + "image_extension": enum("png", "jpg"), + "video_extension": enum("mp4", "gif"), + } + + +@dataclass +class PipelineConfig(ConfigSpec): + """The ``pipeline:`` section: operations and JIT settings.""" + + operations: Any = field(default_factory=lambda: ["identity"]) + with_batch_dim: Any = True + jit_options: Any = "ops" + jit_kwargs: Any = None + name: Any = "pipeline" + validate: Any = True + + VALIDATORS: ClassVar[dict] = { + "operations": optional(operations_list), + "with_batch_dim": boolean, + "jit_options": optional(enum("ops", "pipeline")), + "jit_kwargs": optional(mapping), + "name": string, + "validate": boolean, } -) - -# scan -# Schema for the flat ``parameters:`` config section (formerly ``scan:``). -# ``ignore_extra_keys`` allows arbitrary custom/manual parameters in addition -# to the documented recon parameters below. -parameters_schema = Schema( - { - Optional("xlims", default=None): Or(None, list_of_size_two), - Optional("zlims", default=None): Or(None, list_of_size_two), - Optional("ylims", default=None): Or(None, list_of_size_two), - Optional("selected_transmits", default=None): Or( - None, - positive_integer, - list_of_positive_integers, - "all", - "center", + + +@dataclass +class ParametersConfig(ConfigSpec): + """The ``parameters:`` section: flat scan/probe/custom parameters. + + This section is *open*: documented reconstruction parameters are validated, + and arbitrary custom keys are accepted and passed through unchanged + (consistent with :class:`zea.Parameters`). + """ + + xlims: Any = None + zlims: Any = None + ylims: Any = None + selected_transmits: Any = None + grid_size_x: Any = None + grid_size_z: Any = None + n_ch: Any = None + n_ax: Any = None + center_frequency: Any = None + sampling_frequency: Any = None + demodulation_frequency: Any = None + f_number: Any = None + apply_lens_correction: Any = False + lens_thickness: Any = 1e-3 + lens_sound_speed: Any = 1000 + theta_range: Any = None + phi_range: Any = None + rho_range: Any = None + fill_value: Any = 0.0 + resolution: Any = None + + ALLOW_EXTRA: ClassVar[bool] = True + VALIDATORS: ClassVar[dict] = { + "xlims": optional(list_of_size_two), + "zlims": optional(list_of_size_two), + "ylims": optional(list_of_size_two), + "selected_transmits": optional( + any_of(positive_integer, list_of_positive_integers, enum("all", "center")) ), - Optional("grid_size_x", default=None): Or(None, positive_integer), - Optional("grid_size_z", default=None): Or(None, positive_integer), - Optional("n_ch", default=None): Or(None, int), - Optional("n_ax", default=None): Or(None, int), - Optional("center_frequency", default=None): Or(None, any_number), - Optional("sampling_frequency", default=None): Or(None, any_number), - Optional("demodulation_frequency", default=None): Or(None, any_number), - Optional("f_number", default=None): Or(None, positive_float), - Optional("apply_lens_correction", default=False): bool, - Optional("lens_thickness", default=1e-3): positive_float, - Optional("lens_sound_speed", default=1000): Or(positive_float, positive_integer), - Optional("theta_range", default=None): Or(None, list_of_size_two), - Optional("phi_range", default=None): Or(None, list_of_size_two), - Optional("rho_range", default=None): Or(None, list_of_size_two), - Optional("fill_value", default=0.0): any_number, - Optional("resolution", default=None): Or(None, positive_float), - }, - ignore_extra_keys=True, -) - -# plot -plot_schema = Schema( - { - Optional("save", default=False): bool, - Optional("plot_lib", default="opencv"): Or(*_ALLOWED_PLOT_LIBS), - Optional("fps", default=20): int, - Optional("tag", default=None): Or(None, str), - Optional("headless", default=False): bool, - Optional("selector", default=None): Or(None, "rectangle", "lasso"), - Optional("selector_metric", default="gcnr"): Or(*metrics_registry.registered_names()), - Optional("fliplr", default=False): bool, - Optional("image_extension", default="png"): Or("png", "jpg"), - Optional("video_extension", default="gif"): Or("mp4", "gif"), + "grid_size_x": optional(positive_integer), + "grid_size_z": optional(positive_integer), + "n_ch": optional(integer), + "n_ax": optional(integer), + "center_frequency": optional(any_number), + "sampling_frequency": optional(any_number), + "demodulation_frequency": optional(any_number), + "f_number": optional(positive_float), + "apply_lens_correction": boolean, + "lens_thickness": positive_float, + "lens_sound_speed": any_of(positive_float, positive_integer), + "theta_range": optional(list_of_size_two), + "phi_range": optional(list_of_size_two), + "rho_range": optional(list_of_size_two), + "fill_value": any_number, + "resolution": optional(positive_float), } -) - -data_schema = Schema( - { - "dtype": Or(*_DATA_TYPES), - "dataset_folder": str, - Optional("resolution", default=None): Or(None, positive_float), - Optional("to_dtype", default="image"): Or(*_DATA_TYPES), - Optional("file_path", default=None): Or(None, str, Path), - Optional("local", default=True): bool, - Optional("frame_no", default=None): Or(None, "all", int), - Optional("dynamic_range", default=[-60, 0]): list_of_size_two, - Optional("input_range", default=None): Or(None, list_of_size_two), - Optional("output_range", default=None): Or(None, list_of_size_two), - Optional("apodization", default=None): Or(None, str), - Optional("user", default=None): Or(None, dict), + + +@dataclass +class ConfigSchema(ConfigSpec): + """The top-level config. + + This is *open*: arbitrary extra top-level sections (e.g. ``model:``) are + accepted and passed through unchanged. The deprecated ``scan:`` section is + aliased to ``parameters:`` before validation (see + :func:`zea.config._migrate_legacy_config`). + """ + + data: Any + plot: Any = None + pipeline: Any = None + parameters: Any = None + device: Any = "auto:1" + hide_devices: Any = None + git: Any = None + + ALLOW_EXTRA: ClassVar[bool] = True + NESTED: ClassVar[dict] = { + "data": DataConfig, + "plot": PlotConfig, + "pipeline": PipelineConfig, + "parameters": ParametersConfig, } -) - -# top level schema -config_schema = Schema( - { - "data": data_schema, - Optional("plot", default=plot_schema.validate({})): plot_schema, - Optional("pipeline", default=pipeline_schema.validate({})): pipeline_schema, - # Flat mapping of scan/probe/custom parameters that overwrite values - # loaded from the file (see ``File.load_parameters`` and - # ``Pipeline.prepare_parameters``). Documented recon parameters are - # validated; arbitrary custom keys are also allowed (ignore_extra_keys). - Optional("parameters", default=parameters_schema.validate({})): parameters_schema, - # Deprecated alias for ``parameters``; still accepted for backward - # compatibility (migrated to ``parameters`` on load). - Optional("scan"): Or(None, dict), - Optional("device", default="auto:1"): Or( - "cpu", - "gpu", - "cuda", - Regex(r"cuda:\d+"), - Regex(r"gpu:\d+"), - Regex(r"auto:\d+"), - Regex(r"auto:-\d+"), - None, - ), - Optional("hide_devices", default=None): Or( - None, list_of_positive_integers, positive_integer_and_zero + VALIDATORS: ClassVar[dict] = { + "device": optional( + any_of( + enum("cpu", "gpu", "cuda"), + regex(r"cuda:\d+"), + regex(r"gpu:\d+"), + regex(r"auto:-?\d+"), + ) ), - Optional("git", default=None): Or(None, str), - }, - # Allow arbitrary extra top-level keys; they are ignored by the workflow - # unless accessed manually from code (see redesign of Config). - ignore_extra_keys=True, -) + "hide_devices": optional(any_of(list_of_positive_integers, positive_integer_and_zero)), + "git": optional(string), + } + + +def validate_config(config: Optional[dict]) -> dict: + """Validate a config dict and return a plain dict with defaults filled in. + + This is the replacement for the previous ``config_schema.validate(...)``. + """ + return ConfigSchema.from_dict(config).to_dict() From 1a31d3c5e2891c797f059df04c3c08b6560c3df0 Mon Sep 17 00:00:00 2001 From: simon Date: Thu, 4 Jun 2026 09:08:07 +0000 Subject: [PATCH 2/3] linter --- zea/internal/config/validation.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/zea/internal/config/validation.py b/zea/internal/config/validation.py index 5420a8924..0f78515e5 100644 --- a/zea/internal/config/validation.py +++ b/zea/internal/config/validation.py @@ -237,9 +237,7 @@ def __post_init__(self) -> None: raise ValueError(f"{type(self).__name__}.{name}: {exc}") from exc setattr(self, name, value) - def _coerce_nested( - self, name: str, nested: Type["ConfigSpec"], value: Any - ) -> "ConfigSpec": + def _coerce_nested(self, name: str, nested: Type["ConfigSpec"], value: Any) -> "ConfigSpec": if value is None: # Optional nested section: fall back to its defaults. return nested.from_dict({}) @@ -263,9 +261,7 @@ def from_dict(cls, dictionary: Optional[dict]) -> "ConfigSpec": if dictionary is None: dictionary = {} if not isinstance(dictionary, dict): - raise ValueError( - f"{cls.__name__}: expected a mapping, got {type(dictionary).__name__}" - ) + raise ValueError(f"{cls.__name__}: expected a mapping, got {type(dictionary).__name__}") field_names = set(cls.field_names()) known = {k: v for k, v in dictionary.items() if k in field_names} @@ -305,9 +301,7 @@ def field_names(cls) -> tuple[str, ...]: def required_fields(cls) -> tuple[str, ...]: """Return the names of fields without a default value.""" return tuple( - f.name - for f in fields(cls) - if f.default is MISSING and f.default_factory is MISSING + f.name for f in fields(cls) if f.default is MISSING and f.default_factory is MISSING ) @classmethod From e3ea1b3c18deec18eea4fa01cb90db4d58365f88 Mon Sep 17 00:00:00 2001 From: Tristan Stevens Date: Thu, 4 Jun 2026 14:36:58 +0000 Subject: [PATCH 3/3] Clean up config validation and remove outdated Interface class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip DataConfig to path/local/indices/user; remove dtype, dynamic_range, resolution, apodization, to_dtype, input_range, output_range - Consolidate dataset_folder + file_path into a single path field (supports hf://) - Rename frame_no → indices in DataConfig and all YAML configs - ParametersConfig becomes a pure open pass-through (no predefined fields) - Remove PlotConfig; keep DataConfig for path management in setup_zea - Delete zea/interface.py and tests/test_interface.py (Interface class was outdated) - Replace schema library dependency with inline _validate_convert_config() - Rewrite config.rst as a hand-authored paradigm doc with explicit key reference tables - Redesign parameters_doc.py (no RST generation, workspace path fix) - Fix docs build: remove interface refs from __init__.py, autosummary, cli.rst - Add Config() TypeError when a path string is passed instead of a dict --- configs/config_camus.yaml | 33 +- configs/config_carotid.yaml | 52 +-- configs/config_echonet.yaml | 46 +-- configs/config_echonetlvh.yaml | 46 +-- configs/config_picmus_iq.yaml | 48 +-- configs/config_picmus_rf.yaml | 49 +-- docs/_static/diagrams_dataflow.png | Bin 89760 -> 0 bytes docs/source/_autosummary/zea.rst | 1 - docs/source/_spec_ref.rst | 2 + docs/source/cli.rst | 10 +- docs/source/config.rst | 288 +++++++-------- docs/source/parameters_doc.py | 151 +------- docs/source/spec_doc.py | 2 + poetry.lock | 14 +- pyproject.toml | 1 - tests/data/test_dataset.py | 4 +- tests/test_config_validation.py | 79 +++-- tests/test_configs.py | 6 + tests/test_interface.py | 60 ---- zea/__init__.py | 2 - zea/__main__.py | 57 +-- zea/config.py | 11 +- zea/data/convert/verasonics.py | 74 +++- zea/data/datasets.py | 13 +- zea/interface.py | 551 ----------------------------- zea/internal/config/parameters.py | 117 +----- zea/internal/config/validation.py | 139 +------- 27 files changed, 379 insertions(+), 1477 deletions(-) delete mode 100644 docs/_static/diagrams_dataflow.png delete mode 100644 tests/test_interface.py delete mode 100644 zea/interface.py diff --git a/configs/config_camus.yaml b/configs/config_camus.yaml index 4f0465e71..e623a854b 100644 --- a/configs/config_camus.yaml +++ b/configs/config_camus.yaml @@ -1,29 +1,12 @@ # config_camus.yaml - comments were autogenerated from PARAMETER_DESCRIPTIONS in zea/config/parameters.py -# The data section contains the parameters for the data. +# Data path and loading settings. data: - # The path of the folder to load data files from (relative to the user data - # root as set in users.yaml) - dataset_folder: hf://zeahub/camus-sample - # The path of the file to load when running the UI (either an absolute path or - # one relative to the dataset folder) - file_path: val/patient0401/patient0401_4CH_half_sequence.hdf5 + # Full path to the data file. Supports absolute paths, paths relative to the + # user data root (set in users.yaml), and Hugging Face Hub paths + # (hf://org/repo/path/to/file.hdf5). + path: hf://zeahub/camus-sample/val/patient0401/patient0401_4CH_half_sequence.hdf5 # true: use local data on this device, false: use data from NAS local: false - # The form of data to load (raw_data, rf_data, iq_data, beamformed_data, - # envelope_data, image, image_sc) - dtype: image_sc - # The dynamic range for showing data in db [min, max] - dynamic_range: [-60, 0] - # The frame number to load when running the UI (null, int, 'all') - frame_no: all - # The type of data to convert to (raw_data, aligned_data, beamformed_data, - # envelope_data, image, image_sc) - to_dtype: image_sc - -# Settings pertaining to plotting when running the UI (`zea --config -# `) -plot: - # Set to true to save the plots to disk, false to only display them in the UI - save: true - # The plotting library to use (opencv, matplotlib) - plot_lib: opencv \ No newline at end of file + # Indices into the data to load. null loads the default, 'all' loads every + # frame, int loads a single frame, list loads specific frames. + indices: all diff --git a/configs/config_carotid.yaml b/configs/config_carotid.yaml index 7e6d911b8..6faad51e2 100644 --- a/configs/config_carotid.yaml +++ b/configs/config_carotid.yaml @@ -1,36 +1,24 @@ # config_carotid.yaml - comments were autogenerated from PARAMETER_DESCRIPTIONS in zea/config/parameters.py -# The data section contains the parameters for the data. +# Data path and loading settings. data: - # The path of the folder to load data files from (relative to the user data - # root as set in users.yaml) - dataset_folder: hf://zeahub/zea-carotid-2023 - # The path of the file to load when running the UI (either an absolute path or - # one relative to the dataset folder) - file_path: 2_cross_bifur_right_0000.hdf5 + # Full path to the data file. Supports absolute paths, paths relative to the + # user data root (set in users.yaml), and Hugging Face Hub paths + # (hf://org/repo/path/to/file.hdf5). + path: hf://zeahub/zea-carotid-2023/2_cross_bifur_right_0000.hdf5 # true: use local data on this device, false: use data from NAS local: false - # The form of data to load (raw_data, rf_data, iq_data, beamformed_data, - # envelope_data, image, image_sc) - dtype: raw_data - # The dynamic range for showing data in db [min, max] - dynamic_range: [-40, 0] - # The frame number to load when running the UI (null, int, 'all') - frame_no: all + # Indices into the data to load. null loads the default, 'all' loads every + # frame, int loads a single frame, list loads specific frames. + indices: all -# The parameters section is a flat mapping of scan/probe/custom parameters that -# overwrite values loaded from the data file. Documented reconstruction -# parameters are listed below; arbitrary custom parameters are also allowed. +# Open mapping of scan/probe/custom parameters that overwrite values loaded from +# the data file. ProbeSpec and ScanSpec are the authoritative sources for valid +# parameter names — see the spec reference in data-acquisition. Arbitrary custom +# parameters are forwarded to the pipeline unchanged. parameters: - # The number of transmits in a frame. Can be 'all' for all transmits, an - # integer for a specific number of transmits selected evenly from the - # transmits in the frame, or a list of integers for specific transmits to - # select from the frame. - selected_transmits: all # 149 is all or 11 for instance for reduce number of Tx - # The number of channels in the raw data (1 for rf data, 2 for iq data) + selected_transmits: all n_ch: 1 - # The number of pixels in the beamforming grid in the x-direction grid_size_x: 400 - # The number of pixels in the beamforming grid in the z-direction grid_size_z: 600 # This section contains the necessary parameters for building the pipeline. @@ -48,17 +36,5 @@ pipeline: - name: normalize - name: log_compress -# The device to run on ('cpu', 'gpu:0', 'gpu:1', ...) +# The device to run on ('cpu', 'gpu:0', 'gpu:1', 'auto:1', ...) device: auto:1 - -# Settings pertaining to plotting when running the UI (`zea --config -# `) -plot: - # Set to true to save the plots to disk, false to only display them in the UI - save: true - # The plotting library to use (opencv, matplotlib) - plot_lib: opencv - # Set to true to run the UI in headless mode - headless: false - # The name for the plot - tag: carotid diff --git a/configs/config_echonet.yaml b/configs/config_echonet.yaml index a4291295d..843ff7057 100644 --- a/configs/config_echonet.yaml +++ b/configs/config_echonet.yaml @@ -1,35 +1,23 @@ # config_echonet.yaml - comments were autogenerated from PARAMETER_DESCRIPTIONS in zea/config/parameters.py -# The data section contains the parameters for the data. +# Data path and loading settings. data: - # The path of the folder to load data files from (relative to the user data - # root as set in users.yaml) - dataset_folder: echonet_v2025/train - # The path of the file to load when running the UI (either an absolute path or - # one relative to the dataset folder) - file_path: 0X1A8F20B8BF0B4B45.hdf5 + # Full path to the data file. Supports absolute paths, paths relative to the + # user data root (set in users.yaml), and Hugging Face Hub paths + # (hf://org/repo/path/to/file.hdf5). + path: echonet_v2025/train/0X1A8F20B8BF0B4B45.hdf5 # true: use local data on this device, false: use data from NAS local: false - # The form of data to load (raw_data, rf_data, iq_data, beamformed_data, - # envelope_data, image, image_sc) - dtype: image - # The dynamic range for showing data in db [min, max] - dynamic_range: [-60, 0] - # The frame number to load when running the UI (null, int, 'all') - frame_no: all - # The type of data to convert to (raw_data, aligned_data, beamformed_data, - # envelope_data, image, image_sc) - to_dtype: image_sc + # Indices into the data to load. null loads the default, 'all' loads every + # frame, int loads a single frame, list loads specific frames. + indices: all -# The parameters section is a flat mapping of scan/probe/custom parameters that -# overwrite values loaded from the data file. Documented reconstruction -# parameters are listed below; arbitrary custom parameters are also allowed. +# Open mapping of scan/probe/custom parameters that overwrite values loaded from +# the data file. ProbeSpec and ScanSpec are the authoritative sources for valid +# parameter names — see the spec reference in data-acquisition. Arbitrary custom +# parameters are forwarded to the pipeline unchanged. parameters: - # The range of theta values in radians for scan conversion (null, [min, max]). - theta_range: [-0.78, 0.78] # [-45, 45] in rads - # The range of rho values in meters for scan conversion (null, [min, max]). + theta_range: [-0.78, 0.78] rho_range: [0, 1] - # Value to fill the image with outside the defined region (float, default - # 0.0). fill_value: -60 # This section contains the necessary parameters for building the pipeline. @@ -40,11 +28,3 @@ pipeline: - name: scan_convert params: jit_compile: false - -# Settings pertaining to plotting when running the UI (`zea --config -# `) -plot: - # Set to true to save the plots to disk, false to only display them in the UI - save: true - # The plotting library to use (opencv, matplotlib) - plot_lib: opencv diff --git a/configs/config_echonetlvh.yaml b/configs/config_echonetlvh.yaml index e37f88e24..f8702dc24 100644 --- a/configs/config_echonetlvh.yaml +++ b/configs/config_echonetlvh.yaml @@ -1,35 +1,23 @@ # config_echonetlvh.yaml - comments were autogenerated from PARAMETER_DESCRIPTIONS in zea/config/parameters.py -# The data section contains the parameters for the data. +# Data path and loading settings. data: - # The path of the folder to load data files from (relative to the user data - # root as set in users.yaml) - dataset_folder: echonetlvh_v2025/train - # The path of the file to load when running the UI (either an absolute path or - # one relative to the dataset folder) - file_path: 0X1017398D3C3F5FF9.hdf5 + # Full path to the data file. Supports absolute paths, paths relative to the + # user data root (set in users.yaml), and Hugging Face Hub paths + # (hf://org/repo/path/to/file.hdf5). + path: echonetlvh_v2025/train/0X1017398D3C3F5FF9.hdf5 # true: use local data on this device, false: use data from NAS local: false - # The form of data to load (raw_data, rf_data, iq_data, beamformed_data, - # envelope_data, image, image_sc) - dtype: image - # The dynamic range for showing data in db [min, max] - dynamic_range: [-60, 0] - # The frame number to load when running the UI (null, int, 'all') - frame_no: all - # The type of data to convert to (raw_data, aligned_data, beamformed_data, - # envelope_data, image, image_sc) - to_dtype: image_sc + # Indices into the data to load. null loads the default, 'all' loads every + # frame, int loads a single frame, list loads specific frames. + indices: all -# The parameters section is a flat mapping of scan/probe/custom parameters that -# overwrite values loaded from the data file. Documented reconstruction -# parameters are listed below; arbitrary custom parameters are also allowed. +# Open mapping of scan/probe/custom parameters that overwrite values loaded from +# the data file. ProbeSpec and ScanSpec are the authoritative sources for valid +# parameter names — see the spec reference in data-acquisition. Arbitrary custom +# parameters are forwarded to the pipeline unchanged. parameters: - # The range of theta values in radians for scan conversion (null, [min, max]). - theta_range: [-0.78, 0.78] # [-45, 45] in rads - # The range of rho values in meters for scan conversion (null, [min, max]). + theta_range: [-0.78, 0.78] rho_range: [0, 256] - # Value to fill the image with outside the defined region (float, default - # 0.0). fill_value: -60 # This section contains the necessary parameters for building the pipeline. @@ -41,11 +29,3 @@ pipeline: params: jit_compile: false order: 2 - -# Settings pertaining to plotting when running the UI (`zea --config -# `) -plot: - # Set to true to save the plots to disk, false to only display them in the UI - save: true - # The plotting library to use (opencv, matplotlib) - plot_lib: opencv diff --git a/configs/config_picmus_iq.yaml b/configs/config_picmus_iq.yaml index 5394b739c..1cffe69fa 100644 --- a/configs/config_picmus_iq.yaml +++ b/configs/config_picmus_iq.yaml @@ -1,44 +1,26 @@ # config_picmus_iq.yaml - comments were autogenerated from PARAMETER_DESCRIPTIONS in zea/config/parameters.py -# The data section contains the parameters for the data. +# Data path and loading settings. data: - # The path of the folder to load data files from (relative to the user data - # root as set in users.yaml) - dataset_folder: hf://zeahub/picmus/database/simulation/contrast_speckle/contrast_speckle_simu_dataset_iq - # The path of the file to load when running the UI (either an absolute path or - # one relative to the dataset folder) - file_path: contrast_speckle_simu_dataset_iq.hdf5 + # Full path to the data file. Supports absolute paths, paths relative to the + # user data root (set in users.yaml), and Hugging Face Hub paths + # (hf://org/repo/path/to/file.hdf5). + path: hf://zeahub/picmus/database/simulation/contrast_speckle/contrast_speckle_simu_dataset_iq/contrast_speckle_simu_dataset_iq.hdf5 # true: use local data on this device, false: use data from NAS local: false - # The form of data to load (raw_data, rf_data, iq_data, beamformed_data, - # envelope_data, image, image_sc) - dtype: raw_data - # The dynamic range for showing data in db [min, max] - dynamic_range: [-60, 0] -# The parameters section is a flat mapping of scan/probe/custom parameters that -# overwrite values loaded from the data file. Documented reconstruction -# parameters are listed below; arbitrary custom parameters are also allowed. +# Open mapping of scan/probe/custom parameters that overwrite values loaded from +# the data file. ProbeSpec and ScanSpec are the authoritative sources for valid +# parameter names — see the spec reference in data-acquisition. Arbitrary custom +# parameters are forwarded to the pipeline unchanged. parameters: - # The number of transmits in a frame. Can be 'all' for all transmits, an - # integer for a specific number of transmits selected evenly from the - # transmits in the frame, or a list of integers for specific transmits to - # select from the frame. selected_transmits: all - # The number of pixels in the beamforming grid in the x-direction grid_size_x: 300 - # The number of pixels in the beamforming grid in the z-direction grid_size_z: 500 - # Set to true to apply lens correction in the time-of-flight calculation apply_lens_correction: false - # The speed of sound in the lens in m/s. Usually around 1000 m/s lens_sound_speed: 1000 - # The thickness of the lens in meters lens_thickness: 0.001 - # The limits of the z-axis in the scan in meters (null, [min, max]) zlims: [0.006, 0.055] - # The limits of the x-axis in the scan in meters (null, [min, max]) xlims: [-0.02, 0.02] - # The number of channels in the raw data (1 for rf data, 2 for iq data) n_ch: 2 # This section contains the necessary parameters for building the pipeline. @@ -55,15 +37,5 @@ pipeline: - name: normalize - name: log_compress -# The device to run on ('cpu', 'gpu:0', 'gpu:1', ...) +# The device to run on ('cpu', 'gpu:0', 'gpu:1', 'auto:1', ...) device: auto:1 - -# Settings pertaining to plotting when running the UI (`zea --config -# `) -plot: - # Set to true to save the plots to disk, false to only display them in the UI - save: true - # The plotting library to use (opencv, matplotlib) - plot_lib: matplotlib - # The name for the plot - tag: test diff --git a/configs/config_picmus_rf.yaml b/configs/config_picmus_rf.yaml index e793e6f7e..ffc2a6ae4 100644 --- a/configs/config_picmus_rf.yaml +++ b/configs/config_picmus_rf.yaml @@ -1,45 +1,26 @@ # config_picmus_rf.yaml - comments were autogenerated from PARAMETER_DESCRIPTIONS in zea/config/parameters.py -# The data section contains the parameters for the data. +# Data path and loading settings. data: - # The path of the folder to load data files from (relative to the user data - # root as set in users.yaml) - dataset_folder: hf://zeahub/picmus/database/simulation/contrast_speckle/contrast_speckle_simu_dataset_rf - # The path of the file to load when running the UI (either an absolute path or - # one relative to the dataset folder) - file_path: contrast_speckle_simu_dataset_rf.hdf5 + # Full path to the data file. Supports absolute paths, paths relative to the + # user data root (set in users.yaml), and Hugging Face Hub paths + # (hf://org/repo/path/to/file.hdf5). + path: hf://zeahub/picmus/database/simulation/contrast_speckle/contrast_speckle_simu_dataset_rf/contrast_speckle_simu_dataset_rf.hdf5 # true: use local data on this device, false: use data from NAS local: false - # The form of data to load (raw_data, rf_data, iq_data, beamformed_data, - # envelope_data, image, image_sc) - dtype: raw_data - # The dynamic range for showing data in db [min, max] - dynamic_range: [-50, 0] - -# The parameters section is a flat mapping of scan/probe/custom parameters that -# overwrite values loaded from the data file. Documented reconstruction -# parameters are listed below; arbitrary custom parameters are also allowed. +# Open mapping of scan/probe/custom parameters that overwrite values loaded from +# the data file. ProbeSpec and ScanSpec are the authoritative sources for valid +# parameter names — see the spec reference in data-acquisition. Arbitrary custom +# parameters are forwarded to the pipeline unchanged. parameters: - # The number of transmits in a frame. Can be 'all' for all transmits, an - # integer for a specific number of transmits selected evenly from the - # transmits in the frame, or a list of integers for specific transmits to - # select from the frame. selected_transmits: all - # The number of pixels in the beamforming grid in the x-direction grid_size_x: 400 - # The number of pixels in the beamforming grid in the z-direction grid_size_z: 600 - # The number of channels in the raw data (1 for rf data, 2 for iq data) n_ch: 1 - # Set to true to apply lens correction in the time-of-flight calculation apply_lens_correction: false - # The speed of sound in the lens in m/s. Usually around 1000 m/s lens_sound_speed: 1000 - # The thickness of the lens in meters lens_thickness: 0.001 - # The limits of the z-axis in the scan in meters (null, [min, max]) zlims: [0.006, 0.055] - # The limits of the x-axis in the scan in meters (null, [min, max]) xlims: [-0.02, 0.02] # This section contains the necessary parameters for building the pipeline. @@ -60,15 +41,5 @@ pipeline: - name: normalize - name: log_compress -# The device to run on ('cpu', 'gpu:0', 'gpu:1', ...) +# The device to run on ('cpu', 'gpu:0', 'gpu:1', 'auto:1', ...) device: auto:1 - -# Settings pertaining to plotting when running the UI (`zea --config -# `) -plot: - # Set to true to save the plots to disk, false to only display them in the UI - save: true - # The plotting library to use (opencv, matplotlib) - plot_lib: matplotlib - # The name for the plot - tag: picmus_rf \ No newline at end of file diff --git a/docs/_static/diagrams_dataflow.png b/docs/_static/diagrams_dataflow.png deleted file mode 100644 index 08d185a49db92c34ff09bb6bbbaf77f0101315be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89760 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, ``zea`` also provides a command line interface (CLI). -------------------------------- -File reading and visualization -------------------------------- - -.. autoprogram:: zea.__main__:get_parser() - :prog: zea +.. note:: + The ``zea`` CLI is currently a placeholder. Extended visualization and data + inspection commands will be added in a future release. In the meantime, + use ``python -m zea.data.convert`` and ``python -m zea.data`` below. ------------------------------- Convert datasets diff --git a/docs/source/config.rst b/docs/source/config.rst index 9eda7a529..9a2101e43 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -1,189 +1,155 @@ -.. THIS FILE WAS AUTOGENERATED USING docs/source/parameters_doc.py. DO NOT EDIT MANUALLY. - .. _config: -Config -====== - -This page documents the ``zea`` configuration system. Configs are YAML files that -control data loading, preprocessing, model settings, and scan parameters. +Parameters +========== .. note:: - Configs are used to initialize :doc:`zea.Models ` and the :doc:`pipeline`. - For the data format and file I/O, see :doc:`data-acquisition`. + For the HDF5 data format and file I/O see :doc:`data-acquisition`. + For pipeline operations see :doc:`pipeline`. -.. note:: - The ``parameters`` section is a flat mapping that overrides values loaded from the - data file. It mirrors :class:`zea.Parameters` (the merged :class:`zea.Probe` and scan - parameters — see :doc:`data-acquisition`), and may additionally contain arbitrary - custom parameters that are passed straight through to the pipeline. The documented - reconstruction keys below are the most common ones, but they are not exhaustive. +---------------------------- +Parameters in the file +---------------------------- + +Every ``zea`` HDF5 file stores all parameters needed to process the acquisition +alongside the raw data. They are split into two groups: + +**Probe** (``probe/``) + Fixed for the whole acquisition — element geometry, center frequency, + bandwidth, lens properties. Shared across all tracks. + Defined by :class:`~zea.data.spec.ProbeSpec`. + +**Scan** (``scan/``) + Per-track transmit sequence — delays, apodizations, angles, waveforms, + sound speed. Each track has its own :class:`~zea.data.spec.ScanSpec`. -Configs are written in YAML format and can be loaded, edited, and saved using the ``zea`` API. +See the :ref:`group-reference` table for the complete field listing. -------------------------------- -How to Load and Save a Config -------------------------------- +---------------------------- +zea.Parameters +---------------------------- -Here is a minimal example of how to load and save a config file using zea: +:meth:`~zea.File.load_parameters` merges the probe and scan groups into a +single :class:`~zea.Parameters` object and adds derived quantities +(``wavelength``, ``n_tx``, ``grid``, ``xlims``/``zlims``, ``selected_transmits``): + +.. code-block:: python + + with zea.File("data.hdf5") as f: + parameters = f.load_parameters() # single-track + parameters = f.load_parameters(track=0) # multi-track + +---------------------------- +Config +---------------------------- + +A config is a YAML file (loaded as :class:`~zea.Config`) that specifies where +the data lives, the pipeline to run, the device to use, and any parameter +overrides. .. doctest:: >>> from zea import Config >>> from zea.config import check_config - >>> # Load a config from file >>> config = Config.from_path("../configs/config_picmus_rf.yaml") - >>> # or some predefined from Hugging Face Hub - >>> config = Config.from_path("hf://zeahub/configs/config_picmus_rf.yaml") - - >>> # We can check if the config has valid parameters (zea compliance) - >>> config = check_config(config) - - >>> # Access or change parameters - >>> config.parameters.grid_size_x = 512 - >>> print(config.parameters.grid_size_x) - 512 - - >>> # Save the config back to file - >>> config.to_yaml("my_new_config.yaml") + >>> config = check_config(config) # fills defaults, validates + >>> config.pipeline.operations + ['demodulate', 'downsample', 'beamform', 'envelope_detect', 'normalize', 'log_compress'] + >>> config.to_yaml("my_config.yaml") .. testcleanup:: import os + os.remove("my_config.yaml") - os.remove("my_new_config.yaml") - -------------------------------- -Parameter List -------------------------------- +Supported keys +~~~~~~~~~~~~~~ -Below is a hierarchical list of all configuration parameters, grouped by section. -Descriptions are shown for each parameter. +**data** — where to find the file -.. contents:: - :local: - :depth: 2 +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Key + - Default + - Description + * - ``path`` + - ``null`` + - Full path to the HDF5 file. Supports local absolute paths, paths relative + to the user data root (set in ``users.yaml``), and Hugging Face Hub paths + (``hf://org/repo/path/to/file.hdf5``). + * - ``local`` + - ``true`` + - Whether to use local data (``true``) or a network/NAS location (``false``). + * - ``indices`` + - ``null`` + - Which frames to load: ``null`` (default), ``'all'``, a single ``int``, or + a list of ints. + +**parameters** — override any field from the :ref:`group-reference` or pass +custom keys straight through to the pipeline: + +.. code-block:: yaml + + parameters: + center_frequency: 5.0e6 + xlims: [-0.02, 0.02] + grid_size_x: 512 + +**pipeline** — list of operations (see :doc:`pipeline`): -------------------------------- -Parameters Reference -------------------------------- +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Key + - Default + - Description + * - ``operations`` + - ``[identity]`` + - Ordered list of operations. Each entry is either an operation name (string) + or a mapping with ``name`` and optional ``params``. + * - ``jit_options`` + - ``'ops'`` + - JIT scope: ``'ops'`` (compile each op), ``'pipeline'`` (compile the whole + pipeline), or ``null`` (disable JIT). + * - ``with_batch_dim`` + - ``true`` + - Whether operations expect a leading batch dimension. + +**Top-level keys** .. list-table:: :header-rows: 1 - :widths: 20 80 - - * - **Parameter** - - **Description** - * - ``data`` - - The data section contains the parameters for the data. - * - ``data.apodization`` - - The receive apodization to use. - * - ``data.dataset_folder`` - - The path of the folder to load data files from (relative to the user data root as set in users.yaml) - * - ``data.dtype`` - - The form of data to load (raw_data, rf_data, iq_data, beamformed_data, envelope_data, image, image_sc) - * - ``data.dynamic_range`` - - The dynamic range for showing data in db [min, max] - * - ``data.file_path`` - - The path of the file to load when running the UI (either an absolute path or one relative to the dataset folder) - * - ``data.frame_no`` - - The frame number to load when running the UI (null, int, 'all') - * - ``data.input_range`` - - The range of the input data in db (null, [min, max]) - * - ``data.local`` - - true: use local data on this device, false: use data from NAS - * - ``data.output_range`` - - The output range to which the data should be mapped (e.g. [0, 1]). - * - ``data.resolution`` - - The spatial resolution of the data in meters per pixel (float, optional). - * - ``data.to_dtype`` - - The type of data to convert to (raw_data, aligned_data, beamformed_data, envelope_data, image, image_sc) - * - ``data.user`` - - The user to use when loading data (null, dict) + :widths: 20 15 65 + + * - Key + - Default + - Description * - ``device`` - - The device to run on ('cpu', 'gpu:0', 'gpu:1', ...) - * - ``git`` - - The git commit hash or branch for reproducibility (string, optional). + - ``'auto:1'`` + - Target hardware: ``cpu``, ``gpu``, ``cuda``, ``gpu:0``, ``auto:1`` + (auto-select; ``-1`` for last device). * - ``hide_devices`` - - List of device indices to hide from selection (list of int, optional). - * - ``parameters`` - - The parameters section is a flat mapping of scan/probe/custom parameters that overwrite values loaded from the data file. Documented reconstruction parameters are listed below; arbitrary custom parameters are also allowed. - * - ``parameters.apply_lens_correction`` - - Set to true to apply lens correction in the time-of-flight calculation - * - ``parameters.center_frequency`` - - The center frequency of the transmit pulse in Hz - * - ``parameters.demodulation_frequency`` - - The demodulation frequency of the data in Hz. This is the assumed center frequency of the transmit waveform used to demodulate the rf data to iq data. - * - ``parameters.f_number`` - - The receive f-number for apodization. Set to zero to disable masking. The f-number is the ratio between the distance from the transducer and the size of the aperture. - * - ``parameters.fill_value`` - - Value to fill the image with outside the defined region (float, default 0.0). - * - ``parameters.grid_size_x`` - - The number of pixels in the beamforming grid in the x-direction - * - ``parameters.grid_size_z`` - - The number of pixels in the beamforming grid in the z-direction - * - ``parameters.lens_sound_speed`` - - The speed of sound in the lens in m/s. Usually around 1000 m/s - * - ``parameters.lens_thickness`` - - The thickness of the lens in meters - * - ``parameters.n_ax`` - - The number of samples in a receive recording per channel. - * - ``parameters.n_ch`` - - The number of channels in the raw data (1 for rf data, 2 for iq data) - * - ``parameters.phi_range`` - - The range of phi values in radians for 3D scan conversion (null, [min, max]). - * - ``parameters.resolution`` - - The resolution for scan conversion in meters per pixel (float, optional). - * - ``parameters.rho_range`` - - The range of rho values in meters for scan conversion (null, [min, max]). - * - ``parameters.sampling_frequency`` - - The sampling frequency of the data in Hz - * - ``parameters.selected_transmits`` - - The number of transmits in a frame. Can be 'all' for all transmits, an integer for a specific number of transmits selected evenly from the transmits in the frame, or a list of integers for specific transmits to select from the frame. - * - ``parameters.theta_range`` - - The range of theta values in radians for scan conversion (null, [min, max]). - * - ``parameters.xlims`` - - The limits of the x-axis in the scan in meters (null, [min, max]) - * - ``parameters.ylims`` - - The limits of the y-axis in the scan in meters (null, [min, max]) - * - ``parameters.zlims`` - - The limits of the z-axis in the scan in meters (null, [min, max]) - * - ``pipeline`` - - This section contains the necessary parameters for building the pipeline. - * - ``pipeline.jit_kwargs`` - - Additional keyword arguments for the JIT compiler. Defaults to None. - * - ``pipeline.jit_options`` - - The JIT options to use. Must be 'pipeline', 'ops', or None. 'pipeline' compiles the entire pipeline as a single function. 'ops' compiles each operation separately. None disables JIT compilation. Defaults to 'ops'. - * - ``pipeline.name`` - - The name of the pipeline. Defaults to 'pipeline'. - * - ``pipeline.operations`` - - The operations to perform on the data. This is a list of dictionaries, where each dictionary contains the parameters for a single operation. - * - ``pipeline.validate`` - - Whether to validate the pipeline. Defaults to True. - * - ``pipeline.with_batch_dim`` - - Whether operations should expect a batch dimension in the input. Defaults to True. - * - ``plot`` - - Settings pertaining to plotting when running the UI (`zea --config `) - * - ``plot.fliplr`` - - Set to true to flip the image left to right - * - ``plot.fps`` - - Frames per second for video output. - * - ``plot.headless`` - - Set to true to run the UI in headless mode - * - ``plot.image_extension`` - - The file extension to use when saving the image (png, jpg) - * - ``plot.plot_lib`` - - The plotting library to use (opencv, matplotlib) - * - ``plot.save`` - - Set to true to save the plots to disk, false to only display them in the UI - * - ``plot.selector`` - - Type of selector to use for ROI selection in the UI ('rectangle', 'lasso', or None). - * - ``plot.selector_metric`` - - Metric to use for evaluating selected regions (e.g., 'gcnr'). - * - ``plot.tag`` - - The name for the plot - * - ``plot.video_extension`` - - The file extension to use when saving the video (mp4, gif) - * - ``scan`` - - Deprecated alias for 'parameters'. Supported for backward compatibility; prefer using 'parameters'. + - ``null`` + - Device indices to exclude from auto-selection (int or list of ints). + * - ``git`` + - ``null`` + - Git commit or branch recorded for reproducibility. + +The top-level config is **open**: arbitrary extra sections (``model:``, etc.) +are accepted and passed through unchanged. + +---------------------------- +API reference +---------------------------- + +.. autoclass:: zea.Config + :members: from_path, from_yaml, to_yaml + :undoc-members: + :show-inheritance: + +.. autofunction:: zea.config.check_config + :no-index: diff --git a/docs/source/parameters_doc.py b/docs/source/parameters_doc.py index 7c19bb2e7..eafdd861a 100644 --- a/docs/source/parameters_doc.py +++ b/docs/source/parameters_doc.py @@ -1,6 +1,7 @@ """Automatically generate comments in YAML config files based on parameter descriptions. -Also generates a reStructuredText (RST) file for sphinx documentation. +Run this script to add inline comments to all YAML config files under ``configs/``. +Also checks that PARAMETER_DESCRIPTIONS stays in sync with the ConfigSchema spec. """ import os @@ -8,22 +9,20 @@ import sys from pathlib import Path +# Ensure the workspace version of zea is used when this script is run directly. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + from zea import log from zea.internal.config.parameters import PARAMETER_DESCRIPTIONS -# Keys documented in PARAMETER_DESCRIPTIONS that are deprecated aliases and thus -# intentionally absent from the config spec (see _migrate_legacy_config). -DEPRECATED_KEYS = {"scan"} - AUTOGEN_HEADER_TEMPLATE = "# {filename} - comments were autogenerated from PARAMETER_DESCRIPTIONS in zea/config/parameters.py\n" AUTOGEN_HEADER_KEYWORD = "autogenerated" # Determine if we are running from the docs directory or project root def get_project_paths(): - """Get the project paths for configs and parameters.rst based on the current working directory.""" + """Get the project paths for configs based on the current working directory.""" cwd = Path.cwd().resolve() - # If we're in docs or docs/source, adjust accordingly if cwd.name == "source" and cwd.parent.name == "docs": project_root = cwd.parent.parent elif cwd.name == "docs": @@ -32,19 +31,14 @@ def get_project_paths(): project_root = cwd configs_dir = project_root / "configs" - rst_path = project_root / "docs" / "source" / "config.rst" - # Ensure the configs directory exists if not configs_dir.exists(): raise FileNotFoundError(f"Configs directory does not exist: {configs_dir}") - # Ensure the rst path is valid - if not rst_path.parent.exists(): - raise FileNotFoundError(f"RST path does not exist: {rst_path.parent}") - return configs_dir, rst_path + return configs_dir -CONFIGS_DIRNAME, PARAMETERS_RST_PATH = get_project_paths() +CONFIGS_DIRNAME = get_project_paths() def flatten_dict_keys(d, prefix=""): @@ -92,8 +86,6 @@ def process_yaml_content(lines, descriptions, indent_size=2): """Process YAML content line by line and add comments.""" modified_lines = [] current_keys = [] - data_found = False - plot_found = False for line in lines: if line.strip().startswith("#"): continue @@ -102,17 +94,12 @@ def process_yaml_content(lines, descriptions, indent_size=2): current_keys = current_keys[:indent_level] key = line.split(":")[0].strip() current_keys.append(key) - if key == "data" and indent_level == 0: - data_found = True - if key == "plot" and indent_level == 0: - plot_found = True # Special handling: skip comments for operation entries inside pipeline.operations if ( len(current_keys) >= 3 and current_keys[0] == "pipeline" and current_keys[1] == "operations" ): - # Do not add comments for keys inside operations list modified_lines.append(line) continue description = descriptions @@ -124,17 +111,12 @@ def process_yaml_content(lines, descriptions, indent_size=2): comment_lines = wrap_string_as_comment( description, indent_level, max_line_length=80, indent_size=indent_size ) - # Only add comment if it's not just "# -" if comment_lines.strip() != "# -": modified_lines.append(comment_lines) modified_lines.append(line) else: modified_lines.append(line) - if data_found and plot_found: - return modified_lines - else: - print("data and/or plot key not found. Not adding comments.") - return lines + return modified_lines def add_comments_to_yaml(file_path, descriptions): @@ -159,118 +141,10 @@ def check_parameter_descriptions(descriptions, spec): rst_keys = flatten_dict_keys(descriptions) schema_keys = spec.all_field_paths() missing = sorted(schema_keys - rst_keys) - extra = sorted(rst_keys - schema_keys - DEPRECATED_KEYS) + extra = sorted(rst_keys - schema_keys) return missing, extra -def dict_to_rst_table(param_dict): - """Convert a nested dictionary to a reStructuredText (RST) table.""" - lines = [] - lines.append(".. list-table::") - lines.append(" :header-rows: 1") - lines.append(" :widths: 20 80\n") - lines.append(" * - **Parameter**") - lines.append(" - **Description**") - - def recurse(d, prefix=""): - for k in sorted(d): - if k == "description": - continue - v = d[k] - param_name = f"{prefix}.{k}" if prefix else k - if isinstance(v, dict): - desc = v.get("description", "") - lines.append(f" * - ``{param_name}``\n - {desc}") - recurse(v, param_name) - else: - lines.append(f" * - ``{param_name}``\n - {v}") - - recurse(param_dict) - return "\n".join(lines) - - -def create_parameters_rst(param_dict, rst_path=PARAMETERS_RST_PATH): - """Generate a reStructuredText (RST) file from the parameter dictionary.""" - intro = """.. THIS FILE WAS AUTOGENERATED USING docs/source/parameters_doc.py. DO NOT EDIT MANUALLY. - -.. _config: - -Config -====== - -This page documents the ``zea`` configuration system. Configs are YAML files that -control data loading, preprocessing, model settings, and scan parameters. - -.. note:: - Configs are used to initialize :doc:`zea.Models ` and the :doc:`pipeline`. - For the data format and file I/O, see :doc:`data-acquisition`. - -.. note:: - The ``parameters`` section is a flat mapping that overrides values loaded from the - data file. It mirrors :class:`zea.Parameters` (the merged :class:`zea.Probe` and scan - parameters — see :doc:`data-acquisition`), and may additionally contain arbitrary - custom parameters that are passed straight through to the pipeline. The documented - reconstruction keys below are the most common ones, but they are not exhaustive. - -Configs are written in YAML format and can be loaded, edited, and saved using the ``zea`` API. - -------------------------------- -How to Load and Save a Config -------------------------------- - -Here is a minimal example of how to load and save a config file using zea: - -.. doctest:: - - >>> from zea import Config - >>> from zea.config import check_config - - >>> # Load a config from file - >>> config = Config.from_path("../configs/config_picmus_rf.yaml") - >>> # or some predefined from Hugging Face Hub - >>> config = Config.from_path("hf://zeahub/configs/config_picmus_rf.yaml") - - >>> # We can check if the config has valid parameters (zea compliance) - >>> config = check_config(config) - - >>> # Access or change parameters - >>> config.parameters.grid_size_x = 512 - >>> print(config.parameters.grid_size_x) - 512 - - >>> # Save the config back to file - >>> config.to_yaml("my_new_config.yaml") - -.. testcleanup:: - - import os - - os.remove("my_new_config.yaml") - -------------------------------- -Parameter List -------------------------------- - -Below is a hierarchical list of all configuration parameters, grouped by section. -Descriptions are shown for each parameter. - -.. contents:: - :local: - :depth: 2 - -------------------------------- -Parameters Reference -------------------------------- -""" - table = dict_to_rst_table(param_dict) - with open(rst_path, "w", encoding="utf-8") as f: - f.write(intro) - f.write("\n") - f.write(table) - f.write("\n") - log.info(f"Generated {rst_path} from PARAMETER_DESCRIPTIONS.") - - def update_configs(descriptions, configs_dir=CONFIGS_DIRNAME): """Update YAML config files with comments based on parameter descriptions.""" config_dir = Path(configs_dir) @@ -304,8 +178,5 @@ def update_configs(descriptions, configs_dir=CONFIGS_DIRNAME): else: log.info("All config parameters are documented in PARAMETER_DESCRIPTIONS.") - # 2. Generate config.rst - create_parameters_rst(PARAMETER_DESCRIPTIONS) - - # 3. Update YAML configs with comments + # 2. Update YAML configs with comments update_configs(PARAMETER_DESCRIPTIONS) diff --git a/docs/source/spec_doc.py b/docs/source/spec_doc.py index 48b2ccd84..b7c942f53 100644 --- a/docs/source/spec_doc.py +++ b/docs/source/spec_doc.py @@ -327,6 +327,8 @@ def generate() -> str: # --- Group reference tabs ------------------------------------------------- lines += [ + ".. _group-reference:", + "", "Group reference", "~~~~~~~~~~~~~~~", "", diff --git a/poetry.lock b/poetry.lock index 0961ea95c..ece148dd3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4844,18 +4844,6 @@ files = [ {file = "ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f"}, ] -[[package]] -name = "schema" -version = "0.7.7" -description = "Simple data validation library" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "schema-0.7.7-py2.py3-none-any.whl", hash = "sha256:5d976a5b50f36e74e2157b47097b60002bd4d42e65425fcc9c9befadb4255dde"}, - {file = "schema-0.7.7.tar.gz", hash = "sha256:7da553abd2958a19dc2547c388cde53398b39196175a9be59ea1caf5ab0a1807"}, -] - [[package]] name = "scikit-image" version = "0.25.2" @@ -6603,4 +6591,4 @@ tests = ["cloudpickle", "ipykernel", "ipywidgets", "papermill", "pre-commit", "p [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "79841908effb8e2652ca2ef0e08762a38bf0dc8403835b9a89d1e82d5a123590" +content-hash = "b2322c414b93c57f2836316c56eabe2fc668f2045b63824a41c3df66e415d6fe" diff --git a/pyproject.toml b/pyproject.toml index 20d83d67b..5b88211c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ dependencies = [ "matplotlib >=3.8", "scipy >=1.13", "pillow >=12.2.0", - "schema >=0.7", "tqdm >=4", "pyyaml >=6", "decorator >=5", diff --git a/tests/data/test_dataset.py b/tests/data/test_dataset.py index cec6120a1..5cc476fd8 100644 --- a/tests/data/test_dataset.py +++ b/tests/data/test_dataset.py @@ -62,12 +62,12 @@ ) def test_dataset_indexing(file_idx, idx, expected_shape, dummy_dataset_path): """Test ui initialization function""" - config = {"data": {"dataset_folder": dummy_dataset_path, "dtype": "image"}} + config = {"data": {"path": str(dummy_dataset_path)}} config = check_config(Config(config)) dataset = Dataset.from_config(**config.data) file = dataset[file_idx] - data = file[file.format_key(config.data.dtype)]["values"][idx] + data = file[file.format_key("image")]["values"][idx] assert data.shape == expected_shape, ( f"Data shape {data.shape} does not match expected shape {expected_shape}" diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index 36baab85d..b3c6645c1 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -9,50 +9,48 @@ validate_config, ) -MINIMAL = {"data": {"dtype": "image", "dataset_folder": "some/folder"}} - def test_defaults_are_filled(): """Validation fills in defaults for all optional sections.""" - result = validate_config(MINIMAL) + result = validate_config({}) assert result["device"] == "auto:1" assert result["git"] is None - # Nested sections get their own defaults. - assert result["plot"]["plot_lib"] == "opencv" assert result["pipeline"]["operations"] == ["identity"] - assert result["parameters"]["lens_thickness"] == 1e-3 # data defaults - assert result["data"]["to_dtype"] == "image" - assert result["data"]["dynamic_range"] == [-60, 0] + assert result["data"]["local"] is True + assert result["data"]["path"] is None + assert result["data"]["indices"] is None def test_validation_is_idempotent(): """Validating an already-validated config yields the same dict.""" - once = validate_config(MINIMAL) + once = validate_config({"data": {"path": "hf://zeahub/picmus/file.hdf5", "local": False}}) twice = validate_config(once) assert once == twice -def test_missing_required_section_raises(): - with pytest.raises(ValueError, match="missing required keys"): - validate_config({}) +def test_empty_config_is_valid(): + """An empty config is valid — no required fields in ConfigSchema.""" + result = validate_config({}) + assert result["device"] == "auto:1" + assert result["data"]["local"] is True -def test_missing_required_data_field_raises(): - with pytest.raises(ValueError, match="missing required keys"): - validate_config({"data": {"dtype": "image"}}) +def test_missing_required_data_field_does_not_raise(): + """All data fields are optional — an empty data: section is valid.""" + result = validate_config({"data": {}}) + assert result["data"]["path"] is None + assert result["data"]["local"] is True @pytest.mark.parametrize( "config", [ - {**MINIMAL, "plot": {"plot_lib": "not_a_lib"}}, # enum - {**MINIMAL, "device": "tpu:0"}, # regex/enum - {**MINIMAL, "parameters": {"lens_thickness": -1.0}}, # positive float - {**MINIMAL, "parameters": {"grid_size_x": 0}}, # positive integer - {"data": {"dtype": "not_a_dtype", "dataset_folder": "x"}}, # enum - {**MINIMAL, "data": {**MINIMAL["data"], "dynamic_range": [1, 2, 3]}}, # list len + {"device": "tpu:0"}, # invalid device + {"pipeline": {"jit_options": "bad_option"}}, # enum + {"data": {"local": "yes"}}, # must be bool + {"data": {"indices": {"bad": "type"}}}, # invalid indices type ], ) def test_invalid_values_raise(config): @@ -62,13 +60,13 @@ def test_invalid_values_raise(config): @pytest.mark.parametrize("device", ["cpu", "gpu", "cuda", "cuda:0", "gpu:1", "auto:1", "auto:-1"]) def test_valid_devices(device): - result = validate_config({**MINIMAL, "device": device}) + result = validate_config({"device": device}) assert result["device"] == device def test_arbitrary_parameters_keys_pass_through(): """The parameters section accepts and round-trips arbitrary custom keys.""" - config = {**MINIMAL, "parameters": {"grid_size_x": 128, "my_custom_param": 42}} + config = {"parameters": {"grid_size_x": 128, "my_custom_param": 42}} result = validate_config(config) assert result["parameters"]["grid_size_x"] == 128 assert result["parameters"]["my_custom_param"] == 42 @@ -76,7 +74,7 @@ def test_arbitrary_parameters_keys_pass_through(): def test_arbitrary_top_level_keys_preserved(): """Unknown top-level sections (e.g. model:) are preserved unchanged.""" - config = {**MINIMAL, "model": {"name": "diffusion", "steps": 100}} + config = {"model": {"name": "diffusion", "steps": 100}} result = validate_config(config) assert result["model"] == {"name": "diffusion", "steps": 100} @@ -88,23 +86,44 @@ def test_parameters_config_is_open(): def test_all_field_paths_includes_nested(): paths = ConfigSchema.all_field_paths() - assert "data.dtype" in paths - assert "plot.plot_lib" in paths - assert "parameters.grid_size_x" in paths + assert "data.path" in paths + assert "data.local" in paths + assert "data.indices" in paths assert "pipeline.operations" in paths + assert "pipeline.jit_options" in paths assert "device" in paths + assert "git" in paths + assert "plot.plot_lib" not in paths + assert "data.dtype" not in paths + assert "data.dynamic_range" not in paths def test_scan_alias_migrated_to_parameters(): """The deprecated scan: section is aliased to parameters: on load.""" - migrated = _migrate_legacy_config({**MINIMAL, "scan": {"grid_size_x": 64}}) + migrated = _migrate_legacy_config({"scan": {"grid_size_x": 64}}) assert "scan" not in migrated assert migrated["parameters"] == {"grid_size_x": 64} def test_check_config_freezes_config_object(): - config = Config(MINIMAL) + config = Config({}) checked = check_config(config) assert isinstance(checked, Config) assert checked.__frozen__ is True - assert checked.plot.plot_lib == "opencv" + assert checked.pipeline.operations == ["identity"] + assert checked.data.local is True + + +def test_data_config_local_default(): + """DataConfig local defaults to True even without data: in the config.""" + result = validate_config({}) + assert result["data"]["local"] is True + + +def test_data_config_passthrough_with_full_section(): + """A full data: section validates correctly.""" + config = {"data": {"path": "hf://zeahub/picmus/file.hdf5", "local": False, "indices": "all"}} + result = validate_config(config) + assert result["data"]["path"] == "hf://zeahub/picmus/file.hdf5" + assert result["data"]["local"] is False + assert result["data"]["indices"] == "all" diff --git a/tests/test_configs.py b/tests/test_configs.py index 58abf2037..1d0c7c1ff 100644 --- a/tests/test_configs.py +++ b/tests/test_configs.py @@ -76,6 +76,12 @@ def test_all_configs_valid(file): raise ValueError(f"Error in config {file}") from ve +def test_config_rejects_string_path(): + """Config(path) must raise TypeError — use Config.from_path() instead.""" + with pytest.raises(TypeError, match="Config.from_path"): + Config("configs/config_picmus_rf.yaml") + + def test_dot_indexing(): """Tests if the dot indexing works for simple dictionaries.""" dictionary = {"a": 3, "b": 4} diff --git a/tests/test_interface.py b/tests/test_interface.py deleted file mode 100644 index e5d4cf499..000000000 --- a/tests/test_interface.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Basic testing for interface / generate""" - -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import numpy as np - -from tests.data import generate_example_dataset -from zea.data.file import File -from zea.interface import Interface -from zea.internal.setup_zea import setup_config - -wd = Path(__file__).parent.parent -sys.path.append(str(wd)) - - -def test_interface_initialization(): - """Test interface initialization""" - config = setup_config("hf://zeahub/configs/config_camus.yaml") - - interface = Interface(config) - interface.run(plot=True) - - data = interface.get_data() - assert data is not None - assert isinstance(data, np.ndarray), "Data is not a numpy array" - assert len(data.shape) == 2, "Data must be 2d (grid_size_z, grid_size_x)" - - -def test_interface_reads_map_backed_dataset(tmp_path): - """For map-backed types (e.g. image) the read must descend into the - 'values' sub-dataset rather than indexing the group directly.""" - path = tmp_path / "with_image.hdf5" - generate_example_dataset( - path, - add_optional_dtypes=True, - n_frames=3, - grid_size_z=8, - grid_size_x=8, - image_dtype=np.uint8, - ) - - with File(path) as f: - iface = object.__new__(Interface) - iface.file = f - iface.verbose = False - config = MagicMock() - config.data.dtype = "image" - config.data.frame_no = 0 - iface.config = config - - grp = f[f.format_key("image")] - if hasattr(grp, "keys") and "values" in grp: - data = grp["values"][0] - else: - data = grp[0] - - assert isinstance(data, np.ndarray), "get_data must return ndarray" - assert data.ndim >= 2, "returned frame must be at least 2-D" diff --git a/zea/__init__.py b/zea/__init__.py index b4a824b35..f67e9a7f8 100644 --- a/zea/__init__.py +++ b/zea/__init__.py @@ -31,7 +31,6 @@ from .data.datasets import Dataset, Folder from .data.file import File, load_file from .datapaths import set_data_paths - from .interface import Interface from .internal.device import init_device from .internal.setup_zea import setup, setup_config from .ops import Pipeline @@ -151,7 +150,6 @@ def _check_backend_installed(): "File": ("zea.data.file", "File"), "load_file": ("zea.data.file", "load_file"), "set_data_paths": ("zea.datapaths", "set_data_paths"), - "Interface": ("zea.interface", "Interface"), "init_device": ("zea.internal.device", "init_device"), "setup": ("zea.internal.setup_zea", "setup"), "setup_config": ("zea.internal.setup_zea", "setup_config"), diff --git a/zea/__main__.py b/zea/__main__.py index 4851fdf61..c025f927e 100644 --- a/zea/__main__.py +++ b/zea/__main__.py @@ -1,63 +1,12 @@ -"""Main entry point for zea - -Run as `zea --config path/to/config.yaml` to start the zea interface. -Or do not pass a config file to open a file dialog to choose a config file. +"""Main entry point for zea. +CLI functionality will be added in a future PR. """ -import argparse -import sys -from pathlib import Path - -from zea.visualize import set_mpl_style - - -def get_parser(): - """Command line argument parser""" - parser = argparse.ArgumentParser( - description="Load and process ultrasound data based on a configuration file." - ) - parser.add_argument("-c", "--config", type=str, default=None, help="path to the config file.") - parser.add_argument( - "-t", - "--task", - default="view", - choices=["view"], - type=str, - help="Which task to run. Currently only 'view' is supported.", - ) - parser.add_argument( - "--skip_validate_file", - default=False, - action="store_true", - help="Skip zea file integrity checks. Use with caution.", - ) - return parser - def main(): """main entrypoint for zea""" - args = get_parser().parse_args() - - set_mpl_style() - - wd = Path(__file__).parent.resolve() - sys.path.append(str(wd)) - - from zea.interface import Interface - from zea.internal.setup_zea import setup - - config = setup(args.config) - - if args.task == "view": - cli = Interface( - config, - validate_file=not args.skip_validate_file, - ) - - cli.run(plot=True) - else: - raise ValueError(f"Unknown task {args.task}, see `zea --help` for available tasks.") + print("zea CLI is not yet available. Please use the Python API directly.") if __name__ == "__main__": diff --git a/zea/config.py b/zea/config.py index fda7dca49..56a92049e 100644 --- a/zea/config.py +++ b/zea/config.py @@ -26,11 +26,11 @@ >>> config = Config.from_path("hf://zeahub/configs/config_picmus_rf.yaml") >>> # Access attributes with dot notation - >>> print(config.data.dtype) - raw_data + >>> print(config.data.local) + False >>> # Update recursively - >>> config.update_recursive({"data": {"dtype": "raw_data"}}) + >>> config.update_recursive({"data": {"local": False}}) >>> # Save to YAML >>> config.to_yaml("new_config.yaml") @@ -103,6 +103,11 @@ def __init__(self, dictionary=None, __parent__=None, **kwargs): super().__setattr__("__accessed__", {}) super().__setattr__("__parent__", __parent__) + if isinstance(dictionary, (str, Path)): + raise TypeError( + f"Config() expects a dict, not {type(dictionary).__name__!r}. " + "To load from a file use Config.from_path()." + ) if dictionary is None: dictionary = {} if kwargs: diff --git a/zea/data/convert/verasonics.py b/zea/data/convert/verasonics.py index 823c908dc..881126e42 100644 --- a/zea/data/convert/verasonics.py +++ b/zea/data/convert/verasonics.py @@ -40,6 +40,7 @@ """ # noqa: E501 import os +import re import sys import traceback from pathlib import Path @@ -48,7 +49,6 @@ import numpy as np import yaml from keras import ops -from schema import And, Optional, Or, Regex, Schema from zea import log from zea.data.convert.utils import ( @@ -69,22 +69,60 @@ } -_CONVERT_YAML_SCHEMA = Schema( - { - "files": [ - { - "name": str, - Optional("first_frame"): And(int, lambda x: x >= 0), - Optional("frames"): Or( - "all", - And(str, Regex(r"^\d+(-\d+)?$")), # Matches "30-99" or single number like "5" - [And(int, lambda x: x >= 0)], # List of non-negative integers - ), - Optional("transmits"): Or("all", [And(int, lambda x: x >= 0)]), - } - ] - } -) +_FRAMES_RANGE_RE = re.compile(r"^\d+(-\d+)?$") + + +def _validate_convert_config(data): + """Validate the structure of a convert.yaml config dict. + + Expected shape:: + + files: + - name: + first_frame: = 0> # optional + frames: all | "N" | "N-M" | [N, ...] # optional + transmits: all | [N, ...] # optional + """ + if not isinstance(data, dict) or "files" not in data: + raise ValueError("convert.yaml must have a top-level 'files' key") + if not isinstance(data["files"], list): + raise ValueError("'files' must be a list") + for entry in data["files"]: + if not isinstance(entry, dict): + raise ValueError(f"each entry in 'files' must be a dict, got {type(entry).__name__}") + if not isinstance(entry.get("name"), str): + raise ValueError(f"each file entry must have a string 'name', got {entry!r}") + if "first_frame" in entry: + ff = entry["first_frame"] + if not isinstance(ff, int) or isinstance(ff, bool) or ff < 0: + raise ValueError(f"'first_frame' must be a non-negative int, got {ff!r}") + if "frames" in entry: + fr = entry["frames"] + if not ( + fr == "all" + or (isinstance(fr, str) and _FRAMES_RANGE_RE.fullmatch(fr)) + or ( + isinstance(fr, list) + and all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in fr) + ) + ): + raise ValueError( + f"'frames' must be 'all', a range string like '30-99', or a list of " + f"non-negative ints, got {fr!r}" + ) + if "transmits" in entry: + tr = entry["transmits"] + if not ( + tr == "all" + or ( + isinstance(tr, list) + and all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in tr) + ) + ): + raise ValueError( + f"'transmits' must be 'all' or a list of non-negative ints, got {tr!r}" + ) + return data class VerasonicsFile(h5py.File): @@ -452,7 +490,7 @@ def load_convert_config(self): data = yaml.load(file, Loader=yaml.FullLoader) # Validate the YAML structure - validated_data = _CONVERT_YAML_SCHEMA.validate(data) + validated_data = _validate_convert_config(data) files = validated_data["files"] filenames = [file["name"] for file in files] diff --git a/zea/data/datasets.py b/zea/data/datasets.py index f99de0b50..636e8a149 100644 --- a/zea/data/datasets.py +++ b/zea/data/datasets.py @@ -511,18 +511,11 @@ def find_files(self, paths) -> List[str]: return file_paths @classmethod - def from_config(cls, dataset_folder, user=None, **kwargs): + def from_config(cls, path, user=None, **kwargs): """Creates a Dataset from a config file.""" - path = format_data_path(dataset_folder, user) - - if "file_path" in kwargs: - log.warning( - "Found 'file_path' in config, this will be ignored since a Dataset is " - + "always multiple files." - ) - + resolved = format_data_path(path, user) reduced_params = reduce_to_signature(cls.__init__, kwargs) - return cls(path, **reduced_params) + return cls(resolved, **reduced_params) def __len__(self): """Returns the number of files in the dataset.""" diff --git a/zea/interface.py b/zea/interface.py deleted file mode 100644 index 724e1a418..000000000 --- a/zea/interface.py +++ /dev/null @@ -1,551 +0,0 @@ -"""Convenience interface for loading and displaying ultrasound data. - -Example usage -^^^^^^^^^^^^^^ - -.. doctest:: - - >>> import zea - >>> from zea.internal.setup_zea import setup_config - - >>> config = setup_config("hf://zeahub/configs/config_camus.yaml") - - >>> interface = zea.Interface(config) - >>> interface.run(plot=True) # doctest: +SKIP - -""" - -import asyncio -import time -from pathlib import Path -from typing import List - -import matplotlib -import matplotlib.pyplot as plt -import numpy as np -from PIL import Image - -from zea import log -from zea.config import Config -from zea.data.file import File -from zea.datapaths import format_data_path -from zea.display import to_8bit -from zea.internal.core import DataTypes -from zea.internal.utils import keep_trying -from zea.internal.viewer import ( - ImageViewerMatplotlib, - ImageViewerOpenCV, - filename_from_window_dialog, - running_in_notebook, -) -from zea.io_lib import matplotlib_figure_to_numpy, save_video -from zea.ops import Pipeline - - -class Interface: - """Interface for selecting / loading / processing single ultrasound images. - - Useful for inspecting datasets and single ultrasound images. - - # TODO: maybe we can refactor such that it is clear what needs to be in config. - """ - - def __init__(self, config: Config = None, verbose: bool = True, validate_file: bool = True): - """Initialize Interface. - - Args: - config (Config): Configuration object. - verbose (bool): Whether to print verbose output. - validate_file (bool): Whether to validate the file. - """ - self.config = Config(config) - self.verbose = verbose - - self.file = File(self.file_path) - - if validate_file: - self.file.validate() - - # get probe and parameters from file - self.probe = self.file.probe - self._param_overrides = dict(self.config.get("parameters", {}) or {}) - self.parameters = self.file.load_parameters(**self._param_overrides) - - # initialize Pipeline - assert "pipeline" in self.config, ( - "Pipeline not found in config, please specify pipeline in config." - ) - - self.process = Pipeline.from_config( - self.config, - with_batch_dim=False, - jit_options=None, - ) - self.input_tensors = self.process.prepare_parameters( - self.parameters, **self._param_overrides - ) - - # initialize attributes for UI class - self.data = None - self.image = None - self.mpl_img = None - self.fig = None - self.ax = None - self.gui = None - self.image_viewer = None - - self.plot_lib = self.config.plot.plot_lib - - if self.config.plot.headless is None: - self.headless = False - else: - self.headless = self.config.plot.headless - - self.check_for_display() - - if self.plot_lib == "opencv": - self.image_viewer = ImageViewerOpenCV( - self.data_to_display, - window_name=self.file.name, - num_threads=1, - headless=self.headless, - ) - elif self.plot_lib == "matplotlib": - self.image_viewer = ImageViewerMatplotlib( - self.data_to_display, - window_name=self.file.name, - num_threads=1, - ) - - @property - def dtype(self): - """Data type of data when loaded from file.""" - return self.config.data.dtype - - @property - def dataset_folder(self): - """Path to dataset folder.""" - return format_data_path(self.config.data.dataset_folder, self.config.data.user) - - @property - def file_path(self): - """Path to data file.""" - if self.config.data.file_path: - return self.dataset_folder / self.config.data.file_path - else: - return self.choose_file_path() - - @file_path.setter - def file_path(self, value): - """Set file path to data file.""" - self.config.data.file_path = value - - def choose_file_path(self): - """Choose file path from window dialog.""" - if self.headless: - raise ValueError( - "No file path specified for data file, which is required " - "in headless mode as window dialog cannot be opened." - ) - filetype = "hdf5" - log.info("Please select file from window dialog...") - self.file_path = filename_from_window_dialog( - f"Choose .{filetype} file", - filetypes=((filetype, "*." + filetype),), - initialdir=self.dataset_folder, - ) - return self.file_path - - @property - def data_root(self): - """Root path to data file.""" - return Path(self.config.user.data_root) - - @dtype.setter - def dtype(self, value): - self.config.data.dtype = value - - @property - def to_dtype(self): - """Data type to convert to for display.""" - return self.config.data.to_dtype - - @to_dtype.setter - def to_dtype(self, value): - self.config.data.to_dtype = value - - @property - def frame_no(self): - """Frame number to display.""" - return self.config.data.get("frame_no") - - @frame_no.setter - def frame_no(self, value): - self.config.data.frame_no = value - - def check_for_display(self): - """check if in headless mode (no monitor available)""" - if self.headless is False: - if matplotlib.get_backend().lower() == "agg": - self.headless = True - log.warning("Could not connect to display, running headless.") - else: - # self.plot_lib = "matplotlib" # force matplotlib in headless mode - matplotlib.use("agg") - log.info("Running in headless mode as set by config.") - - def set_backend_for_notebooks(self): - """Set backend to QtAgg if running in notebook""" - if running_in_notebook() and not self.headless: - matplotlib.use("QtAgg") - - def get_data(self): - """Get data. Chosen datafile should be listed in the dataset. - - Using either file specified in config or if None, the ui window. - - Returns: - data (np.ndarray): data array of shape (n_tx, n_el, n_ax, N_ch) - """ - if self.verbose: - log.info(f"Selected {log.yellow(self.file_path)}") - - # grab frame number from config or user input if not set in config - if self.frame_no == "all": - log.info("Will run all frames as `all` was chosen in config...") - elif self.frame_no is None: - if self.file.n_frames == 1: - self.frame_no = 0 - else: - self.frame_no = keep_trying( - lambda: int(input(f">> Frame number (0 / {self.file.n_frames - 1}): ")) - ) - - # get data from dataset - grp = self.file[self.file.format_key(self.dtype)] - if hasattr(grp, "keys") and "values" in grp: - data = grp["values"][self.frame_no] - else: - data = grp[self.frame_no] - - return data - - def data_to_display(self, data=None): - """Get data and convert to display to_dtype.""" - if data is None: - self.data = self.get_data() - else: - self.data = data - - if self.to_dtype not in ["image", "image_sc"]: - log.warning( - f"Image to_dtype: {self.to_dtype} not supported for displaying data." - "falling back to to_dtype: `image_sc`" - ) - self.to_dtype = "image_sc" - - # select transmits if raw or aligned data - data_type = self.process.operations[0].input_data_type - if data_type in [DataTypes.RAW_DATA, DataTypes.ALIGNED_DATA]: - n_tx = self.data.shape[0] - assert len(self.parameters.selected_transmits) <= n_tx, ( - f"Number of selected transmits {len(self.parameters.selected_transmits)} " - f"exceeds number of transmits in raw data {n_tx}" - ) - self.data = np.take(self.data, self.parameters.selected_transmits, axis=0) - - inputs = {self.process.key: self.data} - - outputs = self.process(**inputs, **self.input_tensors) - - self.image = outputs[self.process.output_key] - - # match orientation if necessary - if self.config.plot.fliplr: - self.image = np.fliplr(self.image) - # opencv requires 8 bit images - if self.plot_lib == "opencv": - self.image = to_8bit(self.image, self.config.data.dynamic_range) - return self.image - - def run(self, plot=False, block=True): - """Run ui. Will retrieve, process and plot data if set to True.""" - save = self.config.plot.save - - if self.frame_no == "all": - try: - loop = asyncio.get_running_loop() - loop.create_task(self.run_movie(save)) # already running loop - except RuntimeError: - asyncio.run(self.run_movie(save)) # no loop yet - - else: - if plot: - self.image = self.plot( - save=save, - block=block, - ) - else: - self.image = self.data_to_display() - - return self.image - - def plot( - self, - data: np.ndarray = None, - save: bool = False, - block: bool = True, - ): - """Plot image using matplotlib or opencv. - - Args: - save (bool): whether to save the image to disk. - block (bool): whether to block the UI while plotting. - Returns: - image (np.ndarray): plotted image (grabbed from figure). - """ - assert self.image_viewer is not None, "Image viewer not initialized." - - self.image_viewer.threading = False - - if self.plot_lib == "matplotlib": - if self.image_viewer.fig is None: - self._init_plt_figure() - self.image_viewer.show(data) - if save: - self.save_image(self.fig) - if not self.headless and block: - plt.show(block=True) - self.image = matplotlib_figure_to_numpy(self.fig) - return self.image - - elif self.plot_lib == "opencv": - self.image_viewer.show(data) - if not self.headless and block: - self.image_viewer._cv2.waitKey(0) - self.save_image(self.image) - return self.image - - def _init_plt_figure(self): - figsize = (10, 10) - if self.parameters: - extent = [ - self.parameters.xlims[0] * 1e3, - self.parameters.xlims[1] * 1e3, - self.parameters.zlims[1] * 1e3, - self.parameters.zlims[0] * 1e3, - ] - # set figure aspect ratio to match scan - aspect_ratio = abs(extent[1] - extent[0]) / abs(extent[3] - extent[2]) - figsize = tuple(np.array(figsize) * aspect_ratio) - else: - extent = None - - self.fig, self.ax = plt.subplots(figsize=figsize) - - image_range = self.config.data.dynamic_range - imshow_kwargs = { - "cmap": "gray", - "vmin": image_range[0], - "vmax": image_range[1], - "origin": "upper", - "extent": extent, - "interpolation": "none", - } - cax_kwargs = { - "pad": 0.05, - "position": "right", - "size": "5%", - } - - self.ax.set_xlabel("Lateral Width (mm)", size=15) - self.ax.set_ylabel("Axial length (mm)", size=15) - self.ax.tick_params(axis="x") - self.ax.tick_params(axis="y") - - # assign properties of fig, ax to image viewer - self.image_viewer.imshow_kwargs = imshow_kwargs - self.image_viewer.cax_kwargs = cax_kwargs - self.image_viewer.fig = self.fig - self.image_viewer.ax = self.ax - - async def run_movie(self, save: bool = False): - """Run all frames in file in sequence""" - - log.info('Playing video, press/hold "q" while the window is active to exit...') - self.image_viewer.threading = True - images = await self._movie_loop(save) - - if save: - self.save_video(images) - - async def _movie_loop(self, save: bool = False) -> List[np.ndarray]: - """Process data and plot it in real time. - - NOTE: when plot loop is terminated by user, it will only save the shown frames. - This is to prevent long waiting times when saving a movie (for large datasets). - - Args: - save (bool): Whether to save the plotted images. - - Returns: - list: A list of the plotted images. - """ - # Initialize list of images - images = [] - - # Load correct number of frames (needs to get_data first) - self.frame_no = 0 - self.get_data() - n_frames = self.file.n_frames - - self.verbose = False - try: - while True: - # first frame is already plotted during initialization of plotting - start_time = time.time() - frame_counter = 0 - self.image_viewer.frame_no = 0 - while frame_counter < n_frames: - if self.gui: - await self.gui.check_freeze() - - await asyncio.sleep(0.01) - - self.frame_no = frame_counter - - if frame_counter == 0: - if self.plot_lib == "matplotlib": - if self.image_viewer.fig is None: - self._init_plt_figure() - - self.image_viewer.show() - - # set counter to frame number of image viewer (possibly not updated) - frame_counter = self.image_viewer.frame_no - - # check if frame counter updated - if frame_counter != self.frame_no: - fps = frame_counter / (time.time() - start_time) - print( - f"frame {frame_counter} / {n_frames} ({fps:.2f} fps)", - end="\r", - ) - if save and (len(images) < n_frames): - if self.plot_lib == "matplotlib": - # grab image from plt figure - image = matplotlib_figure_to_numpy(self.fig) - else: - image = np.array(self.image) - images.append(image) - - # For opencv, show frame for 25 ms and check if "q" is pressed - if not self.headless: - if self.plot_lib == "opencv": - if self.image_viewer._cv2.waitKey(25) & 0xFF == ord("q"): - self.image_viewer.close() - return images - if self.image_viewer.has_been_closed(): - return images - # For matplotlib, check if window has been closed - elif self.plot_lib == "matplotlib": - if time.sleep(0.025) and self.image_viewer.has_been_closed(): - return images - # For headless mode, check if all frames have been plotted - if self.headless: - if len(images) == n_frames: - return images - - # clear line, frame number - print("\x1b[2K", end="\r") - - # only loop once if in headless mode - if self.headless: - return images - - except KeyboardInterrupt: - if save: - if len(images) > 0: - self.save_video(images) - raise - - def save_image(self, fig, path=None): - """Save image to disk. - - Args: - fig (fig object): figure. - path (str, optional): path to save image to. Defaults to None. - - """ - if path is None: - if self.config.plot.tag: - tag = "_" + self.config.plot.tag - else: - tag = "" - - if self.frame_no is not None: - filename = self.file_path.stem + "-" + str(self.frame_no) + tag - else: - filename = self.file_path.stem + tag - - ext = f".{self.config.plot.image_extension.lstrip('.')}" - - path = Path("./figures", filename).with_suffix(ext) - Path("./figures").mkdir(parents=True, exist_ok=True) - - if isinstance(fig, plt.Figure): - fig.savefig(path, transparent=True) - elif isinstance(fig, Image.Image): - fig.save(path) - else: - raise ValueError( - f"Figure is not PIL image or matplotlib figure object, got {type(fig)}" - ) - - if self.verbose: - log.info(f"Image saved to {log.yellow(path)}") - - def save_video(self, images, path=None): - """Save video to disk. - - Args: - images (list): list of images. - path (str, optional): path to save image to. Defaults to None. - - """ - if path is None: - if self.config.plot.tag: - tag = "_" + self.config.plot.tag - else: - tag = "" - filename = self.file_path.stem + tag + "." + self.config.plot.video_extension - - path = Path("./figures", filename) - Path("./figures").mkdir(parents=True, exist_ok=True) - - if not isinstance(images[0], np.ndarray): - raise ValueError("Images are not numpy arrays.") - - fps = self.config.plot.fps - - save_video(images, path, fps=fps) - - if self.verbose: - log.info(f"Video saved to {log.yellow(path)}") - - def __del__(self): - try: - if self.image_viewer is not None: - self.image_viewer.close() - except Exception: - pass - try: - if self.fig is not None: - plt.close(self.fig) - except Exception: - pass - try: - if self.file is not None: - self.file.close() - except Exception: - pass diff --git a/zea/internal/config/parameters.py b/zea/internal/config/parameters.py index ba19f80db..c875efc46 100644 --- a/zea/internal/config/parameters.py +++ b/zea/internal/config/parameters.py @@ -1,91 +1,28 @@ """Parameter descriptions for the config file.""" -from zea.internal.config.validation import _ALLOWED_PLOT_LIBS, _DATA_TYPES - - -def allows_type_to_str(allowed_types): - """Transforms a list of allowed types into a string for use in a comment.""" - ouput_str = ", ".join([str(a) if a is not None else "null" for a in allowed_types]) - return ouput_str - - PARAMETER_DESCRIPTIONS = { "data": { - "description": "The data section contains the parameters for the data.", - "dataset_folder": ( - "The path of the folder to load data files from (relative to the user data " - "root as set in users.yaml)" + "description": "Data path and loading settings.", + "path": ( + "Full path to the data file. Supports absolute paths, paths relative to " + "the user data root (set in users.yaml), and Hugging Face Hub paths " + "(hf://org/repo/path/to/file.hdf5)." ), - "to_dtype": (f"The type of data to convert to ({allows_type_to_str(_DATA_TYPES)})"), - "file_path": ( - "The path of the file to load when running the UI (either an absolute path " - "or one relative to the dataset folder)" - ), - "frame_no": "The frame number to load when running the UI (null, int, 'all')", - "input_range": "The range of the input data in db (null, [min, max])", - "apodization": "The receive apodization to use.", - "output_range": ("The output range to which the data should be mapped (e.g. [0, 1])."), - "resolution": ("The spatial resolution of the data in meters per pixel (float, optional)."), "local": "true: use local data on this device, false: use data from NAS", - "dtype": ( - "The form of data to load (raw_data, rf_data, iq_data, beamformed_data, " - "envelope_data, image, image_sc)" + "indices": ( + "Indices into the data to load. null loads the default, 'all' loads every frame, " + "int loads a single frame, list loads specific frames." ), - "dynamic_range": "The dynamic range for showing data in db [min, max]", - "user": "The user to use when loading data (null, dict)", + "user": "User path overrides set automatically by setup_zea (null, dict).", }, "parameters": { "description": ( - "The parameters section is a flat mapping of scan/probe/custom parameters " - "that overwrite values loaded from the data file. Documented reconstruction " - "parameters are listed below; arbitrary custom parameters are also allowed." - ), - "selected_transmits": ( - "The number of transmits in a frame. Can be 'all' for all transmits, an " - "integer for a specific number of transmits selected evenly from the " - "transmits in the frame, or a list of integers for specific transmits to " - "select from the frame." - ), - "grid_size_x": "The number of pixels in the beamforming grid in the x-direction", - "grid_size_z": "The number of pixels in the beamforming grid in the z-direction", - "n_ch": "The number of channels in the raw data (1 for rf data, 2 for iq data)", - "n_ax": "The number of samples in a receive recording per channel.", - "xlims": "The limits of the x-axis in the scan in meters (null, [min, max])", - "ylims": "The limits of the y-axis in the scan in meters (null, [min, max])", - "zlims": "The limits of the z-axis in the scan in meters (null, [min, max])", - "center_frequency": "The center frequency of the transmit pulse in Hz", - "sampling_frequency": "The sampling frequency of the data in Hz", - "demodulation_frequency": ( - "The demodulation frequency of the data in Hz. This is the assumed center " - "frequency of the transmit waveform used to demodulate the rf data to iq " - "data." - ), - "apply_lens_correction": ( - "Set to true to apply lens correction in the time-of-flight calculation" + "Open mapping of scan/probe/custom parameters that overwrite values loaded " + "from the data file. ProbeSpec and ScanSpec are the authoritative sources " + "for valid parameter names — see the spec reference in data-acquisition. " + "Arbitrary custom parameters are forwarded to the pipeline unchanged." ), - "lens_thickness": "The thickness of the lens in meters", - "lens_sound_speed": ("The speed of sound in the lens in m/s. Usually around 1000 m/s"), - "f_number": ( - "The receive f-number for apodization. Set to zero to disable masking. " - "The f-number is the ratio between the distance from the transducer and the " - "size of the aperture." - ), - "fill_value": ( - "Value to fill the image with outside the defined region (float, default 0.0)." - ), - "phi_range": ( - "The range of phi values in radians for 3D scan conversion (null, [min, max])." - ), - "theta_range": ( - "The range of theta values in radians for scan conversion (null, [min, max])." - ), - "rho_range": ("The range of rho values in meters for scan conversion (null, [min, max])."), - "resolution": ("The resolution for scan conversion in meters per pixel (float, optional)."), }, - "scan": ( - "Deprecated alias for 'parameters'. Supported for backward compatibility; " - "prefer using 'parameters'." - ), "pipeline": { "description": "This section contains the necessary parameters for building the pipeline.", "operations": ( @@ -101,29 +38,11 @@ def allows_type_to_str(allowed_types): "'ops' compiles each operation separately. None disables JIT compilation. " "Defaults to 'ops'." ), - "jit_kwargs": ("Additional keyword arguments for the JIT compiler. Defaults to None."), - "name": ("The name of the pipeline. Defaults to 'pipeline'."), - "validate": ("Whether to validate the pipeline. Defaults to True."), - }, - "device": "The device to run on ('cpu', 'gpu:0', 'gpu:1', ...)", - "plot": { - "description": ( - "Settings pertaining to plotting when running the UI " - "(`zea --config `)" - ), - "save": ("Set to true to save the plots to disk, false to only display them in the UI"), - "plot_lib": (f"The plotting library to use ({allows_type_to_str(_ALLOWED_PLOT_LIBS)})"), - "fps": "Frames per second for video output.", - "tag": "The name for the plot", - "fliplr": "Set to true to flip the image left to right", - "image_extension": "The file extension to use when saving the image (png, jpg)", - "video_extension": "The file extension to use when saving the video (mp4, gif)", - "headless": "Set to true to run the UI in headless mode", - "selector": ( - "Type of selector to use for ROI selection in the UI ('rectangle', 'lasso', or None)." - ), - "selector_metric": ("Metric to use for evaluating selected regions (e.g., 'gcnr')."), + "jit_kwargs": "Additional keyword arguments for the JIT compiler. Defaults to None.", + "name": "The name of the pipeline. Defaults to 'pipeline'.", + "validate": "Whether to validate the pipeline. Defaults to True.", }, + "device": "The device to run on ('cpu', 'gpu:0', 'gpu:1', 'auto:1', ...)", "git": "The git commit hash or branch for reproducibility (string, optional).", - "hide_devices": ("List of device indices to hide from selection (list of int, optional)."), + "hide_devices": "List of device indices to hide from selection (list of int, optional).", } diff --git a/zea/internal/config/validation.py b/zea/internal/config/validation.py index 0f78515e5..aefc508df 100644 --- a/zea/internal/config/validation.py +++ b/zea/internal/config/validation.py @@ -13,8 +13,7 @@ The ``parameters`` section and the top-level config are *open*: they accept arbitrary extra keys, which are stored and re-emitted unchanged. This mirrors :class:`zea.Parameters`, which keeps unknown keys as pass-through -``_custom_params``. When adding a documented parameter, add a field (with a -default if optional) to the relevant ``*Config`` dataclass below. +``_custom_params``. """ import re @@ -22,19 +21,11 @@ from pathlib import Path from typing import Any, Callable, ClassVar, Optional, Type -from zea.internal.checks import _DATA_TYPES -from zea.metrics import metrics_registry - -_ALLOWED_PLOT_LIBS = ("opencv", "matplotlib") - - # --------------------------------------------------------------------------- # Validator helpers # # Each validator is a ``Callable[[Any], Any]`` that returns the (possibly -# coerced) value or raises ``ValueError`` with a human-readable message. These -# replace the ``schema`` library primitives (``And`` / ``Or`` / ``Regex`` / -# lambdas) that were previously used. +# coerced) value or raises ``ValueError`` with a human-readable message. # --------------------------------------------------------------------------- @@ -331,64 +322,31 @@ def all_field_paths(cls, prefix: str = "") -> set[str]: @dataclass class DataConfig(ConfigSpec): - """The ``data:`` section: what data to load and how.""" + """The ``data:`` section: data path and loading settings.""" - dtype: Any - dataset_folder: Any - resolution: Any = None - to_dtype: Any = "image" - file_path: Any = None + path: Any = None local: Any = True - frame_no: Any = None - dynamic_range: Any = field(default_factory=lambda: [-60, 0]) - input_range: Any = None - output_range: Any = None - apodization: Any = None + indices: Any = None user: Any = None VALIDATORS: ClassVar[dict] = { - "dtype": enum(*_DATA_TYPES), - "dataset_folder": string, - "resolution": optional(positive_float), - "to_dtype": enum(*_DATA_TYPES), - "file_path": optional(string_or_path), + "path": optional(string_or_path), "local": boolean, - "frame_no": optional(any_of(enum("all"), integer)), - "dynamic_range": list_of_size_two, - "input_range": optional(list_of_size_two), - "output_range": optional(list_of_size_two), - "apodization": optional(string), + "indices": optional(any_of(enum("all"), integer, list_of_positive_integers)), "user": optional(mapping), } @dataclass -class PlotConfig(ConfigSpec): - """The ``plot:`` section: UI / plotting settings.""" - - save: Any = False - plot_lib: Any = "opencv" - fps: Any = 20 - tag: Any = None - headless: Any = False - selector: Any = None - selector_metric: Any = "gcnr" - fliplr: Any = False - image_extension: Any = "png" - video_extension: Any = "gif" +class ParametersConfig(ConfigSpec): + """The ``parameters:`` section — open pass-through for scan/probe/custom parameters. - VALIDATORS: ClassVar[dict] = { - "save": boolean, - "plot_lib": enum(*_ALLOWED_PLOT_LIBS), - "fps": integer, - "tag": optional(string), - "headless": boolean, - "selector": optional(enum("rectangle", "lasso")), - "selector_metric": enum(*metrics_registry.registered_names()), - "fliplr": boolean, - "image_extension": enum("png", "jpg"), - "video_extension": enum("mp4", "gif"), - } + ProbeSpec and ScanSpec are the single source of truth for which parameter + names are valid. Any key listed here overrides the value loaded from the + data file; arbitrary custom keys are forwarded to the pipeline unchanged. + """ + + ALLOW_EXTRA: ClassVar[bool] = True @dataclass @@ -412,75 +370,17 @@ class PipelineConfig(ConfigSpec): } -@dataclass -class ParametersConfig(ConfigSpec): - """The ``parameters:`` section: flat scan/probe/custom parameters. - - This section is *open*: documented reconstruction parameters are validated, - and arbitrary custom keys are accepted and passed through unchanged - (consistent with :class:`zea.Parameters`). - """ - - xlims: Any = None - zlims: Any = None - ylims: Any = None - selected_transmits: Any = None - grid_size_x: Any = None - grid_size_z: Any = None - n_ch: Any = None - n_ax: Any = None - center_frequency: Any = None - sampling_frequency: Any = None - demodulation_frequency: Any = None - f_number: Any = None - apply_lens_correction: Any = False - lens_thickness: Any = 1e-3 - lens_sound_speed: Any = 1000 - theta_range: Any = None - phi_range: Any = None - rho_range: Any = None - fill_value: Any = 0.0 - resolution: Any = None - - ALLOW_EXTRA: ClassVar[bool] = True - VALIDATORS: ClassVar[dict] = { - "xlims": optional(list_of_size_two), - "zlims": optional(list_of_size_two), - "ylims": optional(list_of_size_two), - "selected_transmits": optional( - any_of(positive_integer, list_of_positive_integers, enum("all", "center")) - ), - "grid_size_x": optional(positive_integer), - "grid_size_z": optional(positive_integer), - "n_ch": optional(integer), - "n_ax": optional(integer), - "center_frequency": optional(any_number), - "sampling_frequency": optional(any_number), - "demodulation_frequency": optional(any_number), - "f_number": optional(positive_float), - "apply_lens_correction": boolean, - "lens_thickness": positive_float, - "lens_sound_speed": any_of(positive_float, positive_integer), - "theta_range": optional(list_of_size_two), - "phi_range": optional(list_of_size_two), - "rho_range": optional(list_of_size_two), - "fill_value": any_number, - "resolution": optional(positive_float), - } - - @dataclass class ConfigSchema(ConfigSpec): """The top-level config. - This is *open*: arbitrary extra top-level sections (e.g. ``model:``) are - accepted and passed through unchanged. The deprecated ``scan:`` section is - aliased to ``parameters:`` before validation (see + This is *open*: arbitrary extra top-level sections (e.g. ``data:``, + ``model:``) are accepted and passed through unchanged. The deprecated + ``scan:`` section is aliased to ``parameters:`` before validation (see :func:`zea.config._migrate_legacy_config`). """ - data: Any - plot: Any = None + data: Any = None pipeline: Any = None parameters: Any = None device: Any = "auto:1" @@ -490,7 +390,6 @@ class ConfigSchema(ConfigSpec): ALLOW_EXTRA: ClassVar[bool] = True NESTED: ClassVar[dict] = { "data": DataConfig, - "plot": PlotConfig, "pipeline": PipelineConfig, "parameters": ParametersConfig, }