pluot_core 0.0.1__tar.gz
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.
- pluot_core-0.0.1/.gitignore +71 -0
- pluot_core-0.0.1/PKG-INFO +14 -0
- pluot_core-0.0.1/README.md +1 -0
- pluot_core-0.0.1/pyproject.toml +22 -0
- pluot_core-0.0.1/src/pluot_core/__init__.py +0 -0
- pluot_core-0.0.1/src/pluot_core/font.py +138 -0
- pluot_core-0.0.1/src/pluot_core/sync_store.py +68 -0
- pluot_core-0.0.1/src/pluot_core/viewport.py +133 -0
- pluot_core-0.0.1/src/pluot_core/zarr.py +121 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/target
|
|
2
|
+
target/
|
|
3
|
+
**/*.rs.bk
|
|
4
|
+
Cargo.lock
|
|
5
|
+
bin/
|
|
6
|
+
pkg/
|
|
7
|
+
wasm-pack.log
|
|
8
|
+
.DS_Store
|
|
9
|
+
node_modules/
|
|
10
|
+
|
|
11
|
+
crates/LICENSE
|
|
12
|
+
dist/
|
|
13
|
+
dist-tsc/
|
|
14
|
+
tsconfig.tsbuildinfo
|
|
15
|
+
data/out/
|
|
16
|
+
|
|
17
|
+
zarrs/
|
|
18
|
+
|
|
19
|
+
__pycache__/
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
.ipynb_checkpoints/
|
|
22
|
+
*.so
|
|
23
|
+
.venv/
|
|
24
|
+
.ropeproject/
|
|
25
|
+
__marimo__/
|
|
26
|
+
bindings-python/pluot_widget/src/pluot_widget/static/
|
|
27
|
+
.Rproj.user
|
|
28
|
+
|
|
29
|
+
.astro/
|
|
30
|
+
npm-debug.log*
|
|
31
|
+
yarn-debug.log*
|
|
32
|
+
yarn-error.log*
|
|
33
|
+
pnpm-debug.log*
|
|
34
|
+
.env
|
|
35
|
+
.env.production
|
|
36
|
+
|
|
37
|
+
# Snapshot test outputs (reference snapshots are in tests/snapshots/)
|
|
38
|
+
current/
|
|
39
|
+
snaps-dirty/
|
|
40
|
+
|
|
41
|
+
skills-lock.json
|
|
42
|
+
.claude/settings.local.json
|
|
43
|
+
.claude/scheduled_tasks.lock
|
|
44
|
+
.claude/skills/autoresearch
|
|
45
|
+
.agents/
|
|
46
|
+
|
|
47
|
+
# R stuff
|
|
48
|
+
.Rhistory
|
|
49
|
+
.Rproj.user
|
|
50
|
+
*.o
|
|
51
|
+
AUTHORS
|
|
52
|
+
*.tar.gz
|
|
53
|
+
*.Rcheck
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
autoresearch/
|
|
57
|
+
circle_diffs/
|
|
58
|
+
text_diffs/
|
|
59
|
+
|
|
60
|
+
crates/pluot_core/src/vendored-fonts/*.ttf
|
|
61
|
+
bindings-js/core/src/vendored-fonts/*.ttf.js
|
|
62
|
+
bindings-r/src/.cargo/
|
|
63
|
+
|
|
64
|
+
.pluot_integration_test_*.sh
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
bindings-js/**/CHANGELOG.md
|
|
68
|
+
crates/**/CHANGELOG.md
|
|
69
|
+
RELEASE_NOTES.md
|
|
70
|
+
|
|
71
|
+
references/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pluot_core
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Shared utilities used by the pluot and pluot_widget packages
|
|
5
|
+
Project-URL: repository, https://github.com/keller-mark/pluot
|
|
6
|
+
Author: Mark Keller
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: numpy>=2
|
|
10
|
+
Requires-Dist: obstore
|
|
11
|
+
Requires-Dist: zarr>=3
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
This pluot_core package will contain all shared functionality that is used by the pluot and pluot_widget packages, such as zarr and font stuff and viewport utilities.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This pluot_core package will contain all shared functionality that is used by the pluot and pluot_widget packages, such as zarr and font stuff and viewport utilities.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pluot_core"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
license = "Apache-2.0"
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "Mark Keller"}
|
|
12
|
+
]
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
description = "Shared utilities used by the pluot and pluot_widget packages"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"numpy>=2",
|
|
17
|
+
"zarr>=3",
|
|
18
|
+
"obstore",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
repository = "https://github.com/keller-mark/pluot"
|
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# TODO: use matplotlib's FontManager?
|
|
7
|
+
# Reference: https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/font_manager.py
|
|
8
|
+
# But this would introduce a matplotlib dependency.
|
|
9
|
+
# Is there a more lightweight alternative?
|
|
10
|
+
|
|
11
|
+
# Optional path overrides: font_family -> file path.
|
|
12
|
+
# Takes priority over system font detection.
|
|
13
|
+
_FONT_OVERRIDES: dict[str, str] = {}
|
|
14
|
+
|
|
15
|
+
def register_font(font_name: str, path: str) -> None:
|
|
16
|
+
"""Override the file path used for a named font. Takes priority over system detection."""
|
|
17
|
+
_FONT_OVERRIDES[font_name] = path
|
|
18
|
+
|
|
19
|
+
def _normalize(name: str) -> str:
|
|
20
|
+
return re.sub(r'[\s\-_]', '', name).lower()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# OS Font paths
|
|
24
|
+
try:
|
|
25
|
+
_HOME = Path.home()
|
|
26
|
+
except Exception: # Exceptions thrown by home() are not specified...
|
|
27
|
+
_HOME = Path(os.devnull) # Just an arbitrary path with no children.
|
|
28
|
+
MSFolders = \
|
|
29
|
+
r'Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders'
|
|
30
|
+
MSFontDirectories = [
|
|
31
|
+
r'SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts',
|
|
32
|
+
r'SOFTWARE\Microsoft\Windows\CurrentVersion\Fonts']
|
|
33
|
+
MSUserFontDirectories = [
|
|
34
|
+
str(_HOME / 'AppData/Local/Microsoft/Windows/Fonts'),
|
|
35
|
+
str(_HOME / 'AppData/Roaming/Microsoft/Windows/Fonts'),
|
|
36
|
+
]
|
|
37
|
+
X11FontDirectories = [
|
|
38
|
+
# an old standard installation point
|
|
39
|
+
"/usr/X11R6/lib/X11/fonts/TTF/",
|
|
40
|
+
"/usr/X11/lib/X11/fonts",
|
|
41
|
+
# here is the new standard location for fonts
|
|
42
|
+
"/usr/share/fonts/",
|
|
43
|
+
# documented as a good place to install new fonts
|
|
44
|
+
"/usr/local/share/fonts/",
|
|
45
|
+
# common application, not really useful
|
|
46
|
+
"/usr/lib/openoffice/share/fonts/truetype/",
|
|
47
|
+
# user fonts
|
|
48
|
+
str((Path(os.environ.get('XDG_DATA_HOME') or _HOME / ".local/share"))
|
|
49
|
+
/ "fonts"),
|
|
50
|
+
str(_HOME / ".fonts"),
|
|
51
|
+
]
|
|
52
|
+
OSXFontDirectories = [
|
|
53
|
+
"/Library/Fonts/",
|
|
54
|
+
"/Network/Library/Fonts/",
|
|
55
|
+
"/System/Library/Fonts/",
|
|
56
|
+
# fonts installed via MacPorts
|
|
57
|
+
"/opt/local/share/fonts",
|
|
58
|
+
# user fonts
|
|
59
|
+
str(_HOME / "Library/Fonts"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
def _system_font_dirs() -> list[Path]:
|
|
63
|
+
if sys.platform == 'darwin':
|
|
64
|
+
return [Path(d) for d in OSXFontDirectories]
|
|
65
|
+
elif sys.platform == 'win32':
|
|
66
|
+
return [Path(d) for d in MSUserFontDirectories]
|
|
67
|
+
else: # Linux / other
|
|
68
|
+
return [Path(d) for d in X11FontDirectories]
|
|
69
|
+
|
|
70
|
+
def _find_system_font(font_name: str) -> str | None:
|
|
71
|
+
norm_name = _normalize(font_name)
|
|
72
|
+
for font_dir in _system_font_dirs():
|
|
73
|
+
if not font_dir.exists():
|
|
74
|
+
continue
|
|
75
|
+
for path in font_dir.rglob('*'):
|
|
76
|
+
if path.suffix.lower() not in ('.ttf', '.otf'):
|
|
77
|
+
continue
|
|
78
|
+
if _normalize(path.stem) == norm_name:
|
|
79
|
+
return str(path)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
# Cache results of _find_system_font to avoid redundant directory scans.
|
|
83
|
+
_FONT_PATH_CACHE: dict[str, str | None] = {}
|
|
84
|
+
|
|
85
|
+
def _resolve_font_path(font_name: str) -> str | None:
|
|
86
|
+
"""Return the file path for a font.
|
|
87
|
+
|
|
88
|
+
Lookup order:
|
|
89
|
+
1. Explicit override registered via register_font().
|
|
90
|
+
2. System font detection.
|
|
91
|
+
"""
|
|
92
|
+
if font_name in _FONT_OVERRIDES:
|
|
93
|
+
return _FONT_OVERRIDES[font_name]
|
|
94
|
+
if font_name not in _FONT_PATH_CACHE:
|
|
95
|
+
_FONT_PATH_CACHE[font_name] = _find_system_font(font_name)
|
|
96
|
+
return _FONT_PATH_CACHE[font_name]
|
|
97
|
+
|
|
98
|
+
def _key_to_font_family(key: str) -> str:
|
|
99
|
+
"""Extract the font family from a zarr-style key.
|
|
100
|
+
|
|
101
|
+
Keys use the format "{family}/{style}/{weight}.ttf". The family is the
|
|
102
|
+
first path segment. A leading "/" is stripped defensively if present.
|
|
103
|
+
"""
|
|
104
|
+
# Strip any accidental leading slash then drop trailing .ttf/.otf.
|
|
105
|
+
name = key.lstrip('/')
|
|
106
|
+
if name.lower().endswith('.ttf') or name.lower().endswith('.otf'):
|
|
107
|
+
name = name[:-4]
|
|
108
|
+
# First segment is the family; remaining segments are style and weight.
|
|
109
|
+
return name.split('/')[0]
|
|
110
|
+
|
|
111
|
+
class _BytesBuffer:
|
|
112
|
+
"""Minimal buffer wrapper compatible with zarr.py's `.to_bytes()` protocol."""
|
|
113
|
+
def __init__(self, data: bytes):
|
|
114
|
+
self._data = data
|
|
115
|
+
def to_bytes(self) -> bytes:
|
|
116
|
+
return self._data
|
|
117
|
+
|
|
118
|
+
class FontStore:
|
|
119
|
+
"""
|
|
120
|
+
Store for fonts, registered as '__fonts__' in GLOBAL_STORES.
|
|
121
|
+
Rust requests fonts via zarr_get_status / zarr_get with store_name='__fonts__'
|
|
122
|
+
and key='{family}/{weight}/{style}.ttf'. The get_sync method is called by
|
|
123
|
+
zarr_get_status to eagerly resolve synchronous results without waiting for
|
|
124
|
+
an async call.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def get_sync(self, key: str) -> bytes:
|
|
128
|
+
"""Synchronously return font bytes, or raise if unavailable."""
|
|
129
|
+
font_family = _key_to_font_family(key)
|
|
130
|
+
path = _resolve_font_path(font_family)
|
|
131
|
+
if path is None:
|
|
132
|
+
raise FileNotFoundError(f"Font not found: {font_family}")
|
|
133
|
+
with open(path, 'rb') as f:
|
|
134
|
+
return f.read()
|
|
135
|
+
|
|
136
|
+
async def get(self, key: str, prototype=None, byte_range=None) -> _BytesBuffer:
|
|
137
|
+
"""Async fallback for zarr_get cache-miss path."""
|
|
138
|
+
return _BytesBuffer(self.get_sync(key))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Copied from zarr-python
|
|
2
|
+
# Reference: https://github.com/zarr-developers/zarr-python/blob/fe229107f9915f05817f7a664d3550695ff9ca44/src/zarr/testing/stateful.py#L438
|
|
3
|
+
|
|
4
|
+
import builtins
|
|
5
|
+
from typing import Any
|
|
6
|
+
import zarr
|
|
7
|
+
from zarr.abc.store import Store
|
|
8
|
+
from zarr.core.buffer import Buffer, BufferPrototype
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SyncStoreWrapper(zarr.core.sync.SyncMixin):
|
|
12
|
+
def __init__(self, store: Store) -> None:
|
|
13
|
+
"""Synchronous Store wrapper
|
|
14
|
+
|
|
15
|
+
This class holds synchronous methods that map to async methods of Store classes.
|
|
16
|
+
The synchronous wrapper is needed because hypothesis' stateful testing infra does
|
|
17
|
+
not support asyncio so we redefine sync versions of the Store API.
|
|
18
|
+
https://github.com/HypothesisWorks/hypothesis/issues/3712#issuecomment-1668999041
|
|
19
|
+
"""
|
|
20
|
+
self.store = store
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def read_only(self) -> bool:
|
|
24
|
+
return self.store.read_only
|
|
25
|
+
|
|
26
|
+
def set(self, key: str, data_buffer: Buffer) -> None:
|
|
27
|
+
return self._sync(self.store.set(key, data_buffer))
|
|
28
|
+
|
|
29
|
+
def list(self) -> builtins.list[str]:
|
|
30
|
+
return self._sync_iter(self.store.list())
|
|
31
|
+
|
|
32
|
+
def get(self, key: str, prototype: BufferPrototype, **kwargs) -> Buffer | None:
|
|
33
|
+
return self._sync(self.store.get(key, prototype=prototype, **kwargs))
|
|
34
|
+
|
|
35
|
+
def get_partial_values(
|
|
36
|
+
self, key_ranges: builtins.list[Any], prototype: BufferPrototype
|
|
37
|
+
) -> builtins.list[Buffer | None]:
|
|
38
|
+
return self._sync(self.store.get_partial_values(prototype=prototype, key_ranges=key_ranges))
|
|
39
|
+
|
|
40
|
+
def delete(self, path: str) -> None:
|
|
41
|
+
return self._sync(self.store.delete(path))
|
|
42
|
+
|
|
43
|
+
def is_empty(self, prefix: str) -> bool:
|
|
44
|
+
return self._sync(self.store.is_empty(prefix=prefix))
|
|
45
|
+
|
|
46
|
+
def clear(self) -> None:
|
|
47
|
+
return self._sync(self.store.clear())
|
|
48
|
+
|
|
49
|
+
def exists(self, key: str) -> bool:
|
|
50
|
+
return self._sync(self.store.exists(key))
|
|
51
|
+
|
|
52
|
+
def list_dir(self, prefix: str) -> None:
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
def list_prefix(self, prefix: str) -> None:
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def supports_listing(self) -> bool:
|
|
60
|
+
return self.store.supports_listing
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def supports_writes(self) -> bool:
|
|
64
|
+
return self.store.supports_writes
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def supports_deletes(self) -> bool:
|
|
68
|
+
return self.store.supports_deletes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Literal, Optional
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
# TODO: auto-generate these types from the Rust side: https://github.com/keller-mark/pluot/issues/133
|
|
7
|
+
AspectRatioMode = Literal["Ignore", "Contain", "Cover"]
|
|
8
|
+
AspectRatioAlignmentMode = Literal["Center", "Start", "End"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Margins:
|
|
13
|
+
margin_top: float = 0.0
|
|
14
|
+
margin_right: float = 0.0
|
|
15
|
+
margin_bottom: float = 0.0
|
|
16
|
+
margin_left: float = 0.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ViewportParams:
|
|
21
|
+
width: float
|
|
22
|
+
height: float
|
|
23
|
+
aspect_ratio_mode: AspectRatioMode
|
|
24
|
+
aspect_ratio_alignment_mode: AspectRatioAlignmentMode
|
|
25
|
+
margins: Optional[Margins] = field(default=None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Bounds:
|
|
30
|
+
# Each value is optional.
|
|
31
|
+
# When an entire dimension is omitted (X or Y),
|
|
32
|
+
# use the current camera settings for that dimension.
|
|
33
|
+
# When a single value is omitted (e.g., x_min), ensure the resulting camera matrix keeps this boundary unchanged.
|
|
34
|
+
x_min: Optional[float] = None
|
|
35
|
+
x_max: Optional[float] = None
|
|
36
|
+
y_min: Optional[float] = None
|
|
37
|
+
y_max: Optional[float] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_scales_and_align_translations(viewport_params: ViewportParams):
|
|
41
|
+
margins = viewport_params.margins
|
|
42
|
+
margin_top = margins.margin_top if margins else 0.0
|
|
43
|
+
margin_right = margins.margin_right if margins else 0.0
|
|
44
|
+
margin_bottom = margins.margin_bottom if margins else 0.0
|
|
45
|
+
margin_left = margins.margin_left if margins else 0.0
|
|
46
|
+
|
|
47
|
+
layer_w = viewport_params.width - margin_left - margin_right
|
|
48
|
+
layer_h = viewport_params.height - margin_top - margin_bottom
|
|
49
|
+
layer_aspect_ratio = layer_w / layer_h
|
|
50
|
+
|
|
51
|
+
x_scale = 1.0
|
|
52
|
+
y_scale = 1.0
|
|
53
|
+
if viewport_params.aspect_ratio_mode == "Contain":
|
|
54
|
+
if layer_aspect_ratio > 1.0:
|
|
55
|
+
x_scale = layer_aspect_ratio
|
|
56
|
+
elif layer_aspect_ratio < 1.0:
|
|
57
|
+
y_scale = 1.0 / layer_aspect_ratio
|
|
58
|
+
elif viewport_params.aspect_ratio_mode == "Cover":
|
|
59
|
+
if layer_aspect_ratio > 1.0:
|
|
60
|
+
y_scale = 1.0 / layer_aspect_ratio
|
|
61
|
+
elif layer_aspect_ratio < 1.0:
|
|
62
|
+
x_scale = layer_aspect_ratio
|
|
63
|
+
|
|
64
|
+
x_align_translation = 0.0
|
|
65
|
+
y_align_translation = 0.0
|
|
66
|
+
if viewport_params.aspect_ratio_alignment_mode == "Start":
|
|
67
|
+
x_align_translation = x_scale - 1.0
|
|
68
|
+
y_align_translation = y_scale - 1.0
|
|
69
|
+
elif viewport_params.aspect_ratio_alignment_mode == "End":
|
|
70
|
+
x_align_translation = 1.0 - x_scale
|
|
71
|
+
y_align_translation = 1.0 - y_scale
|
|
72
|
+
|
|
73
|
+
return x_scale, y_scale, x_align_translation, y_align_translation
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_bounds(camera_matrix: np.ndarray, viewport_params: ViewportParams) -> Bounds:
|
|
77
|
+
"""Calculate the visible data range based on camera view and viewport parameters."""
|
|
78
|
+
zoom_x = camera_matrix[0]
|
|
79
|
+
zoom_y = camera_matrix[5]
|
|
80
|
+
translate_x = camera_matrix[12]
|
|
81
|
+
translate_y = camera_matrix[13]
|
|
82
|
+
|
|
83
|
+
x_scale, y_scale, x_align_translation, y_align_translation = _get_scales_and_align_translations(viewport_params)
|
|
84
|
+
|
|
85
|
+
x_adj = x_scale - 1.0
|
|
86
|
+
y_adj = y_scale - 1.0
|
|
87
|
+
|
|
88
|
+
x_min = ((-translate_x - 1.0 - x_adj + x_align_translation) / zoom_x + 1.0) / 2.0
|
|
89
|
+
x_max = ((-translate_x + 1.0 + x_adj + x_align_translation) / zoom_x + 1.0) / 2.0
|
|
90
|
+
y_min = ((-translate_y - 1.0 - y_adj + y_align_translation) / zoom_y + 1.0) / 2.0
|
|
91
|
+
y_max = ((-translate_y + 1.0 + y_adj + y_align_translation) / zoom_y + 1.0) / 2.0
|
|
92
|
+
|
|
93
|
+
return Bounds(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_camera_matrix_from_bounds(bounds: Bounds, prev_camera_matrix: np.ndarray, viewport_params: ViewportParams) -> np.ndarray:
|
|
97
|
+
"""Given data bounds, compute the corresponding camera matrix.
|
|
98
|
+
Missing bound values are filled in from prev_camera_matrix.
|
|
99
|
+
"""
|
|
100
|
+
current_bounds = get_bounds(prev_camera_matrix, viewport_params)
|
|
101
|
+
x_min = bounds.x_min if bounds.x_min is not None else current_bounds.x_min
|
|
102
|
+
x_max = bounds.x_max if bounds.x_max is not None else current_bounds.x_max
|
|
103
|
+
y_min = bounds.y_min if bounds.y_min is not None else current_bounds.y_min
|
|
104
|
+
y_max = bounds.y_max if bounds.y_max is not None else current_bounds.y_max
|
|
105
|
+
|
|
106
|
+
x_scale, y_scale, x_align_translation, y_align_translation = _get_scales_and_align_translations(viewport_params)
|
|
107
|
+
|
|
108
|
+
x_adj = x_scale - 1.0
|
|
109
|
+
y_adj = y_scale - 1.0
|
|
110
|
+
|
|
111
|
+
x_range = x_max - x_min
|
|
112
|
+
y_range = y_max - y_min
|
|
113
|
+
|
|
114
|
+
zoom_x = (1.0 + x_adj) / x_range
|
|
115
|
+
zoom_y = (1.0 + y_adj) / y_range
|
|
116
|
+
|
|
117
|
+
# When aspect ratio is ignored, zoom each axis independently.
|
|
118
|
+
# Otherwise take the minimum so all requested data fits within the viewport.
|
|
119
|
+
if viewport_params.aspect_ratio_mode != "Ignore":
|
|
120
|
+
zoom_x = zoom_y = min(zoom_x, zoom_y)
|
|
121
|
+
|
|
122
|
+
# Invert the get_bounds translation equations:
|
|
123
|
+
# min + max = (-translate + align) / zoom + 1.0
|
|
124
|
+
# So: translate = align - zoom * ((min + max) - 1.0)
|
|
125
|
+
translate_x = x_align_translation - zoom_x * ((x_min + x_max) - 1.0)
|
|
126
|
+
translate_y = y_align_translation - zoom_y * ((y_min + y_max) - 1.0)
|
|
127
|
+
|
|
128
|
+
return np.array([
|
|
129
|
+
zoom_x, 0.0, 0.0, 0.0,
|
|
130
|
+
0.0, zoom_y, 0.0, 0.0,
|
|
131
|
+
0.0, 0.0, 1.0, 0.0,
|
|
132
|
+
translate_x, translate_y, 0.0, 1.0,
|
|
133
|
+
], dtype=np.float32)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
def store_instance_to_metadata(store) -> dict:
|
|
2
|
+
"""Derive portable ``ZarrStoreInfo`` metadata from a zarr-python store instance.
|
|
3
|
+
|
|
4
|
+
The result mirrors the Rust ``ZarrStoreInfo`` JSON (see
|
|
5
|
+
``crates/pluot_core/src/params.rs``): an adjacently-tagged ``store_type`` /
|
|
6
|
+
``store_params`` pair plus an optional ``store_extensions`` list.
|
|
7
|
+
|
|
8
|
+
Resolution order:
|
|
9
|
+
1. A wrapper store may declare its own metadata via a ``store_metadata``
|
|
10
|
+
attribute (see :func:`store_with_metadata`), which already passes the
|
|
11
|
+
inner store's metadata through and layers on any extension.
|
|
12
|
+
2. A ``LocalStore`` exposes ``.root`` -> ``LocalStore``.
|
|
13
|
+
3. An fsspec/remote-backed store yields a URL -> ``HttpStore``.
|
|
14
|
+
4. Otherwise fall back to a ``MemoryStore`` descriptor. The instance is
|
|
15
|
+
still usable at render time (it is registered by name in
|
|
16
|
+
``GLOBAL_STORES``), but its data is not reconstructable from metadata.
|
|
17
|
+
"""
|
|
18
|
+
declared = getattr(store, "store_metadata", None)
|
|
19
|
+
if isinstance(declared, dict) and "store_type" in declared:
|
|
20
|
+
return declared
|
|
21
|
+
|
|
22
|
+
# LocalStore exposes `.root` (a path).
|
|
23
|
+
root = getattr(store, "root", None)
|
|
24
|
+
if root is not None:
|
|
25
|
+
return {
|
|
26
|
+
"store_type": "LocalStore",
|
|
27
|
+
"store_params": {"path": str(root)},
|
|
28
|
+
"store_extensions": None,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Remote / fsspec-backed stores: derive a URL where possible.
|
|
32
|
+
url = _derive_store_url(store)
|
|
33
|
+
if url is not None:
|
|
34
|
+
return {
|
|
35
|
+
"store_type": "HttpStore",
|
|
36
|
+
"store_params": {"url": url},
|
|
37
|
+
"store_extensions": None,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
"store_type": "MemoryStore",
|
|
42
|
+
"store_params": {
|
|
43
|
+
"message": f"In-memory or custom store ({type(store).__name__})"
|
|
44
|
+
},
|
|
45
|
+
"store_extensions": None,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Registry of store-extension appliers used by store_metadata_to_instance to
|
|
50
|
+
# reconstruct virtual-zarr wrapper stores (the inverse of the store_extensions
|
|
51
|
+
# recorded by store_instance_to_metadata). Appliers are opt-in so this package
|
|
52
|
+
# need not depend on every virtual-zarr implementation.
|
|
53
|
+
_STORE_EXTENSION_APPLIERS: dict = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def register_store_extension(extension: str, applier) -> None:
|
|
57
|
+
"""Register the applier used to reconstruct a ``ZarrStoreExtension`` wrapper.
|
|
58
|
+
|
|
59
|
+
``applier`` takes a base store and returns a wrapped store (e.g. one that
|
|
60
|
+
virtualizes OME-TIFF data as zarr).
|
|
61
|
+
"""
|
|
62
|
+
_STORE_EXTENSION_APPLIERS[extension] = applier
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def store_metadata_to_instance(info: dict):
|
|
66
|
+
"""Construct a concrete zarr-python store instance from ``ZarrStoreInfo`` metadata.
|
|
67
|
+
|
|
68
|
+
The inverse of :func:`store_instance_to_metadata`:
|
|
69
|
+
|
|
70
|
+
- ``HttpStore`` -> a remote fsspec-backed store for the URL;
|
|
71
|
+
- ``LocalStore`` -> a ``zarr.storage.LocalStore`` for the path;
|
|
72
|
+
- ``MemoryStore`` -> raises (an in-memory store has no portable
|
|
73
|
+
representation and must be provided directly).
|
|
74
|
+
|
|
75
|
+
Any ``store_extensions`` are then applied outermost-last using appliers
|
|
76
|
+
registered via :func:`register_store_extension`.
|
|
77
|
+
"""
|
|
78
|
+
store_type = info["store_type"]
|
|
79
|
+
params = info.get("store_params") or {}
|
|
80
|
+
|
|
81
|
+
if store_type == "HttpStore":
|
|
82
|
+
store = http_store_from_url(params["url"])
|
|
83
|
+
elif store_type == "LocalStore":
|
|
84
|
+
from zarr.storage import LocalStore
|
|
85
|
+
store = LocalStore(params["path"])
|
|
86
|
+
elif store_type == "MemoryStore":
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"Cannot reconstruct an in-memory store from metadata "
|
|
89
|
+
f"({params.get('message')!r}); provide the store instance directly."
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError(f"Unknown store_type: {store_type!r}")
|
|
93
|
+
|
|
94
|
+
for ext in info.get("store_extensions") or []:
|
|
95
|
+
applier = _STORE_EXTENSION_APPLIERS.get(ext)
|
|
96
|
+
if applier is None:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"No applier registered for store extension {ext!r}. "
|
|
99
|
+
"Register one via register_store_extension()."
|
|
100
|
+
)
|
|
101
|
+
store = applier(store)
|
|
102
|
+
return store
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def http_store_from_url(url: str):
|
|
106
|
+
"""Construct a remote (fsspec-backed) zarr store from a URL."""
|
|
107
|
+
from obstore.store import HTTPStore
|
|
108
|
+
from zarr.storage import ObjectStore
|
|
109
|
+
|
|
110
|
+
obs_store = HTTPStore.from_url(url)
|
|
111
|
+
return ObjectStore(obs_store, read_only=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _derive_store_url(store):
|
|
115
|
+
"""Best-effort extraction of a URL from a remote obstore-backed zarr store."""
|
|
116
|
+
# zarr.storage.ObjectStore's .store should contain an obstore HTTPStore.
|
|
117
|
+
obs_store = getattr(store, "store", None)
|
|
118
|
+
url = getattr(obs_store, "url", None)
|
|
119
|
+
if isinstance(url, str) and "://" in url:
|
|
120
|
+
return url
|
|
121
|
+
return None
|