Source code for spacr.custom_features
"""
Custom per-object feature functions.
Users can drop Python files under ``~/.spacr/features/*.py`` that
export functions::
def <name>(mask: np.ndarray, image: np.ndarray, **kwargs) -> float | dict
:func:`discover_features` collects each such function — it has to be
public, defined in the file itself and take at least two parameters —
and :func:`call_feature` invokes one, coercing the result into a
``{column_name: value}`` mapping (``<name>`` for a scalar,
``<name>_<key>`` per key for a dict).
.. note::
This is a standalone API. No part of the measure pipeline calls it
yet, so dropping a file into ``~/.spacr/features/`` does not on its
own add columns to the measurements DB — a caller has to run the
discovery and invocation loop itself.
Example ``~/.spacr/features/asymmetry.py``::
import numpy as np
def asymmetry(mask, image, **_):
ys, xs = np.where(mask > 0)
if len(xs) < 5:
return 0.0
# Something the built-ins don't compute
return float(np.std(xs) / (np.std(ys) + 1e-9))
Errors are logged and swallowed: a feature that raises yields
an empty mapping, so one bad function cannot break the caller's loop.
Public API::
from spacr.custom_features import (
features_dir, discover_features, call_feature,
)
"""
from __future__ import annotations
import importlib.util
import inspect
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List
from .logging_util import _spacr_home
LOG = logging.getLogger("spacr.custom_features")
[docs]
def features_dir() -> Path:
"""Return ``~/.spacr/features/`` — created if it doesn't exist."""
p = _spacr_home() / "features"
p.mkdir(parents=True, exist_ok=True)
return p
@dataclass
[docs]
class CustomFeature:
"""One discovered feature function.
:param name: public function name discovered in the user module;
:func:`call_feature` uses it as the scalar output key or as the
prefix in ``<name>_<returned_key>``.
:param source: path of the user ``.py`` module that defined ``fn``,
retained as the feature's origin metadata.
:param fn: discovered callable invoked by :func:`call_feature` as
``fn(mask, image, **kwargs)``. Exceptions are logged under ``name``
and produce an empty result.
"""
name: str
source: Path
fn: Callable[..., Any]
[docs]
def discover_features() -> List[CustomFeature]:
"""Walk :func:`features_dir` and return every public callable found.
Silently skips files that fail to import — the offending path is
logged at INFO and users see a warning in the Console.
"""
hits: List[CustomFeature] = []
for py in sorted(features_dir().glob("*.py")):
if py.name.startswith("_"):
continue
try:
spec = importlib.util.spec_from_file_location(
f"spacr._userfeat_{py.stem}", py,
)
if spec is None or spec.loader is None:
continue
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
except Exception as e:
LOG.info("skipping %s (import failed: %s)", py, e)
continue
for name in dir(mod):
if name.startswith("_"):
continue
fn = getattr(mod, name)
if not callable(fn):
continue
try:
if fn.__module__ != mod.__name__:
continue
except Exception:
continue
try:
sig = inspect.signature(fn)
params = list(sig.parameters.values())
if len(params) < 2:
continue
except Exception:
continue
hits.append(CustomFeature(name=name, source=py, fn=fn))
LOG.info("discovered %d custom feature(s) under %s",
len(hits), features_dir())
return hits
[docs]
def call_feature(cf: CustomFeature, mask, image,
**kwargs) -> Dict[str, Any]:
"""Invoke a custom feature safely, coercing the result to a
``{column_name: value}`` dict.
:param cf: discovered feature.
:param mask: 2-D uint16 mask (background 0).
:param image: 2-D uint16 image aligned with ``mask``.
:returns: mapping of DB column name → scalar. Empty dict on
exception.
"""
try:
result = cf.fn(mask, image, **kwargs)
except Exception as e:
LOG.info("custom feature %s raised: %s", cf.name, e)
return {}
if isinstance(result, dict):
return {f"{cf.name}_{k}": v for k, v in result.items()}
return {cf.name: result}