Source code for embodichain.utils.cfg

# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------------

from __future__ import annotations

import functools
import inspect
import logging

from copy import deepcopy
from pathlib import Path
from typing import Dict, List, Union

from fvcore.common.config import BASE_KEY
from fvcore.common.config import CfgNode as _CfgNode
from iopath.common.file_io import PathManager as PathManagerBase
from yacs.config import _VALID_TYPES, _assert_with_logging

sep = "."
prefix = ""
PathManager = PathManagerBase()
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)


def _flatten_dict(
    src: Dict,
    prefix: str | None = prefix,
    sep: str | None = sep,
    dct: Dict | None = {},
) -> Dict:
    """Traverse a dictionary and return all keys including nested ones.

    Args:
        src: Dictionary to flatten.
        prefix: Prefix added to each flattened key.
        sep: Separator between nested key components.
        dct: Reserved compatibility argument.

    Returns:
        Dict: flatten dictionary with all keys.
    """
    items = []
    for k, v in src.items():
        new_key = prefix + sep + k if prefix else k
        if isinstance(v, dict):
            items.extend(_flatten_dict(v, new_key, sep=sep).items())
        else:
            items.append((new_key, v))
    return dict(items)


def _dict_depth(d: Dict | CfgNode) -> int:
    """Calculate the maximal depth of dictionary

    Args:
        d (Dict): an instance of :class:`Dict`.

    Returns:
        int: maximal depth.
    """
    if isinstance(d, dict):
        # 如果d是空dict就直接给0
        return 1 + (max(map(_dict_depth, d.values())) if d else 0)
        # return 1 + (max(map(_dict_depth, d.values())) if d else 0)
    else:
        return 0
        # 无限递归最后肯定不是dict,也就是说肯定会raise error,这是不合理的
        # TypeError("Expected type is dict but {} is received".format(
        #     type(d).__name__))


# NOTE: given the new config system
# (https://detectron2.readthedocs.io/en/latest/tutorials/lazyconfigs.html),
# they will stop adding new functionalities to default CfgNode.


# NOTE: maybe someday one require save config orderly, I have tried and find it not easy.
# there is a method making yaml.load() output ordered dict: https://tendcode.com/article/yaml_order/ ,
# but yacs.config.CfgNode is a subclass of :class:`Dict`, so it may hard to make a dict
# subclass has ordered key when initialize.
[docs] class CfgNode(_CfgNode): # counter records user visits of every attributes and is used in self.unvisited_keys() COUNTER = "__COUNTER__" CACHED_NAMES = "__CACHED_NAMES__"
[docs] def __init__(self, init_dict=None, key_list=None, new_allowed=False): """ Args: init_dict (dict): the possibly-nested dictionary to initailize the CfgNode. key_list (list[str]): a list of names which index this CfgNode from the root. Currently only used for logging purposes. new_allowed (bool): whether adding new key is allowed when merging with other configs. """ super(CfgNode, self).__init__(init_dict) # when self.load_cfg_from_file(), it consequently goes to cls(cfg_as_dict), where `init_dict` is not None # counter dict only contain flattened leaf node of a CfgNode rather than direct child node, # for example, counter dict of node # TOPKEYA: # KEYA: "value1" # KEYB: # SUBKEYA: 1000 # SUBKEYB: 2000 # has key ['TOPKEYA.KEYA', 'TOPKEYA.KEYB.SUBKEYA', 'TOPKEYA.KEYB.SUBKEYB'], but has no 'TOPKEYA' or 'TOPKEA.KEYB', # and the counter dict of node TOPKEYA has key ['KEYA', 'KEYB.SUBKEYA', 'KEYB.SUBKEYB'], but has no 'KEYB'. if init_dict is not None: self.__dict__[CfgNode.COUNTER] = _flatten_dict(init_dict) for key in self.__dict__[CfgNode.COUNTER].keys(): self.__dict__[CfgNode.COUNTER][key] = 0 else: self.__dict__[CfgNode.COUNTER] = {} self.__dict__[CfgNode.CACHED_NAMES] = [] self.set_new_allowed(new_allowed)
def __getattr__(self, name): if name in self: self.__dict__[CfgNode.CACHED_NAMES].append(name) concated_name = sep.join(self.__dict__[CfgNode.CACHED_NAMES]) if concated_name in self.__dict__[CfgNode.COUNTER]: # only parent node of leaf CfgNode can reach here, and top level node can't self.__dict__[CfgNode.COUNTER][concated_name] += 1 self.__dict__[CfgNode.CACHED_NAMES] = [] return self[name] else: raise AttributeError(name) # TODO: overload __setattr__ to use `new_allowed` to avoid user manually add key by `cfg["key"]=value`. # Or is it necessary to do that? Because neither yacs and detectron2 make this feature. # TODO: When adding a new key, COUNTER does not contain an entry for the newly added key @classmethod def _open_cfg(cls, filename): return PathManager.open(filename, "r", encoding="utf-8")
[docs] @classmethod def load_cfg_from_file( cls, filename_or_str_content: str | Path, new_allowed: bool = True, root_path: str | None = None, ) -> CfgNode: """load configration from a yaml file. Modified from function load_yaml_with_base() of fvcore.common.config.CfgNode. The original one do not support `NEW_ALLOWED` key, but I think sometime it will be needed, so we had better add it. Args: filename_or_str_content (Union[str, Path]): a yaml filename or yaml content string new_allowed (bool): whether adding new key is allowed when merging with other configs. root_path (str): Parent directory of `_BASE_` config. Usually _BASE_ is written as a relative path, the result will change if the path executing command change, and we directly use `root_path` as the actual parent directory of `_BASE_` config file to avoid this confusion. Returns: cfg: a :class:`CfgNode` instance. """ is_file = PathManager.isfile(filename_or_str_content) if len(str(filename_or_str_content)) < 256 and str( filename_or_str_content ).endswith(".yaml"): # We assume if input is a yaml file path, it will not longer than 256 # and it should ends with '.yaml' if is_file: with cls._open_cfg(filename_or_str_content) as file: # load_cfg use yaml.safe_load() to prevent malicious code (see https://zhuanlan.zhihu.com/p/54332357); # fvcore supports yaml.unsafe_load(), but I don't see any code use it both in detectron2 and fvcore, # so I think use original load_cfg() in yacs is enough. cfg = cls.load_cfg(file) else: msg = ( f"CfgNode: Input string: '{filename_or_str_content}' looks like" " a yaml file path, but the file is not found on disk!" ) logger.error(msg) raise FileNotFoundError(msg) else: # Otherwise the input is a yaml-format string cfg = cls.load_cfg(filename_or_str_content) if root_path is not None and hasattr(cfg, "_BASE_"): path = Path(root_path) / cfg._BASE_ if not path.exists(): raise ValueError("Path {} does not exist.".format(path)) cfg._BASE_ = str(path) def _load_with_base(base_cfg_file: str) -> CfgNode: if base_cfg_file.startswith("~"): base_cfg_file = Path(base_cfg_file).expanduser() if not any(map(base_cfg_file.startswith, ["/", "https://", "http://"])): if is_file: # the path to base cfg is relative to the config file itself. base_cfg_file = Path(filename_or_str_content).parent / base_cfg_file return cls.load_cfg_from_file(base_cfg_file, new_allowed=new_allowed) if BASE_KEY in cfg: if isinstance(cfg[BASE_KEY], list): base_cfg = cls(new_allowed=new_allowed) base_cfg_files = cfg[BASE_KEY] # NOTE: `new_allowed` of the new added key is default False, so after a "new_allowed" merge new keys from other config, # the new key is not `new_allowed`, which is unreasonable, so we manually update `new_allowed` of merged new keys for base_cfg_file in base_cfg_files: base_cfg.merge_from_other_cfg(_load_with_base(base_cfg_file)) base_cfg.set_new_allowed(new_allowed) else: base_cfg_file = cfg[BASE_KEY] base_cfg = _load_with_base(base_cfg_file) del cfg[BASE_KEY] base_cfg.merge_from_other_cfg(cfg) return base_cfg cfg.set_new_allowed(new_allowed) return cfg
[docs] def merge_from_other_cfg(self, cfg_other): """Merge `cfg_other` into this CfgNode.""" _merge_a_into_b(cfg_other, self, self, []) other_counter = cfg_other.__dict__[CfgNode.COUNTER] self.__dict__[CfgNode.COUNTER] = { **self.__dict__[CfgNode.COUNTER], **other_counter, }
[docs] def dict(self): # NOTE: Without deepcopy, if value is a list, cfg.dict() will use a shallow copy of this list, # then change this list of cfg.dict() will lead to unexpected changeing of original cfg result = {} for key, value in deepcopy(self).items(): if isinstance(value, CfgNode): result[key] = value.dict() else: result[key] = value return result
[docs] def diff(self, other: CfgNode): """Show the difference between self and other `CfgNode`, helping user find Help users quickly identify the difference between them. Args: other (CfgNode): Another `CfgNode`. Returns: DeepDiff: A class containing difference, include adding, deleting and modifing. """ from deepdiff import DeepDiff return DeepDiff(self, other)
[docs] def dump(self, *args, **kwargs): """ At present dump() can only ensure original CfgNode == the one after dump and reload, but can not ensure the order of their keys is consistent. Returns: str: a yaml string representation of the config """ # to make it show up in docs return super().dump(*args, **kwargs)
[docs] def save(self, filepath): with open(filepath, "w", encoding="utf-8") as fp: # set sort_key=False to keep writing order the same as original # input file rather than ordered by alphabetically; # set default_flow_style=None to keep list element written in one line # allow_unicode=True to support Chinese input self.dump( stream=fp, sort_keys=False, default_flow_style=None, allow_unicode=True )
[docs] def depth(self): return _dict_depth(self)
[docs] def unvisited_keys(self, inverse: bool | None = False) -> List[str]: """Return all unvisited keys. Args: inverse (bool | None, optional): return all visited keys if `inverse` is True. Defaults to False. Returns: List[str]: list of all unvisited/visited keys. """ self.__update_counter(self) condition = lambda x: x == 0 if not inverse else x > 0 return [ key for key, value in self.__dict__[CfgNode.COUNTER].items() if condition(value) ]
def __update_counter(self, root: CfgNode, prefix=""): """Internal methods to recursively update counter for each keys. Args: root (CfgNode): Parent node of current CfgNode. prefix (str, optional): Concatenation of parent, grandparent and so on. For root CfgNode `prefix` is "", for a SUBKEY `prefix` may be "TOPKEYA.KEYB". """ for key, kid_node in self.items(): new_key = prefix + sep + key if prefix else key if isinstance(kid_node, dict) and _dict_depth(kid_node) > 0: kid_node.__update_counter(root, new_key) else: # a new_key of value "TOPKEYA.KEYB.SUBKEYA" lead to a1 slice_key # of value "['KEYB.SUBKEYA', 'TOPKEYA.KEYB.SUBKEYA']", which contain all parent keys sliced_keys = [ ".".join(new_key.split(".")[-k:]) for k in range(2, 1 + len(new_key.split("."))) ] # `self` is the father of `key`, and `root` is the father of `self` for root_key in root.__dict__[CfgNode.COUNTER].keys(): matched = any( [sliced_key in root_key for sliced_key in sliced_keys] ) if matched: root.__dict__[CfgNode.COUNTER][root_key] = self.__dict__[ CfgNode.COUNTER ][key]
def _check_and_coerce_cfg_value_type(replacement, original, key, full_key): """Checks that `replacement`, which is intended to replace `original` is of the right type. The type is correct if it matches exactly or is one of a few cases in which the type can be easily coerced. """ original_type = type(original) replacement_type = type(replacement) # The types must match (with some exceptions) if replacement_type == original_type or issubclass(original_type, replacement_type): return replacement # If either of them is None, allow type conversion to one of the valid types if (replacement_type == type(None) and original_type in _VALID_TYPES) or ( original_type == type(None) and replacement_type in _VALID_TYPES ): return replacement # Cast replacement from from_type to to_type if the replacement and original # types match from_type and to_type def conditional_cast(from_type, to_type): if replacement_type == from_type and original_type == to_type: return True, to_type(replacement) else: return False, None # Conditionally casts # list <-> tuple casts = [(tuple, list), (list, tuple)] # For py2: allow converting from str (bytes) to a unicode string try: casts.append((str, unicode)) # noqa: F821 except Exception: pass for from_type, to_type in casts: converted, converted_value = conditional_cast(from_type, to_type) if converted: return converted_value raise ValueError( f"Key type mismatchs during merging config! Key: {full_key}, original: {original} of type {original_type}, new: {replacement} of type {replacement_type}." ) def _merge_a_into_b(a, b, root, key_list): """Merge config dictionary a into config dictionary b, clobbering the options in b whenever they are also specified in a. """ _assert_with_logging( isinstance(a, CfgNode), "`a` (cur type {}) must be an instance of {}".format(type(a), CfgNode), ) _assert_with_logging( isinstance(b, CfgNode), "`b` (cur type {}) must be an instance of {}".format(type(b), CfgNode), ) for k, v_ in a.items(): full_key = ".".join(key_list + [k]) v = deepcopy(v_) v = b._decode_cfg_value(v) if k in b: v = _check_and_coerce_cfg_value_type(v, b[k], k, full_key) # Recursively merge dicts if isinstance(v, CfgNode): try: _merge_a_into_b(v, b[k], root, key_list + [k]) except BaseException: raise else: b[k] = v elif b.is_new_allowed() or isinstance(b, MutableCfgNode): b[k] = v else: if root.key_is_deprecated(full_key): continue elif root.key_is_renamed(full_key): root.raise_key_rename_error(full_key) else: raise KeyError("Non-existent config key: {}".format(full_key))
[docs] class MutableCfgNode(CfgNode):
[docs] def __init__(self, init_dict=None, key_list=None, new_allowed=False): super().__init__(init_dict, key_list, new_allowed) self.set_new_allowed(new_allowed)
def _get_args_from_config(from_config_func, *args, **kwargs): """ Use `from_config` to obtain explicit arguments. Returns: dict: arguments to be used for cls.__init__ """ # inspect.signature() obtains parameter list of function, such as (a, b=0, *c, d, e=1, **f) signature = inspect.signature(from_config_func) # cfg should be passed as the first parameter, whether it is a positional or keyword argument if list(signature.parameters.keys())[0] != "cfg": if inspect.isfunction(from_config_func): name = from_config_func.__name__ else: name = f"{from_config_func.__self__}.from_config" raise TypeError(f"{name} must take 'cfg' as the first argument!") support_var_arg = any( param.kind in [param.VAR_POSITIONAL, param.VAR_KEYWORD] for param in signature.parameters.values() ) if ( support_var_arg ): # forward all arguments to from_config, if from_config accepts them ret = from_config_func(*args, **kwargs) else: # forward supported arguments to from_config supported_arg_names = set(signature.parameters.keys()) extra_kwargs = {} for name in list(kwargs.keys()): if name not in supported_arg_names: extra_kwargs[name] = kwargs.pop(name) ret = from_config_func(*args, **kwargs) # forward the other arguments to __init__ ret.update(extra_kwargs) return ret def _called_with_cfg(*args, **kwargs): """ Returns: bool: whether the arguments contain CfgNode and should be considered forwarded to from_config. """ from omegaconf import DictConfig if len(args) and isinstance(args[0], (_CfgNode, DictConfig)): return True if isinstance(kwargs.pop("cfg", None), (_CfgNode, DictConfig)): return True # `from_config`'s first argument is forced to be "cfg". # So the above check covers all cases. return False
[docs] def configurable(init_func=None, *, from_config=None): """Decorate a callable so it can receive a :class:`CfgNode`. The associated ``from_config`` callable translates a configuration object into explicit keyword arguments. Args: init_func: A class's ``__init__`` method. The class must have a ``from_config`` classmethod that takes ``cfg`` as the first argument. from_config: Translation function for decorated functions or methods. It must take ``cfg`` as its first argument. Examples: Decorate a class constructor that supplies its own translator: .. code-block:: python class A: @configurable def __init__(self, a, b=2): pass @classmethod def from_config(cls, cfg): return {"a": cfg.A, "b": cfg.B} instance = A(cfg) Pass a translator directly when decorating a function: .. code-block:: python @configurable(from_config=lambda cfg: {"a": cfg.A, "b": cfg.B}) def a_func(a, b=2): pass result = a_func(cfg) """ if init_func is not None: assert ( inspect.isfunction(init_func) and from_config is None and init_func.__name__ == "__init__" ), "Incorrect use of @configurable. Check API documentation for examples." @functools.wraps(init_func) def wrapped(self, *args, **kwargs): try: from_config_func = type(self).from_config except AttributeError as e: raise AttributeError( "Class with @configurable must have a 'from_config' classmethod." ) from e if not inspect.ismethod(from_config_func): raise TypeError( "Class with @configurable must have a 'from_config' classmethod." ) if _called_with_cfg(*args, **kwargs): explicit_args = _get_args_from_config(from_config_func, *args, **kwargs) init_func(self, **explicit_args) else: init_func(self, *args, **kwargs) return wrapped else: if from_config is None: return configurable # @configurable() is made equivalent to @configurable assert inspect.isfunction( from_config ), "from_config argument of configurable must be a function!" def wrapper(orig_func): params = inspect.signature(orig_func).parameters if "self" in params or "cls" in params: # classmethod or instancemethod @functools.wraps(orig_func) def wrapped( self, *args, **kwargs ): # here `self` means actual `self` or `cls` if _called_with_cfg(*args, **kwargs): explicit_args = _get_args_from_config( from_config, *args, **kwargs ) return orig_func(self, **explicit_args) else: return orig_func(self, *args, **kwargs) wrapped.from_config = from_config return wrapped else: # function or staticmethod @functools.wraps(orig_func) def wrapped(*args, **kwargs): if _called_with_cfg(*args, **kwargs): explicit_args = _get_args_from_config( from_config, *args, **kwargs ) return orig_func(**explicit_args) else: return orig_func(*args, **kwargs) wrapped.from_config = from_config return wrapped return wrapper