openads 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of openads might be problematic. Click here for more details.
- openads/__init__.py +77 -0
- openads/__main__.py +8 -0
- openads/_lazy.py +70 -0
- openads/_version.py +5 -0
- openads/assets/__init__.py +162 -0
- openads/assets/fetch.py +157 -0
- openads/assets/manifest.json +545 -0
- openads/assets/manifest.py +105 -0
- openads/cli/__init__.py +5 -0
- openads/cli/main.py +408 -0
- openads/config/__init__.py +32 -0
- openads/config/convert.py +155 -0
- openads/config/defaults/dwi.yaml +123 -0
- openads/config/defaults/keep_files.yaml +132 -0
- openads/config/defaults/pwi.yaml +204 -0
- openads/config/loader.py +254 -0
- openads/config/schema.py +177 -0
- openads/core/__init__.py +23 -0
- openads/core/artifacts.py +192 -0
- openads/core/layout.py +1180 -0
- openads/core/result.py +55 -0
- openads/core/types.py +42 -0
- openads/core/workspace.py +327 -0
- openads/errors.py +106 -0
- openads/inputs/__init__.py +45 -0
- openads/inputs/builtin_derivations.py +208 -0
- openads/inputs/derivations.py +297 -0
- openads/inputs/discovery.py +284 -0
- openads/inputs/sidecars.py +102 -0
- openads/inputs/spec.py +165 -0
- openads/io/__init__.py +26 -0
- openads/io/ants.py +244 -0
- openads/io/cleanup.py +209 -0
- openads/io/figures.py +48 -0
- openads/io/json.py +39 -0
- openads/io/manifest.py +150 -0
- openads/io/nifti.py +224 -0
- openads/io/surfa.py +53 -0
- openads/io/tables.py +58 -0
- openads/io/text.py +25 -0
- openads/io/transforms.py +106 -0
- openads/logging.py +37 -0
- openads/models/__init__.py +19 -0
- openads/models/checkpoints.py +112 -0
- openads/models/dagmnet_dwi.py +679 -0
- openads/models/dagmnet_pwi.py +199 -0
- openads/models/device.py +95 -0
- openads/models/synthstrip.py +300 -0
- openads/ops/__init__.py +83 -0
- openads/ops/adc.py +43 -0
- openads/ops/atlas.py +858 -0
- openads/ops/bimodal.py +84 -0
- openads/ops/hp_symclassic.py +227 -0
- openads/ops/inference_dwi.py +152 -0
- openads/ops/inference_pwi.py +184 -0
- openads/ops/interpret.py +392 -0
- openads/ops/masks.py +97 -0
- openads/ops/metrics.py +122 -0
- openads/ops/morphology.py +190 -0
- openads/ops/normalize.py +289 -0
- openads/ops/padding.py +62 -0
- openads/ops/postprocess.py +75 -0
- openads/ops/postprocess_hp.py +104 -0
- openads/ops/prob_is.py +91 -0
- openads/ops/pseudo_dwi.py +95 -0
- openads/ops/radiology.py +964 -0
- openads/ops/registration.py +63 -0
- openads/ops/ttp/__init__.py +17 -0
- openads/ops/ttp/fallback.py +54 -0
- openads/ops/ttp/fill.py +177 -0
- openads/ops/ttp/gd.py +145 -0
- openads/ops/ttp/motion.py +16 -0
- openads/ops/ttp/peaks.py +96 -0
- openads/ops/ttp/slicetime.py +152 -0
- openads/ops/viz.py +273 -0
- openads/ops/volume_order.py +137 -0
- openads/ops/volumes.py +69 -0
- openads/pipelines/__init__.py +59 -0
- openads/pipelines/base.py +600 -0
- openads/pipelines/combined.py +135 -0
- openads/pipelines/dwi.py +50 -0
- openads/pipelines/pwi.py +53 -0
- openads/py.typed +0 -0
- openads/registry/__init__.py +331 -0
- openads/registry/base.py +97 -0
- openads/stages/__init__.py +261 -0
- openads/stages/base.py +378 -0
- openads/stages/cleanup/__init__.py +55 -0
- openads/stages/cleanup/methods/__init__.py +6 -0
- openads/stages/cleanup/methods/keep_manifest.py +43 -0
- openads/stages/cleanup/params.py +29 -0
- openads/stages/gen_mask/__init__.py +53 -0
- openads/stages/gen_mask/common.py +33 -0
- openads/stages/gen_mask/methods/__init__.py +12 -0
- openads/stages/gen_mask/methods/synthstrip.py +88 -0
- openads/stages/gen_mask/params.py +37 -0
- openads/stages/gen_ttp/__init__.py +55 -0
- openads/stages/gen_ttp/common.py +169 -0
- openads/stages/gen_ttp/methods/__init__.py +13 -0
- openads/stages/gen_ttp/methods/argmin.py +47 -0
- openads/stages/gen_ttp/methods/peak_legacy.py +393 -0
- openads/stages/gen_ttp/moco.py +262 -0
- openads/stages/gen_ttp/params.py +248 -0
- openads/stages/inference/__init__.py +91 -0
- openads/stages/inference/methods/__init__.py +13 -0
- openads/stages/inference/methods/dagmnet_dwi.py +124 -0
- openads/stages/inference/methods/dagmnet_pwi.py +280 -0
- openads/stages/inference/params.py +144 -0
- openads/stages/prepdata/__init__.py +60 -0
- openads/stages/prepdata/methods/__init__.py +13 -0
- openads/stages/prepdata/methods/standard.py +267 -0
- openads/stages/prepdata/params.py +34 -0
- openads/stages/registration/__init__.py +151 -0
- openads/stages/registration/methods/__init__.py +12 -0
- openads/stages/registration/methods/ants_affine_syn.py +337 -0
- openads/stages/registration/params.py +78 -0
- openads/stages/report/__init__.py +107 -0
- openads/stages/report/methods/__init__.py +13 -0
- openads/stages/report/methods/dwi_standard.py +96 -0
- openads/stages/report/methods/pwi_standard.py +64 -0
- openads/stages/report/params.py +103 -0
- openads/stages/report/steps/__init__.py +13 -0
- openads/stages/report/steps/figures.py +230 -0
- openads/stages/report/steps/interpretation.py +83 -0
- openads/stages/report/steps/qfv.py +113 -0
- openads/stages/report/steps/radiology.py +281 -0
- openads/stages/skull_strip/__init__.py +58 -0
- openads/stages/skull_strip/methods/__init__.py +12 -0
- openads/stages/skull_strip/methods/apply_mask.py +86 -0
- openads/stages/skull_strip/params.py +36 -0
- openads/stages/ttpadc_coreg/__init__.py +119 -0
- openads/stages/ttpadc_coreg/methods/__init__.py +12 -0
- openads/stages/ttpadc_coreg/methods/ants_affine_masks.py +157 -0
- openads/stages/ttpadc_coreg/params.py +36 -0
- openads/steps/__init__.py +38 -0
- openads/steps/normalize.py +46 -0
- openads/steps/segmentation_dwi.py +274 -0
- openads/steps/segmentation_pwi.py +285 -0
- openads/steps/warp.py +54 -0
- openads-2.0.0.dist-info/METADATA +342 -0
- openads-2.0.0.dist-info/RECORD +145 -0
- openads-2.0.0.dist-info/WHEEL +5 -0
- openads-2.0.0.dist-info/entry_points.txt +3 -0
- openads-2.0.0.dist-info/licenses/LICENSE +24 -0
- openads-2.0.0.dist-info/top_level.txt +1 -0
openads/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""OpenADS: acute stroke DWI/PWI pipelines.
|
|
2
|
+
|
|
3
|
+
``import openads`` is light: it imports no torch, ants, shap, matplotlib, surfa or
|
|
4
|
+
sklearn. Public names load on first access (PEP 562).
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> import openads
|
|
8
|
+
>>> openads.__version__ # doctest: +SKIP
|
|
9
|
+
'2.0.0'
|
|
10
|
+
>>> res = openads.run_dwi("sub-01", all_stages=True, output_root="out") # doctest: +SKIP
|
|
11
|
+
>>> res = openads.run_stage( # doctest: +SKIP
|
|
12
|
+
... "gen_mask", subject_id="sub-01", modality="DWI", output_root="out"
|
|
13
|
+
... )
|
|
14
|
+
|
|
15
|
+
Subpackages, from the top of the layering down: ``cli`` -> ``pipelines`` ->
|
|
16
|
+
``stages`` -> ``steps`` -> ``ops``/``io``/``models``/``core``; ``config``,
|
|
17
|
+
``inputs``, ``registry`` and ``assets`` support them.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from openads._lazy import attach as _attach
|
|
21
|
+
from openads._version import __version__
|
|
22
|
+
|
|
23
|
+
__getattr__, __dir__, _names = _attach(
|
|
24
|
+
__name__,
|
|
25
|
+
submodules=[
|
|
26
|
+
"assets",
|
|
27
|
+
"cli",
|
|
28
|
+
"config",
|
|
29
|
+
"core",
|
|
30
|
+
"errors",
|
|
31
|
+
"inputs",
|
|
32
|
+
"io",
|
|
33
|
+
"logging",
|
|
34
|
+
"models",
|
|
35
|
+
"ops",
|
|
36
|
+
"pipelines",
|
|
37
|
+
"registry",
|
|
38
|
+
"stages",
|
|
39
|
+
"steps",
|
|
40
|
+
],
|
|
41
|
+
attributes={
|
|
42
|
+
# running
|
|
43
|
+
"run_dwi": "openads.pipelines",
|
|
44
|
+
"run_pwi": "openads.pipelines",
|
|
45
|
+
"run_combined": "openads.pipelines",
|
|
46
|
+
"run_stage": "openads.pipelines",
|
|
47
|
+
"get_pipeline": "openads.pipelines",
|
|
48
|
+
"Pipeline": "openads.pipelines",
|
|
49
|
+
"RunPlan": "openads.pipelines",
|
|
50
|
+
"PipelineResult": "openads.core.result",
|
|
51
|
+
"StageResult": "openads.core.result",
|
|
52
|
+
# configuration and inputs
|
|
53
|
+
"load_config": "openads.config.loader",
|
|
54
|
+
"PipelineConfig": "openads.config.schema",
|
|
55
|
+
"SubjectSpec": "openads.inputs.spec",
|
|
56
|
+
"discover_inputs": "openads.inputs.discovery",
|
|
57
|
+
"example_subjects": "openads.assets",
|
|
58
|
+
"download_assets": "openads.assets",
|
|
59
|
+
# workspace and layout
|
|
60
|
+
"SubjectWorkspace": "openads.core.workspace",
|
|
61
|
+
"OutputLayout": "openads.core.layout",
|
|
62
|
+
"ARTIFACTS": "openads.core.layout",
|
|
63
|
+
"Modality": "openads.core.types",
|
|
64
|
+
# extension
|
|
65
|
+
"register": "openads.registry",
|
|
66
|
+
"Registry": "openads.registry",
|
|
67
|
+
"register_stage": "openads.stages",
|
|
68
|
+
"get_stage": "openads.stages",
|
|
69
|
+
"list_stages": "openads.stages",
|
|
70
|
+
"Stage": "openads.stages.base",
|
|
71
|
+
"StageContext": "openads.stages.base",
|
|
72
|
+
"StageSpec": "openads.stages.base",
|
|
73
|
+
"parse_ref": "openads.stages",
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
__all__ = ["__version__", *_names]
|
openads/__main__.py
ADDED
openads/_lazy.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Helpers for lazy imports.
|
|
2
|
+
|
|
3
|
+
Heavy third-party libraries (torch, ants, surfa, shap, matplotlib, sklearn)
|
|
4
|
+
must never be imported when ``import openads`` runs. Modules use :func:`require`
|
|
5
|
+
inside functions, and packages use :func:`attach` to expose submodules and
|
|
6
|
+
attributes on first access (PEP 562).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = ["attach", "require"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def require(module: str, *, purpose: str = "") -> ModuleType:
|
|
20
|
+
"""Import ``module`` now and return it, with a clear error if it is missing.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
module: Absolute module name, for example ``"torch"``.
|
|
24
|
+
purpose: Short text for the error message, for example ``"SynthStrip"``.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The imported module.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ImportError: If the module cannot be imported.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
return importlib.import_module(module)
|
|
34
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
35
|
+
why = f" (needed for {purpose})" if purpose else ""
|
|
36
|
+
raise ImportError(f"OpenADS needs the package providing '{module}'{why}: {exc}") from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def attach(
|
|
40
|
+
package: str,
|
|
41
|
+
submodules: Iterable[str] = (),
|
|
42
|
+
attributes: Mapping[str, str] | None = None,
|
|
43
|
+
) -> tuple[Callable[[str], Any], Callable[[], list[str]], list[str]]:
|
|
44
|
+
"""Build ``__getattr__``, ``__dir__`` and ``__all__`` for a lazy package.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
package: The package name (``__name__`` of the caller).
|
|
48
|
+
submodules: Submodule names that load on first attribute access.
|
|
49
|
+
attributes: Map of attribute name to the module that defines it
|
|
50
|
+
(relative names start with a dot, for example ``".workspace"``).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A tuple ``(__getattr__, __dir__, __all__)``.
|
|
54
|
+
"""
|
|
55
|
+
subs = set(submodules)
|
|
56
|
+
attrs = dict(attributes or {})
|
|
57
|
+
names = sorted(subs | set(attrs))
|
|
58
|
+
|
|
59
|
+
def __getattr__(name: str) -> Any: # noqa: N807
|
|
60
|
+
if name in subs:
|
|
61
|
+
return importlib.import_module(f"{package}.{name}")
|
|
62
|
+
if name in attrs:
|
|
63
|
+
mod = importlib.import_module(attrs[name], package)
|
|
64
|
+
return getattr(mod, name)
|
|
65
|
+
raise AttributeError(f"module {package!r} has no attribute {name!r}")
|
|
66
|
+
|
|
67
|
+
def __dir__() -> list[str]: # noqa: N807
|
|
68
|
+
return list(names)
|
|
69
|
+
|
|
70
|
+
return __getattr__, __dir__, list(names)
|
openads/_version.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Asset resolution (model weights, atlases, templates, example data).
|
|
2
|
+
|
|
3
|
+
Assets are not part of the wheel (about 320 MB). The assets root is found in
|
|
4
|
+
this order (AMENDMENTS A5); the first candidate that exists wins, and there is
|
|
5
|
+
no per-file fall-through to later candidates:
|
|
6
|
+
|
|
7
|
+
1. the explicit ``assets_dir`` argument (``paths.assets_dir`` in the config),
|
|
8
|
+
2. the ``ADS_ASSETS_DIR`` environment variable,
|
|
9
|
+
3. ``<repo>/assets`` when the package runs from a source checkout,
|
|
10
|
+
4. the user cache ``$XDG_CACHE_HOME/openads/assets`` (default
|
|
11
|
+
``~/.cache/openads/assets``).
|
|
12
|
+
|
|
13
|
+
Nothing is downloaded implicitly. ``openads assets download`` (:func:`download`)
|
|
14
|
+
fetches the manifest's files from a public mirror into the user cache (or
|
|
15
|
+
``ADS_ASSETS_DIR``), verifying each SHA-256; running it accepts the licence it
|
|
16
|
+
prints. Lookups only check that a file exists. SHA-256 verification of a
|
|
17
|
+
folder runs on request (``openads assets verify``).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from openads.assets.manifest import AssetEntry, AssetStatus, load_manifest, verify
|
|
26
|
+
from openads.errors import AssetNotFoundError
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ENV_VAR",
|
|
30
|
+
"AssetEntry",
|
|
31
|
+
"AssetStatus",
|
|
32
|
+
"DownloadReport",
|
|
33
|
+
"assets_root",
|
|
34
|
+
"candidate_roots",
|
|
35
|
+
"download",
|
|
36
|
+
"download_assets",
|
|
37
|
+
"example_subjects",
|
|
38
|
+
"load_manifest",
|
|
39
|
+
"resolve",
|
|
40
|
+
"verify",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
#: Environment variable that points to the assets root.
|
|
44
|
+
ENV_VAR = "ADS_ASSETS_DIR"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _repo_assets() -> Path | None:
|
|
48
|
+
"""``<repo>/assets`` if this package runs from a source checkout."""
|
|
49
|
+
pkg = Path(__file__).resolve().parents[1] # .../src/ads
|
|
50
|
+
repo = pkg.parents[1] # .../<repo>
|
|
51
|
+
if (pkg.parent.name == "src") and (repo / "pyproject.toml").is_file():
|
|
52
|
+
return repo / "assets"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cache_assets() -> Path:
|
|
57
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
58
|
+
return Path(base) / "openads" / "assets"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def candidate_roots(assets_dir: Path | str | None = None) -> list[tuple[str, Path]]:
|
|
62
|
+
"""Candidate assets roots in resolution order, labelled by their source.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
assets_dir: Explicit assets folder.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
``[(source, path), ...]``; sources are ``argument``, ``ADS_ASSETS_DIR``,
|
|
69
|
+
``repository`` and ``user-cache``.
|
|
70
|
+
"""
|
|
71
|
+
out: list[tuple[str, Path]] = []
|
|
72
|
+
if assets_dir is not None:
|
|
73
|
+
out.append(("argument", Path(assets_dir).expanduser()))
|
|
74
|
+
env = os.environ.get(ENV_VAR)
|
|
75
|
+
if env:
|
|
76
|
+
out.append((ENV_VAR, Path(env).expanduser()))
|
|
77
|
+
repo = _repo_assets()
|
|
78
|
+
if repo is not None:
|
|
79
|
+
out.append(("repository", repo))
|
|
80
|
+
out.append(("user-cache", _cache_assets()))
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def assets_root(assets_dir: Path | str | None = None) -> Path:
|
|
85
|
+
"""Return the first existing assets root.
|
|
86
|
+
|
|
87
|
+
An explicit ``assets_dir`` or ``ADS_ASSETS_DIR`` that does not exist is an
|
|
88
|
+
error (it is never skipped silently).
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
AssetNotFoundError: If no candidate exists.
|
|
92
|
+
"""
|
|
93
|
+
tried = []
|
|
94
|
+
for source, path in candidate_roots(assets_dir):
|
|
95
|
+
if path.is_dir():
|
|
96
|
+
return path
|
|
97
|
+
if source in ("argument", ENV_VAR):
|
|
98
|
+
raise AssetNotFoundError(f"assets folder from {source} does not exist: {path}")
|
|
99
|
+
tried.append(f"{source}: {path}")
|
|
100
|
+
raise AssetNotFoundError(
|
|
101
|
+
"no assets folder found; run `openads assets download` (or set ADS_ASSETS_DIR or "
|
|
102
|
+
"paths.assets_dir). Tried: " + "; ".join(tried)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve(rel: Path | str, *, assets_dir: Path | str | None = None) -> Path:
|
|
107
|
+
"""Resolve an asset path.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
rel: Path relative to the assets root (``"models/synthstrip.1.pt"``),
|
|
111
|
+
or an absolute path (returned unchanged if it exists).
|
|
112
|
+
assets_dir: Explicit assets folder (highest priority).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The existing path.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
AssetNotFoundError: If the file or folder does not exist.
|
|
119
|
+
"""
|
|
120
|
+
p = Path(rel).expanduser()
|
|
121
|
+
if p.is_absolute():
|
|
122
|
+
if not p.exists():
|
|
123
|
+
raise AssetNotFoundError(f"asset not found: {p}")
|
|
124
|
+
return p
|
|
125
|
+
root = assets_root(assets_dir)
|
|
126
|
+
full = root / p
|
|
127
|
+
if not full.exists():
|
|
128
|
+
raise AssetNotFoundError(
|
|
129
|
+
f"asset {p.as_posix()!r} not found under {root}; run `openads assets download`"
|
|
130
|
+
)
|
|
131
|
+
return full
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def example_subjects(
|
|
135
|
+
modality: str | None = None, *, assets_dir: Path | str | None = None
|
|
136
|
+
) -> list[Path]:
|
|
137
|
+
"""List the bundled example subject folders (``examples/<mod>/sub-*``).
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
modality: ``"dwi"`` or ``"pwi"`` (case-insensitive); ``None`` lists both.
|
|
141
|
+
assets_dir: Explicit assets folder (highest priority).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Sorted subject folders; empty if the assets have no examples.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: On an unknown modality.
|
|
148
|
+
AssetNotFoundError: If no assets folder exists.
|
|
149
|
+
"""
|
|
150
|
+
mods = ["dwi", "pwi"] if modality is None else [modality.lower()]
|
|
151
|
+
if any(m not in ("dwi", "pwi") for m in mods):
|
|
152
|
+
raise ValueError(f"modality must be 'dwi' or 'pwi', got {modality!r}")
|
|
153
|
+
root = assets_root(assets_dir) / "examples"
|
|
154
|
+
return sorted(
|
|
155
|
+
d for m in mods if (root / m).is_dir() for d in (root / m).iterdir() if d.is_dir()
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
from openads.assets.fetch import DownloadReport, download # noqa: E402 (needs the names above)
|
|
160
|
+
|
|
161
|
+
#: Same as :func:`download`, under the name exported at the package top level.
|
|
162
|
+
download_assets = download
|
openads/assets/fetch.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Download the assets (model weights, atlases, example subjects) of the packaged manifest.
|
|
2
|
+
|
|
3
|
+
The wheel ships code only. ``openads assets download`` (or :func:`download`) fetches the files
|
|
4
|
+
from a public mirror, by default the ``assets/`` folder of the official farialab/OpenADS GitHub
|
|
5
|
+
repository at a fixed commit (:data:`DEFAULT_URL`), verifies each one
|
|
6
|
+
against the manifest's SHA-256 and writes it under the assets root. Nothing is downloaded
|
|
7
|
+
implicitly: running the command is the user's acceptance of the licence in :data:`NOTICE`.
|
|
8
|
+
Files already present with the right digest are skipped, so the command is safe to re-run.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from openads.assets.manifest import AssetEntry, load_manifest, sha256
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"DEFAULT_COMMIT",
|
|
27
|
+
"DEFAULT_URL",
|
|
28
|
+
"NOTICE",
|
|
29
|
+
"URL_ENV_VAR",
|
|
30
|
+
"DownloadReport",
|
|
31
|
+
"default_target",
|
|
32
|
+
"download",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
#: Mirror holding the assets at the same relative paths as the manifest: ``assets/`` of
|
|
36
|
+
#: farialab/OpenADS, pinned to the commit the manifest was made from (6c378d1, OpenADS 1.0), so a
|
|
37
|
+
#: later change on ``main`` cannot make a download fail verification.
|
|
38
|
+
DEFAULT_COMMIT = "6c378d1ae36278c9351c247e00286f19e0a893ac"
|
|
39
|
+
DEFAULT_URL = f"https://raw.githubusercontent.com/farialab/OpenADS/{DEFAULT_COMMIT}/assets"
|
|
40
|
+
#: Environment variable that replaces :data:`DEFAULT_URL` (another mirror, a local copy via ``file://``).
|
|
41
|
+
URL_ENV_VAR = "ADS_ASSETS_URL"
|
|
42
|
+
|
|
43
|
+
NOTICE = """\
|
|
44
|
+
OpenADS model weights, atlases and example data are distributed under the JHU Non-Profit Research
|
|
45
|
+
Software License Agreement (see the LICENSE file of the openads package): non-commercial, non-profit
|
|
46
|
+
research and education only; commercial use, including research by for-profit entities, needs a
|
|
47
|
+
separate written licence from Johns Hopkins University; cite OpenADS in publications.
|
|
48
|
+
By downloading these files you accept this licence."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class DownloadReport:
|
|
53
|
+
"""What :func:`download` did.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
root: Assets root written to.
|
|
57
|
+
downloaded: Paths fetched and verified.
|
|
58
|
+
present: Paths already there with the right digest.
|
|
59
|
+
missing: Paths the mirror does not have (HTTP 404).
|
|
60
|
+
failed: ``(path, reason)`` for other errors, including a digest mismatch.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
root: Path
|
|
64
|
+
downloaded: list[str] = field(default_factory=list)
|
|
65
|
+
present: list[str] = field(default_factory=list)
|
|
66
|
+
missing: list[str] = field(default_factory=list)
|
|
67
|
+
failed: list[tuple[str, str]] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def ok(self) -> bool:
|
|
71
|
+
"""Every requested file is present and verified."""
|
|
72
|
+
return not self.missing and not self.failed
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def default_target() -> Path:
|
|
76
|
+
"""Target folder when none is given.
|
|
77
|
+
|
|
78
|
+
``ADS_ASSETS_DIR`` if set, else the user cache that :func:`openads.assets.assets_root`
|
|
79
|
+
searches last.
|
|
80
|
+
"""
|
|
81
|
+
from openads.assets import ENV_VAR, _cache_assets
|
|
82
|
+
|
|
83
|
+
env = os.environ.get(ENV_VAR)
|
|
84
|
+
return Path(env).expanduser() if env else _cache_assets()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fetch(url: str, dest: Path, entry: AssetEntry) -> None:
|
|
88
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
fd, tmp = tempfile.mkstemp(dir=dest.parent, prefix=".part-")
|
|
90
|
+
try:
|
|
91
|
+
with os.fdopen(fd, "wb") as out, urllib.request.urlopen(url, timeout=60) as r:
|
|
92
|
+
while block := r.read(1 << 20):
|
|
93
|
+
out.write(block)
|
|
94
|
+
got = sha256(Path(tmp))
|
|
95
|
+
if got != entry.sha256:
|
|
96
|
+
raise ValueError(f"SHA-256 mismatch (expected {entry.sha256[:12]}…, got {got[:12]}…)")
|
|
97
|
+
os.replace(tmp, dest)
|
|
98
|
+
finally:
|
|
99
|
+
if os.path.exists(tmp):
|
|
100
|
+
os.unlink(tmp)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def download(
|
|
104
|
+
kinds: Iterable[str] | None = None,
|
|
105
|
+
*,
|
|
106
|
+
assets_dir: Path | str | None = None,
|
|
107
|
+
base_url: str | None = None,
|
|
108
|
+
quiet: bool = False,
|
|
109
|
+
) -> DownloadReport:
|
|
110
|
+
"""Download and verify assets.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
kinds: ``"model"``, ``"atlas"``, ``"example"``; ``None`` downloads all.
|
|
114
|
+
assets_dir: Target assets root (default :func:`default_target`).
|
|
115
|
+
base_url: Mirror URL (default ``$ADS_ASSETS_URL`` or :data:`DEFAULT_URL`).
|
|
116
|
+
quiet: Do not print the licence notice and progress.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A :class:`DownloadReport`; check ``report.ok``.
|
|
120
|
+
"""
|
|
121
|
+
root = Path(assets_dir).expanduser() if assets_dir is not None else default_target()
|
|
122
|
+
base = (base_url or os.environ.get(URL_ENV_VAR) or DEFAULT_URL).rstrip("/")
|
|
123
|
+
wanted = set(kinds) if kinds else None
|
|
124
|
+
entries = [e for e in load_manifest() if wanted is None or e.kind in wanted]
|
|
125
|
+
report = DownloadReport(root)
|
|
126
|
+
say = (lambda *a: None) if quiet else (lambda *a: print(*a, file=sys.stderr, flush=True))
|
|
127
|
+
say(NOTICE)
|
|
128
|
+
say(
|
|
129
|
+
f"\nDownloading {len(entries)} files ({sum(e.size for e in entries) / 1e6:.0f} MB)"
|
|
130
|
+
f" from {base}\ninto {root}"
|
|
131
|
+
)
|
|
132
|
+
for k, e in enumerate(entries, 1):
|
|
133
|
+
dest = root / e.path
|
|
134
|
+
if dest.is_file() and dest.stat().st_size == e.size and sha256(dest) == e.sha256:
|
|
135
|
+
report.present.append(e.path)
|
|
136
|
+
continue
|
|
137
|
+
url = f"{base}/{urllib.parse.quote(e.path)}"
|
|
138
|
+
say(f"[{k}/{len(entries)}] {e.path} ({e.size / 1e6:.1f} MB)")
|
|
139
|
+
try:
|
|
140
|
+
_fetch(url, dest, e)
|
|
141
|
+
report.downloaded.append(e.path)
|
|
142
|
+
except urllib.error.HTTPError as err:
|
|
143
|
+
if err.code == 404:
|
|
144
|
+
report.missing.append(e.path)
|
|
145
|
+
else:
|
|
146
|
+
report.failed.append((e.path, f"HTTP {err.code}"))
|
|
147
|
+
except (OSError, ValueError) as err:
|
|
148
|
+
report.failed.append((e.path, str(err)))
|
|
149
|
+
say(
|
|
150
|
+
f"\n{len(report.downloaded)} downloaded, {len(report.present)} already present, "
|
|
151
|
+
f"{len(report.missing)} not on the mirror, {len(report.failed)} failed"
|
|
152
|
+
)
|
|
153
|
+
for p in report.missing:
|
|
154
|
+
say(f" not on the mirror: {p}")
|
|
155
|
+
for p, why in report.failed:
|
|
156
|
+
say(f" failed: {p}: {why}")
|
|
157
|
+
return report
|