tofscope 0.1.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.
Files changed (68) hide show
  1. tofscope/__init__.py +1 -0
  2. tofscope/api/__init__.py +0 -0
  3. tofscope/api/app.py +549 -0
  4. tofscope/api/auth.py +82 -0
  5. tofscope/api/schemas.py +91 -0
  6. tofscope/application/__init__.py +0 -0
  7. tofscope/application/analyses.py +237 -0
  8. tofscope/application/cache.py +166 -0
  9. tofscope/application/commands.py +153 -0
  10. tofscope/application/explore.py +161 -0
  11. tofscope/application/identification.py +153 -0
  12. tofscope/application/identification_models.py +75 -0
  13. tofscope/application/jobs.py +347 -0
  14. tofscope/application/lifecycle.py +80 -0
  15. tofscope/application/models.py +74 -0
  16. tofscope/application/preparations.py +46 -0
  17. tofscope/application/resources.py +63 -0
  18. tofscope/application/saves.py +76 -0
  19. tofscope/application/signals.py +74 -0
  20. tofscope/application/spatial.py +78 -0
  21. tofscope/cli.py +157 -0
  22. tofscope/config.py +139 -0
  23. tofscope/presentation/__init__.py +0 -0
  24. tofscope/presentation/coordinates.py +31 -0
  25. tofscope/presentation/data.py +54 -0
  26. tofscope/presentation/explore.py +267 -0
  27. tofscope/presentation/exports.py +102 -0
  28. tofscope/presentation/isotopes.py +31 -0
  29. tofscope/presentation/volumes.py +210 -0
  30. tofscope/resources/__init__.py +0 -0
  31. tofscope/resources/reference/README.md +36 -0
  32. tofscope/resources/reference/isotopes.json +36901 -0
  33. tofscope/science/__init__.py +0 -0
  34. tofscope/science/abundance.py +42 -0
  35. tofscope/science/depth_calibration.py +73 -0
  36. tofscope/science/integration.py +149 -0
  37. tofscope/science/isotopes.py +90 -0
  38. tofscope/science/raw_peaks.py +50 -0
  39. tofscope/science/signals.py +114 -0
  40. tofscope/science/volume_layers.py +112 -0
  41. tofscope/static/assets/VtkScene-q-o3MPFv.js +5774 -0
  42. tofscope/static/assets/datasophos-wordmark.png +0 -0
  43. tofscope/static/assets/favicon.svg +5 -0
  44. tofscope/static/assets/index-CeV0NVRS.css +1 -0
  45. tofscope/static/assets/index-djLJCKw_.js +24 -0
  46. tofscope/static/index.html +14 -0
  47. tofscope/static/licenses/matplotlib.txt +99 -0
  48. tofscope/static/manifest.json +13 -0
  49. tofscope/storage/__init__.py +0 -0
  50. tofscope/storage/acquisitions.py +281 -0
  51. tofscope/storage/analysis_document.py +210 -0
  52. tofscope/storage/bounded_events.py +167 -0
  53. tofscope/storage/compact_copy.py +143 -0
  54. tofscope/storage/dataset_io.py +166 -0
  55. tofscope/storage/explorer.py +201 -0
  56. tofscope/storage/file_browser.py +41 -0
  57. tofscope/storage/identification_document.py +47 -0
  58. tofscope/storage/integration_store.py +140 -0
  59. tofscope/storage/native_saved_copy.py +252 -0
  60. tofscope/storage/raw_preparation.py +195 -0
  61. tofscope/storage/saved_files.py +331 -0
  62. tofscope/storage/vendor_export.py +114 -0
  63. tofscope-0.1.0.dist-info/METADATA +14 -0
  64. tofscope-0.1.0.dist-info/RECORD +68 -0
  65. tofscope-0.1.0.dist-info/WHEEL +5 -0
  66. tofscope-0.1.0.dist-info/entry_points.txt +2 -0
  67. tofscope-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. tofscope-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,74 @@
1
+ """Signal identity and view invariants, independent of readers and renderers."""
2
+ from dataclasses import replace
3
+ from tofscope.application.commands import AllDepth, SingleFrame, DepthWindow
4
+
5
+
6
+ def valid_name(value):
7
+ value = value.strip()
8
+ if not 1 <= len(value) <= 128:
9
+ raise ValueError('Signal name must contain 1–128 characters.')
10
+ return value
11
+
12
+
13
+ def get_signal(snapshot, signal_id):
14
+ for signal in snapshot.signals:
15
+ if signal.id == signal_id:
16
+ return signal
17
+ raise ValueError('Signal unavailable.')
18
+
19
+
20
+ def image_signal(snapshot):
21
+ return get_signal(snapshot, snapshot.image_signal_id)
22
+
23
+
24
+ def visible_signals(snapshot):
25
+ ids = set(snapshot.profile_signal_ids) | {snapshot.image_signal_id}
26
+ return tuple(sorted((s for s in snapshot.signals if s.id in ids and s.recipe is not None),
27
+ key=lambda s: (s.id != snapshot.image_signal_id, s.style_index)))
28
+
29
+
30
+ def usable_frames(snapshot, signal):
31
+ if signal.recipe is None:
32
+ return None
33
+ low = max(0, signal.recipe.frames[0])
34
+ high = min(snapshot.shape[0] - 1, signal.recipe.frames[1])
35
+ return (low, high) if low <= high else None
36
+
37
+
38
+ def image_frames(snapshot):
39
+ bounds = usable_frames(snapshot, image_signal(snapshot))
40
+ if bounds is None:
41
+ raise ValueError('Image signal has no coverage in the acquisition.')
42
+ depth = snapshot.depth
43
+ if depth.mode == 'all':
44
+ return bounds
45
+ requested = (depth.index, depth.index) if depth.mode == 'frame' else (depth.first, depth.last)
46
+ low, high = max(bounds[0], requested[0]), min(bounds[1], requested[1])
47
+ return (low, high) if low <= high else (bounds[0], bounds[0])
48
+
49
+
50
+ def normalize_view(snapshot):
51
+ if usable_frames(snapshot, image_signal(snapshot)) is None:
52
+ candidates = [s for s in snapshot.signals if usable_frames(snapshot, s)]
53
+ if not candidates:
54
+ raise ValueError('At least one ready signal must cover the acquisition.')
55
+ snapshot = replace(snapshot, image_signal_id=candidates[0].id)
56
+ low, high = image_frames(snapshot)
57
+ depth = snapshot.depth
58
+ if depth.mode == 'frame':
59
+ depth = SingleFrame(mode='frame', index=low)
60
+ elif depth.mode == 'window':
61
+ depth = DepthWindow(mode='window', first=low, last=high)
62
+ ids = tuple(dict.fromkeys((*snapshot.profile_signal_ids, snapshot.image_signal_id)))
63
+ return replace(snapshot, depth=depth, profile_signal_ids=ids)
64
+
65
+
66
+ def signal_label(snapshot, signal):
67
+ recipe = signal.recipe
68
+ name = signal.name
69
+ if sum(r.name == name for r in snapshot.signals) > 1:
70
+ name += f' [{signal.id[:8]}]'
71
+ if recipe is None:
72
+ return name
73
+ source = 'stored' if recipe.source == 'stored' else 'raw'
74
+ return f'{name} · {source} · frames {recipe.frames[0]}–{recipe.frames[1]}'
@@ -0,0 +1,78 @@
1
+ """Validated, durable spatial display state; numerical recipes remain signals."""
2
+
3
+ from typing import Annotated, Literal
4
+ from pydantic import (
5
+ BaseModel,
6
+ ConfigDict,
7
+ Field,
8
+ StrictBool,
9
+ StrictInt,
10
+ model_validator,
11
+ )
12
+
13
+ Number = Annotated[float, Field(strict=True, allow_inf_nan=False)]
14
+
15
+
16
+ class SpatialModel(BaseModel):
17
+ model_config = ConfigDict(extra="forbid", frozen=True)
18
+
19
+
20
+ class Vector(SpatialModel):
21
+ x: Number
22
+ y: Number
23
+ z: Number
24
+
25
+
26
+ class Camera(SpatialModel):
27
+ eye: Vector = Vector(x=1.25, y=1.25, z=1.25)
28
+ center: Vector = Vector(x=0.0, y=0.0, z=0.0)
29
+ up: Vector = Vector(x=0.0, y=0.0, z=1.0)
30
+ projection: Literal["perspective", "orthographic"] = "perspective"
31
+
32
+ @model_validator(mode="after")
33
+ def nondegenerate(self):
34
+ if sum(v * v for v in (self.up.x, self.up.y, self.up.z)) < 1e-12:
35
+ raise ValueError("Camera up vector must be nonzero.")
36
+ if sum(v * v for v in (self.eye.x, self.eye.y, self.eye.z)) < 1e-12:
37
+ raise ValueError("Camera eye must be nonzero.")
38
+ return self
39
+
40
+
41
+ class SpatialLayer(SpatialModel):
42
+ signal_id: str = Field(min_length=1, max_length=128)
43
+ visible: StrictBool = True
44
+ colorscale: Literal[
45
+ "Viridis", "Magma", "Plasma", "Cividis", "Reds", "Blues", "Greens", "Purples"
46
+ ] = "Viridis"
47
+ colorscale_reversed: StrictBool = False
48
+ opacity: Number = Field(default=0.12, ge=0, le=1)
49
+ percentile: Number = Field(default=70.0, ge=0, le=100)
50
+ camera: Camera = Camera()
51
+
52
+
53
+ class SpatialView(SpatialModel):
54
+ screen: Literal["explore", "volume"] = "explore"
55
+ layers: tuple[SpatialLayer, ...] = Field(default=(), max_length=129)
56
+ mode: Literal["overlay", "tiles"] = "overlay"
57
+ quality: Literal["Fast", "Detailed"] = "Fast"
58
+ scale: Literal["independent", "shared"] = "independent"
59
+ linked: StrictBool = True
60
+ camera: Camera = Camera()
61
+ solo: str | None = Field(default=None, max_length=128)
62
+ page: StrictInt = Field(default=0, ge=0, le=128)
63
+ frame_stretch: Number = Field(default=1.2, ge=0.01, le=1000)
64
+ depth_exaggeration: Number = Field(default=1.0, ge=0.01, le=1000)
65
+
66
+ @model_validator(mode="after")
67
+ def references(self):
68
+ ids = [x.signal_id for x in self.layers]
69
+ if len(ids) != len(set(ids)) or self.solo is not None and self.solo not in ids:
70
+ raise ValueError("Invalid spatial layer references.")
71
+ return self
72
+
73
+
74
+ def validate_spatial(spatial, signals):
75
+ ready = {s.id for s in signals if s.recipe is not None}
76
+ if not {x.signal_id for x in spatial.layers} <= ready:
77
+ raise ValueError("Spatial layers must reference ready named signals.")
78
+ return spatial
tofscope/cli.py ADDED
@@ -0,0 +1,157 @@
1
+ """One Python service/port, loopback by default; Node is build-time only."""
2
+
3
+ import argparse
4
+ from dataclasses import asdict
5
+ import hashlib
6
+ from importlib.resources import files
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import secrets
11
+ import socket
12
+ import sys
13
+ import threading
14
+ import time
15
+ import urllib.request
16
+ import webbrowser
17
+ import uvicorn
18
+ from tofscope import __version__
19
+ from tofscope.api.app import create_app
20
+ from tofscope.config import load_settings
21
+
22
+
23
+ def asset_status():
24
+ root = files("tofscope").joinpath("static")
25
+ manifest = root.joinpath("manifest.json")
26
+ if not manifest.is_file():
27
+ raise ValueError(
28
+ "Frontend assets are missing. Run scripts/build_frontend.py before packaging."
29
+ )
30
+ value = json.loads(manifest.read_text())
31
+ for name, digest in value["assets"].items():
32
+ if hashlib.sha256(root.joinpath(name).read_bytes()).hexdigest() != digest:
33
+ raise ValueError("Frontend assets failed integrity verification.")
34
+ return value
35
+
36
+
37
+ def open_after_ready(url, secret, stop):
38
+ deadline = time.monotonic() + 30
39
+ while not stop.is_set() and time.monotonic() < deadline:
40
+ try:
41
+ with urllib.request.urlopen(url + "/readyz", timeout=0.5) as response:
42
+ if response.status == 200:
43
+ webbrowser.open(url + "/#bootstrap=" + secret)
44
+ return
45
+ except OSError:
46
+ pass
47
+ stop.wait(0.1)
48
+
49
+
50
+ def run_service(settings, browser=False):
51
+ asset_status()
52
+ secret = secrets.token_urlsafe(32)
53
+ url = settings.public_origin or f"http://{settings.host}:{settings.port}"
54
+ # Bind first so occupied ports fail before browser startup or credentials change.
55
+ sock = socket.socket(
56
+ socket.AF_INET6 if ":" in settings.host else socket.AF_INET, socket.SOCK_STREAM
57
+ )
58
+ try:
59
+ sock.bind((settings.host, settings.port))
60
+ sock.listen(128)
61
+ except OSError:
62
+ sock.close()
63
+ raise ValueError("Port is unavailable; select another --port.") from None
64
+ secret_file = settings.runtime_dir / "launch.json"
65
+ stop = threading.Event()
66
+ if settings.mode == "local":
67
+ descriptor = os.open(secret_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
68
+ os.fchmod(descriptor, 0o600)
69
+ with os.fdopen(descriptor, "w") as out:
70
+ json.dump({"url": url, "secret": secret}, out)
71
+ if browser and settings.mode == "local":
72
+ threading.Thread(
73
+ target=open_after_ready, args=(url, secret, stop), daemon=True
74
+ ).start()
75
+ try:
76
+ print(f"ToFScope Explore {__version__}: {url} (original acquisitions read only)", flush=True)
77
+ uvicorn.Server(
78
+ uvicorn.Config(
79
+ create_app(settings, bootstrap_secret=secret),
80
+ host=settings.host,
81
+ port=settings.port,
82
+ workers=1,
83
+ limit_concurrency=64,
84
+ proxy_headers=False,
85
+ access_log=False,
86
+ )
87
+ ).run(sockets=[sock])
88
+ finally:
89
+ stop.set()
90
+ sock.close()
91
+ if settings.mode == "local":
92
+ secret_file.unlink(missing_ok=True)
93
+
94
+
95
+ def main(argv=None):
96
+ p = argparse.ArgumentParser(description="ToFScope Explore; original acquisitions are read only")
97
+ p.add_argument(
98
+ "command", nargs="?", choices=["serve", "doctor", "open"], default="serve"
99
+ )
100
+ p.add_argument("--config", type=Path)
101
+ p.add_argument("--data-root", type=Path)
102
+ p.add_argument("--runtime-dir", type=Path)
103
+ p.add_argument("--lock-dir", type=Path)
104
+ p.add_argument("--output-root", type=Path)
105
+ p.add_argument("--host")
106
+ p.add_argument("--port", type=int)
107
+ p.add_argument("--mode", choices=["local", "proxy"])
108
+ p.add_argument("--no-browser", action="store_true")
109
+ p.add_argument("--version", action="version", version=__version__)
110
+ a = p.parse_args(argv)
111
+ try:
112
+ settings = load_settings(
113
+ a.config,
114
+ cli={
115
+ k: getattr(a, k)
116
+ for k in (
117
+ "data_root",
118
+ "runtime_dir",
119
+ "lock_dir",
120
+ "output_root",
121
+ "host",
122
+ "port",
123
+ "mode",
124
+ )
125
+ },
126
+ )
127
+ if a.command == "doctor":
128
+ assets = asset_status()
129
+ public = asdict(settings)
130
+ public.update(
131
+ version=__version__,
132
+ asset_version=assets["version"],
133
+ scope="Explore-only",
134
+ writes_available=False,
135
+ save_copies_available=True,
136
+ notes="Managed array budget excludes Python/HDF5/browser allocator overhead; one API process.",
137
+ )
138
+ print(json.dumps(public, default=str, indent=2))
139
+ return 0
140
+ if a.command == "open":
141
+ credential = json.loads((settings.runtime_dir / "launch.json").read_text())
142
+ webbrowser.open(credential["url"] + "/#bootstrap=" + credential["secret"])
143
+ return 0
144
+ # An explicit service command is browser-free; the default launch opens a browser.
145
+ explicit_serve = bool(
146
+ (argv if argv is not None else sys.argv[1:])
147
+ and (argv if argv is not None else sys.argv[1:])[0] == "serve"
148
+ )
149
+ run_service(settings, browser=not a.no_browser and not explicit_serve)
150
+ return 0
151
+ except (ValueError, OSError) as exc:
152
+ print(f"ToFScope: {exc}", file=sys.stderr)
153
+ return 2
154
+
155
+
156
+ if __name__ == "__main__":
157
+ raise SystemExit(main())
tofscope/config.py ADDED
@@ -0,0 +1,139 @@
1
+ """Server-owned root, runtime and resource policy. CLI > environment > TOML."""
2
+
3
+ from dataclasses import dataclass, field, fields
4
+ from pathlib import Path
5
+ import os
6
+ import tempfile
7
+ import tomllib
8
+ from typing import Mapping
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Settings:
13
+ data_root: Path = field(default_factory=lambda: Path.cwd())
14
+ runtime_dir: Path = field(
15
+ default_factory=lambda: Path(tempfile.gettempdir()) / f"tofscope-{os.getuid()}"
16
+ )
17
+ lock_dir: Path | None = None
18
+ output_root: Path | None = None
19
+ mode: str = "local"
20
+ host: str = "127.0.0.1"
21
+ port: int = 8765
22
+ allow_writes: bool = False
23
+ allow_unverified_native: bool = False
24
+ initial_file: Path | None = None
25
+ trusted_proxies: tuple[str, ...] = ()
26
+ identity_header: str = "x-tofscope-user"
27
+ public_origin: str | None = None
28
+ global_bytes: int = 4 * 1024**3
29
+ user_bytes: int = 2 * 1024**3
30
+ analysis_bytes: int = 1024**3
31
+ response_bytes: int = 32 * 1024**2
32
+ analyses_per_user: int = 8
33
+ analyses_global: int = 32
34
+ jobs_per_analysis: int = 4
35
+ jobs_per_user: int = 8
36
+ jobs_global: int = 32
37
+ signals_per_analysis: int = 129
38
+ signals_per_user: int = 512
39
+ signals_global: int = 2048
40
+ workers: int = 2
41
+ lease_seconds: int = 45
42
+ reconnect_seconds: int = 120
43
+ idle_seconds: int = 900
44
+
45
+ def __post_init__(self):
46
+ root = Path(self.data_root).expanduser().resolve()
47
+ if not root.is_dir() or not os.access(root, os.R_OK | os.X_OK):
48
+ raise ValueError("Data root must be an accessible directory.")
49
+ object.__setattr__(self, "data_root", root)
50
+ runtime = Path(self.runtime_dir).expanduser().resolve()
51
+ runtime.mkdir(parents=True, exist_ok=True, mode=0o700)
52
+ lock = (
53
+ Path(self.lock_dir).expanduser().resolve()
54
+ if self.lock_dir
55
+ else runtime / "locks"
56
+ )
57
+ lock.mkdir(parents=True, exist_ok=True, mode=0o700)
58
+ if not os.access(runtime, os.W_OK) or not os.access(lock, os.W_OK):
59
+ raise ValueError("Runtime and lock directories must be writable.")
60
+ object.__setattr__(self, "runtime_dir", runtime)
61
+ output = Path(self.output_root).expanduser().resolve() if self.output_root else root
62
+ object.__setattr__(self, "output_root", output)
63
+ object.__setattr__(self, "lock_dir", lock)
64
+ if self.mode not in ("local", "proxy"):
65
+ raise ValueError("Mode must be local or proxy.")
66
+ if self.mode == "local" and self.host not in ("127.0.0.1", "::1", "localhost"):
67
+ raise ValueError("Local mode requires loopback binding.")
68
+ if self.mode == "proxy" and not self.trusted_proxies:
69
+ raise ValueError("Proxy mode requires trusted proxy addresses.")
70
+ for f in fields(self):
71
+ if f.type is bool and type(getattr(self, f.name)) is not bool:
72
+ raise ValueError(f"{f.name} must be true or false.")
73
+ if f.type is int and (
74
+ type(getattr(self, f.name)) is not int or getattr(self, f.name) <= 0
75
+ ):
76
+ raise ValueError(f"{f.name} must be a positive integer.")
77
+ if self.initial_file is not None:
78
+ p = Path(self.initial_file)
79
+ p = p if p.is_absolute() else root / p
80
+ if not p.resolve().is_relative_to(root):
81
+ raise ValueError("Initial acquisition must be inside data root.")
82
+ object.__setattr__(self, "initial_file", p.resolve())
83
+
84
+
85
+ # Storage validators use the policy fields directly; no alternate launcher defaults.
86
+
87
+
88
+ def load_settings(file=None, env: Mapping[str, str] | None = None, cli=None):
89
+ env = os.environ if env is None else env
90
+ values = {}
91
+ if file:
92
+ with Path(file).open("rb") as stream:
93
+ values = tomllib.load(stream).get("tofscope", {})
94
+ for f in fields(Settings):
95
+ key = "TOFSCOPE_" + f.name.upper()
96
+ if key in env:
97
+ v = env[key]
98
+ if f.type is bool:
99
+ if v.lower() not in ("true", "false"):
100
+ raise ValueError(f"{key} must be true or false.")
101
+ v = v.lower() == "true"
102
+ elif f.type is int:
103
+ v = int(v)
104
+ elif f.name == "trusted_proxies":
105
+ v = tuple(x.strip() for x in v.split(",") if x.strip())
106
+ values[f.name] = v
107
+ if "TOF_FILE" in env:
108
+ values["initial_file"] = env["TOF_FILE"]
109
+ values.update({k: v for k, v in (cli or {}).items() if v is not None})
110
+ return Settings(**values)
111
+
112
+
113
+ def resolve_data_path(settings, relative_path, *, require_file=False):
114
+ if not isinstance(relative_path, str) or "\x00" in relative_path:
115
+ raise ValueError("A root-relative path is required.")
116
+ p = Path(relative_path)
117
+ if p.is_absolute() or ".." in p.parts:
118
+ raise ValueError("Path must stay within data root.")
119
+ try:
120
+ target = (settings.data_root / p).resolve()
121
+ except RuntimeError:
122
+ raise ValueError("Invalid symbolic link.") from None
123
+ if not target.is_relative_to(settings.data_root) or not target.exists():
124
+ raise ValueError("Data path is unavailable.")
125
+ if require_file and not target.is_file():
126
+ raise ValueError("Select an acquisition file.")
127
+ return target
128
+
129
+
130
+ def is_private_output_path(settings, path):
131
+ """Keep owner output trees private even when acquisition/output roots coincide."""
132
+ if not path.is_relative_to(settings.output_root):
133
+ return False
134
+ if not settings.data_root.is_relative_to(settings.output_root):
135
+ return True
136
+ parts = path.relative_to(settings.output_root).parts
137
+ # Owner directories use the SHA-256 identity namespace. The shared root and
138
+ # ordinary acquisition folders remain browsable; owner folders never do.
139
+ return bool(parts and len(parts[0]) == 64 and all(c in '0123456789abcdef' for c in parts[0]))
File without changes
@@ -0,0 +1,31 @@
1
+ """Reusable, sample-preserving depth geometry for Explore and later spatial views.
2
+
3
+ Coordinates describe original acquisition samples, never a resampled intensity grid.
4
+ Unknown physical depths are omitted from physical views, but retained by CSV exports.
5
+ Cell boundaries are display footprints, not measurements of plane thickness.
6
+ """
7
+ import numpy as np
8
+ from tofscope.science.depth_calibration import DepthCalibration, convert_depth
9
+
10
+
11
+ def calibration(snapshot):
12
+ return DepthCalibration(snapshot.depth_anchors, snapshot.shape[0]) if len(snapshot.depth_anchors) > 1 else None
13
+
14
+
15
+ def depth_samples(snapshot):
16
+ frames = np.arange(snapshot.shape[0])
17
+ active = calibration(snapshot) if snapshot.depth_coordinates == 'depth' else None
18
+ if active is None:
19
+ return frames, frames.astype(float), 'frame', 0
20
+ depths = active.frame_to_depth(frames)
21
+ known = np.isfinite(depths)
22
+ return frames[known], convert_depth(depths[known], 'nm', snapshot.depth_unit), ('µm' if snapshot.depth_unit == 'um' else snapshot.depth_unit), int((~known).sum())
23
+
24
+
25
+ def block_depths(snapshot, frame_blocks):
26
+ """Mean each block's calibrated samples, including anchor slope changes.
27
+
28
+ A block containing unknown samples is invalid: callers must clip before pooling.
29
+ """
30
+ active = calibration(snapshot)
31
+ return [float(np.mean(active.frame_to_depth(block))) for block in frame_blocks]
@@ -0,0 +1,54 @@
1
+ from dataclasses import dataclass, field, asdict
2
+ from typing import Literal
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Axis:
7
+ name: str
8
+ unit: str
9
+ values: list[float]
10
+ boundaries: list[float]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Curve:
15
+ signal_id: str
16
+ style_index: int
17
+ label: str
18
+ values: list[float | None]
19
+ coverage: list[bool]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Panel:
24
+ kind: Literal["image", "line"]
25
+ content_version: str
26
+ axes: list[Axis]
27
+ values: list[list[float | None]] = field(default_factory=list)
28
+ coverage: list[list[bool]] = field(default_factory=list)
29
+ series: list[Curve] = field(default_factory=list)
30
+ units: str = "intensity"
31
+ aggregation: str = "original"
32
+ intensity_range: tuple[float, float] = (0, 1)
33
+ palette: Literal["ion", "gray", "viridis", "magma", "cividis"] = "viridis"
34
+ log: bool = False
35
+ relative: bool = False
36
+ signal_id: str | None = None
37
+ signal_name: str | None = None
38
+ signal_version: int | None = None
39
+ frame_range: tuple[int, int] | None = None
40
+ acquisition_frames: list[int] = field(default_factory=list)
41
+ omitted_frames: int = 0
42
+ coordinate_kind: Literal['frame', 'depth'] = 'frame'
43
+
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ExploreData:
48
+ analysis_id: str
49
+ revision: int
50
+ dataset_generation: str
51
+ panels: dict[str, Panel]
52
+
53
+ def to_dict(self):
54
+ return asdict(self)