spatialdata-js-util 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.
- spatialdata_js_util/__init__.py +107 -0
- spatialdata_js_util/cli.py +803 -0
- spatialdata_js_util/codecs/__init__.py +67 -0
- spatialdata_js_util/codecs/backends.py +250 -0
- spatialdata_js_util/codecs/chunks.py +40 -0
- spatialdata_js_util/codecs/encoding.py +154 -0
- spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
- spatialdata_js_util/codecs/names.py +26 -0
- spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
- spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
- spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
- spatialdata_js_util/codecs/vendor/__init__.py +1 -0
- spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
- spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
- spatialdata_js_util/codecs/zarr_codec.py +149 -0
- spatialdata_js_util/errors.py +5 -0
- spatialdata_js_util/images.py +849 -0
- spatialdata_js_util/index_permutations.py +311 -0
- spatialdata_js_util/points.py +445 -0
- spatialdata_js_util/points_zarr.py +799 -0
- spatialdata_js_util/provenance.py +17 -0
- spatialdata_js_util/pyramids.py +318 -0
- spatialdata_js_util/runners.py +458 -0
- spatialdata_js_util/store.py +305 -0
- spatialdata_js_util/tables.py +310 -0
- spatialdata_js_util/tui/__init__.py +0 -0
- spatialdata_js_util/tui/app.py +112 -0
- spatialdata_js_util/tui/models.py +45 -0
- spatialdata_js_util/tui/screens.py +1255 -0
- spatialdata_js_util/tui/widgets.py +50 -0
- spatialdata_js_util/verify.py +338 -0
- spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
- spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
- spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
- spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
- spatialdata_js_util-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import traceback
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from textual import getters, work
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.events import ScreenResume
|
|
13
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
14
|
+
from textual.screen import Screen
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
Button,
|
|
17
|
+
Checkbox,
|
|
18
|
+
DataTable,
|
|
19
|
+
Footer,
|
|
20
|
+
Input,
|
|
21
|
+
Label,
|
|
22
|
+
ListItem,
|
|
23
|
+
ListView,
|
|
24
|
+
RichLog,
|
|
25
|
+
Select,
|
|
26
|
+
Static,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ..codecs import CODEC_HTJ2K_OPENJPH, CODEC_JPEG2K
|
|
30
|
+
from ..pyramids import DEFAULT_MIN_SIZE as DEFAULT_PYRAMID_MIN_SIZE
|
|
31
|
+
from ..errors import WriterCommandError
|
|
32
|
+
from ..index_permutations import DEFAULT_CONDITIONS
|
|
33
|
+
from ..runners import (
|
|
34
|
+
resolve_morton_from_zarr_output,
|
|
35
|
+
run_list_points,
|
|
36
|
+
run_list_tables,
|
|
37
|
+
run_morton_points,
|
|
38
|
+
run_morton_points_from_zarr,
|
|
39
|
+
run_multiscale_points,
|
|
40
|
+
run_tables_to_csc,
|
|
41
|
+
run_write_index_permutations,
|
|
42
|
+
)
|
|
43
|
+
from ..verify import (
|
|
44
|
+
VerifyCheck,
|
|
45
|
+
all_passed,
|
|
46
|
+
verify_index_permutations_manifest,
|
|
47
|
+
verify_morton_parquet,
|
|
48
|
+
verify_multiscale_parquet,
|
|
49
|
+
)
|
|
50
|
+
from ..store import list_points_keys, read_points_element_attrs
|
|
51
|
+
from .app import WriterApp
|
|
52
|
+
from .widgets import SafeHeader
|
|
53
|
+
from .models import CommandId, TaskSpec
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _positive_int(value: str, default: int) -> int:
|
|
57
|
+
stripped = value.strip()
|
|
58
|
+
if not stripped:
|
|
59
|
+
return default
|
|
60
|
+
parsed = int(stripped)
|
|
61
|
+
if parsed <= 0:
|
|
62
|
+
raise ValueError("value must be positive")
|
|
63
|
+
return parsed
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WriterScreen(Screen[None]):
|
|
67
|
+
app = getters.app(WriterApp)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InputFormScreen(WriterScreen):
|
|
71
|
+
"""Form screen with Enter-to-advance/submit and Escape-to-back."""
|
|
72
|
+
|
|
73
|
+
INPUT_ORDER: tuple[str, ...] = ()
|
|
74
|
+
PRIMARY_BUTTON_ID: str = "run"
|
|
75
|
+
|
|
76
|
+
BINDINGS = [
|
|
77
|
+
Binding("escape", "go_back", "Back"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def on_mount(self) -> None:
|
|
81
|
+
if self.INPUT_ORDER:
|
|
82
|
+
self.query_one(f"#{self.INPUT_ORDER[0]}", Input).focus()
|
|
83
|
+
|
|
84
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
85
|
+
input_id = event.input.id
|
|
86
|
+
if input_id is None:
|
|
87
|
+
self._press_primary()
|
|
88
|
+
return
|
|
89
|
+
if not self.INPUT_ORDER or input_id not in self.INPUT_ORDER:
|
|
90
|
+
self._press_primary()
|
|
91
|
+
return
|
|
92
|
+
if input_id == self.INPUT_ORDER[-1]:
|
|
93
|
+
self._press_primary()
|
|
94
|
+
return
|
|
95
|
+
next_index = self.INPUT_ORDER.index(input_id) + 1
|
|
96
|
+
self.query_one(f"#{self.INPUT_ORDER[next_index]}", Input).focus()
|
|
97
|
+
|
|
98
|
+
def action_go_back(self) -> None:
|
|
99
|
+
self.app.pop_screen()
|
|
100
|
+
|
|
101
|
+
def _press_primary(self) -> None:
|
|
102
|
+
self.query_one(f"#{self.PRIMARY_BUTTON_ID}", Button).press()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class HomeScreen(WriterScreen):
|
|
106
|
+
BINDINGS = [("q", "quit", "Quit")]
|
|
107
|
+
|
|
108
|
+
def on_mount(self) -> None:
|
|
109
|
+
self.query_one("#command-list", ListView).focus()
|
|
110
|
+
|
|
111
|
+
def on_screen_resume(self, event: ScreenResume) -> None:
|
|
112
|
+
self.refresh()
|
|
113
|
+
self.query_one("#command-list", ListView).focus()
|
|
114
|
+
|
|
115
|
+
def compose(self) -> ComposeResult:
|
|
116
|
+
yield SafeHeader()
|
|
117
|
+
yield Static(
|
|
118
|
+
"spatialdata-js-util — pick a command.",
|
|
119
|
+
id="home-title",
|
|
120
|
+
)
|
|
121
|
+
yield ListView(
|
|
122
|
+
ListItem(Label("IMAGES Recompress rasters (JPEG 2000 / HTJ2K)"), id="cmd-recompress"),
|
|
123
|
+
ListItem(Label("POINTS List Points elements in a Zarr store"), id="cmd-list-points"),
|
|
124
|
+
ListItem(Label("POINTS Morton-sort Points from Zarr"), id="cmd-morton-from-zarr"),
|
|
125
|
+
ListItem(Label("POINTS Morton-sort CSV/Parquet file"), id="cmd-morton-points"),
|
|
126
|
+
ListItem(Label("POINTS Write multiscale Points Parquet"), id="cmd-multiscale-points"),
|
|
127
|
+
ListItem(
|
|
128
|
+
Label("POINTS Write index permutations derivative store"),
|
|
129
|
+
id="cmd-index-permutations",
|
|
130
|
+
),
|
|
131
|
+
ListItem(Label("TABLES List table elements in a Zarr store"), id="cmd-list-tables"),
|
|
132
|
+
ListItem(Label("TABLES Convert table matrices to CSC"), id="cmd-tables-to-csc"),
|
|
133
|
+
ListItem(Label("CODECS Show HTJ2K backend availability"), id="cmd-codecs-info"),
|
|
134
|
+
id="command-list",
|
|
135
|
+
)
|
|
136
|
+
yield Footer()
|
|
137
|
+
|
|
138
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
139
|
+
item_id = event.item.id or ""
|
|
140
|
+
if item_id == "cmd-recompress":
|
|
141
|
+
self.app.push_screen(RecompressImagesScreen())
|
|
142
|
+
elif item_id == "cmd-list-points":
|
|
143
|
+
self._start_zarr_command(CommandId.LIST_POINTS)
|
|
144
|
+
elif item_id == "cmd-morton-from-zarr":
|
|
145
|
+
self._start_zarr_command(CommandId.MORTON_FROM_ZARR)
|
|
146
|
+
elif item_id == "cmd-morton-points":
|
|
147
|
+
self.app.push_screen(MortonFileScreen())
|
|
148
|
+
elif item_id == "cmd-multiscale-points":
|
|
149
|
+
self.app.push_screen(MultiscaleScreen())
|
|
150
|
+
elif item_id == "cmd-index-permutations":
|
|
151
|
+
self.app.push_screen(IndexPermutationsScreen())
|
|
152
|
+
elif item_id == "cmd-list-tables":
|
|
153
|
+
self._start_list_tables()
|
|
154
|
+
elif item_id == "cmd-tables-to-csc":
|
|
155
|
+
self.app.push_screen(TablesToCscScreen())
|
|
156
|
+
elif item_id == "cmd-codecs-info":
|
|
157
|
+
self.app.push_screen(RunScreen(codecs_info_task()))
|
|
158
|
+
|
|
159
|
+
def _start_list_tables(self) -> None:
|
|
160
|
+
zarr = self.app.context.zarr_path
|
|
161
|
+
if not zarr:
|
|
162
|
+
self.app.push_screen(ZarrPathScreen(CommandId.LIST_TABLES))
|
|
163
|
+
return
|
|
164
|
+
self.app.push_screen(RunScreen(list_tables_task(zarr)))
|
|
165
|
+
|
|
166
|
+
def _start_zarr_command(self, command: CommandId) -> None:
|
|
167
|
+
if command == CommandId.LIST_POINTS:
|
|
168
|
+
if self.app.context.zarr_path:
|
|
169
|
+
self._run_list_points(self.app.context.zarr_path)
|
|
170
|
+
else:
|
|
171
|
+
self.app.push_screen(ZarrPathScreen(command))
|
|
172
|
+
return
|
|
173
|
+
if self.app.context.zarr_path:
|
|
174
|
+
self.app.push_screen(PointsKeyScreen(command))
|
|
175
|
+
else:
|
|
176
|
+
self.app.push_screen(ZarrPathScreen(command))
|
|
177
|
+
|
|
178
|
+
def _run_list_points(self, zarr: str) -> None:
|
|
179
|
+
def runner() -> dict[str, Any]:
|
|
180
|
+
return run_list_points(zarr)
|
|
181
|
+
|
|
182
|
+
self.app.push_screen(
|
|
183
|
+
RunScreen(
|
|
184
|
+
TaskSpec(
|
|
185
|
+
command=CommandId.LIST_POINTS,
|
|
186
|
+
title="List Points",
|
|
187
|
+
runner=runner,
|
|
188
|
+
verify_kind="none",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ZarrPathScreen(InputFormScreen):
|
|
195
|
+
INPUT_ORDER = ("zarr-path",)
|
|
196
|
+
PRIMARY_BUTTON_ID = "continue"
|
|
197
|
+
|
|
198
|
+
def __init__(self, command: CommandId) -> None:
|
|
199
|
+
super().__init__()
|
|
200
|
+
self.command = command
|
|
201
|
+
|
|
202
|
+
def compose(self) -> ComposeResult:
|
|
203
|
+
yield SafeHeader()
|
|
204
|
+
yield Static("SpatialData Zarr store path", classes="screen-title")
|
|
205
|
+
yield Input(placeholder="/path/to/store.zarr", id="zarr-path")
|
|
206
|
+
with Horizontal():
|
|
207
|
+
yield Button("Continue", variant="primary", id="continue")
|
|
208
|
+
yield Button("Back", id="back")
|
|
209
|
+
yield Footer()
|
|
210
|
+
|
|
211
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
212
|
+
if event.button.id == "back":
|
|
213
|
+
self.action_go_back()
|
|
214
|
+
return
|
|
215
|
+
self._continue()
|
|
216
|
+
|
|
217
|
+
def _continue(self) -> None:
|
|
218
|
+
path = self.query_one("#zarr-path", Input).value.strip()
|
|
219
|
+
if not path:
|
|
220
|
+
self.notify("Enter a Zarr store path.", severity="error")
|
|
221
|
+
return
|
|
222
|
+
resolved = Path(path)
|
|
223
|
+
if not resolved.is_dir():
|
|
224
|
+
self.notify(f"Not a directory: {path}", severity="error")
|
|
225
|
+
return
|
|
226
|
+
self.app.context.zarr_path = str(resolved)
|
|
227
|
+
if self.command == CommandId.LIST_POINTS:
|
|
228
|
+
self._run_list_points(path)
|
|
229
|
+
return
|
|
230
|
+
if self.command == CommandId.LIST_TABLES:
|
|
231
|
+
self.app.push_screen(RunScreen(list_tables_task(path)))
|
|
232
|
+
return
|
|
233
|
+
self.app.push_screen(PointsKeyScreen(self.command))
|
|
234
|
+
|
|
235
|
+
def _run_list_points(self, zarr: str) -> None:
|
|
236
|
+
def runner() -> dict[str, Any]:
|
|
237
|
+
return run_list_points(zarr)
|
|
238
|
+
|
|
239
|
+
self.app.push_screen(
|
|
240
|
+
RunScreen(
|
|
241
|
+
TaskSpec(
|
|
242
|
+
command=CommandId.LIST_POINTS,
|
|
243
|
+
title="List Points",
|
|
244
|
+
runner=runner,
|
|
245
|
+
verify_kind="none",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class PointsKeyScreen(WriterScreen):
|
|
252
|
+
BINDINGS = [
|
|
253
|
+
Binding("escape", "go_back", "Back"),
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
def __init__(self, command: CommandId) -> None:
|
|
257
|
+
super().__init__()
|
|
258
|
+
self.command = command
|
|
259
|
+
|
|
260
|
+
def compose(self) -> ComposeResult:
|
|
261
|
+
yield SafeHeader()
|
|
262
|
+
yield Static("Select Points element", classes="screen-title")
|
|
263
|
+
yield ListView(id="points-key-list")
|
|
264
|
+
with Horizontal():
|
|
265
|
+
yield Button("Continue", variant="primary", id="continue")
|
|
266
|
+
yield Button("Back", id="back")
|
|
267
|
+
yield Footer()
|
|
268
|
+
|
|
269
|
+
def on_mount(self) -> None:
|
|
270
|
+
zarr = self.app.context.zarr_path
|
|
271
|
+
list_view = self.query_one("#points-key-list", ListView)
|
|
272
|
+
if not zarr:
|
|
273
|
+
return
|
|
274
|
+
keys = list_points_keys(zarr)
|
|
275
|
+
if not keys:
|
|
276
|
+
list_view.mount(Static("No Points elements found."))
|
|
277
|
+
return
|
|
278
|
+
for key in keys:
|
|
279
|
+
list_view.mount(ListItem(Label(key), id=f"key-{key}"))
|
|
280
|
+
if len(keys) == 1:
|
|
281
|
+
self.app.context.points_key = keys[0]
|
|
282
|
+
list_view.index = 0
|
|
283
|
+
list_view.focus()
|
|
284
|
+
|
|
285
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
286
|
+
item_id = event.item.id or ""
|
|
287
|
+
if item_id.startswith("key-"):
|
|
288
|
+
self.app.context.points_key = item_id.removeprefix("key-")
|
|
289
|
+
self._continue()
|
|
290
|
+
|
|
291
|
+
def action_go_back(self) -> None:
|
|
292
|
+
self.app.pop_screen()
|
|
293
|
+
|
|
294
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
295
|
+
if event.button.id == "back":
|
|
296
|
+
self.action_go_back()
|
|
297
|
+
return
|
|
298
|
+
self._continue()
|
|
299
|
+
|
|
300
|
+
def _continue(self) -> None:
|
|
301
|
+
list_view = self.query_one("#points-key-list", ListView)
|
|
302
|
+
if list_view.index is None:
|
|
303
|
+
self.notify("Select a Points element.", severity="error")
|
|
304
|
+
return
|
|
305
|
+
item = list_view.children[list_view.index]
|
|
306
|
+
item_id = item.id or ""
|
|
307
|
+
if not item_id.startswith("key-"):
|
|
308
|
+
self.notify("Select a Points element.", severity="error")
|
|
309
|
+
return
|
|
310
|
+
self.app.context.points_key = item_id.removeprefix("key-")
|
|
311
|
+
if self.command == CommandId.MORTON_FROM_ZARR:
|
|
312
|
+
self.app.push_screen(MortonFromZarrScreen())
|
|
313
|
+
elif self.command == CommandId.INDEX_PERMUTATIONS:
|
|
314
|
+
self.app.push_screen(IndexPermutationsScreen(from_zarr_context=True))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class MortonFromZarrScreen(InputFormScreen):
|
|
318
|
+
INPUT_ORDER = ("feature-key", "row-group-size", "compression", "output-element")
|
|
319
|
+
|
|
320
|
+
def compose(self) -> ComposeResult:
|
|
321
|
+
yield SafeHeader()
|
|
322
|
+
yield Static("Morton-sort Points from Zarr", classes="screen-title")
|
|
323
|
+
with VerticalScroll():
|
|
324
|
+
yield Label("Feature key column (optional)")
|
|
325
|
+
yield Input(placeholder="feature_name", id="feature-key")
|
|
326
|
+
yield Label("Row group size")
|
|
327
|
+
yield Input(value="50000", id="row-group-size")
|
|
328
|
+
yield Label("Compression")
|
|
329
|
+
yield Input(value="zstd", id="compression")
|
|
330
|
+
yield Label("Output Points element name (optional)")
|
|
331
|
+
yield Input(placeholder="leave empty to overwrite selected element", id="output-element")
|
|
332
|
+
yield Checkbox("Write to points.experimental/", id="experimental")
|
|
333
|
+
with Horizontal():
|
|
334
|
+
yield Button("Run", variant="primary", id="run")
|
|
335
|
+
yield Button("Back", id="back")
|
|
336
|
+
yield Footer()
|
|
337
|
+
|
|
338
|
+
def on_mount(self) -> None:
|
|
339
|
+
zarr = self.app.context.zarr_path
|
|
340
|
+
key = self.app.context.points_key
|
|
341
|
+
if not zarr or not key:
|
|
342
|
+
return
|
|
343
|
+
try:
|
|
344
|
+
attrs = read_points_element_attrs(zarr, key)
|
|
345
|
+
feature_key = attrs.get("feature_key")
|
|
346
|
+
if feature_key:
|
|
347
|
+
self.query_one("#feature-key", Input).value = str(feature_key)
|
|
348
|
+
except OSError:
|
|
349
|
+
pass
|
|
350
|
+
super().on_mount()
|
|
351
|
+
|
|
352
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
353
|
+
if event.button.id == "back":
|
|
354
|
+
self.action_go_back()
|
|
355
|
+
return
|
|
356
|
+
self._submit()
|
|
357
|
+
|
|
358
|
+
def _submit(self) -> None:
|
|
359
|
+
zarr = self.app.context.zarr_path
|
|
360
|
+
key = self.app.context.points_key
|
|
361
|
+
if not zarr or not key:
|
|
362
|
+
self.notify("Missing Zarr context.", severity="error")
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
feature_key = self.query_one("#feature-key", Input).value.strip() or None
|
|
366
|
+
output_key = self.query_one("#output-element", Input).value.strip() or None
|
|
367
|
+
experimental = self.query_one("#experimental", Checkbox).value
|
|
368
|
+
try:
|
|
369
|
+
row_group_size = _positive_int(
|
|
370
|
+
self.query_one("#row-group-size", Input).value, 50_000
|
|
371
|
+
)
|
|
372
|
+
except ValueError as exc:
|
|
373
|
+
self.notify(str(exc), severity="error")
|
|
374
|
+
return
|
|
375
|
+
compression = self.query_one("#compression", Input).value.strip() or "zstd"
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
output_spec = resolve_morton_from_zarr_output(
|
|
379
|
+
Path(zarr),
|
|
380
|
+
key,
|
|
381
|
+
output_points_key=output_key,
|
|
382
|
+
experimental=experimental,
|
|
383
|
+
)
|
|
384
|
+
except WriterCommandError as exc:
|
|
385
|
+
self.notify(str(exc), severity="error")
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
target_exists = output_spec.output_parquet.exists()
|
|
389
|
+
if (
|
|
390
|
+
output_spec.collection == "points"
|
|
391
|
+
and output_spec.output_points_key is not None
|
|
392
|
+
):
|
|
393
|
+
target_exists = target_exists or (
|
|
394
|
+
Path(zarr) / "points" / output_spec.output_points_key
|
|
395
|
+
).exists()
|
|
396
|
+
requires_confirm = output_spec.in_place or target_exists
|
|
397
|
+
|
|
398
|
+
def runner() -> dict[str, Any]:
|
|
399
|
+
return run_morton_points_from_zarr(
|
|
400
|
+
zarr,
|
|
401
|
+
points_key=key,
|
|
402
|
+
experimental=experimental,
|
|
403
|
+
output_points_key=output_key,
|
|
404
|
+
feature_key=feature_key,
|
|
405
|
+
overwrite=requires_confirm,
|
|
406
|
+
row_group_size=row_group_size,
|
|
407
|
+
compression=compression,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if output_spec.in_place:
|
|
411
|
+
confirm_message = (
|
|
412
|
+
f"Overwrite selected Points element:\npoints/{key}\n\n"
|
|
413
|
+
f"Parquet path:\n{output_spec.output_parquet}\n\nProceed?"
|
|
414
|
+
)
|
|
415
|
+
elif target_exists and output_spec.collection == "points":
|
|
416
|
+
confirm_message = (
|
|
417
|
+
f"Overwrite existing Points element:\n"
|
|
418
|
+
f"points/{output_spec.output_points_key}\n\n"
|
|
419
|
+
f"Parquet path:\n{output_spec.output_parquet}\n\nProceed?"
|
|
420
|
+
)
|
|
421
|
+
elif target_exists and output_spec.collection == "points.experimental":
|
|
422
|
+
confirm_message = (
|
|
423
|
+
f"Overwrite existing experimental Points artifact:\n"
|
|
424
|
+
f"points.experimental/{output_spec.output_points_key}\n\n"
|
|
425
|
+
f"Parquet path:\n{output_spec.output_parquet}\n\nProceed?"
|
|
426
|
+
)
|
|
427
|
+
else:
|
|
428
|
+
confirm_message = ""
|
|
429
|
+
|
|
430
|
+
task = TaskSpec(
|
|
431
|
+
command=CommandId.MORTON_FROM_ZARR,
|
|
432
|
+
title="Morton-sort from Zarr",
|
|
433
|
+
runner=runner,
|
|
434
|
+
verify_kind="morton",
|
|
435
|
+
verify_paths=[output_spec.output_parquet],
|
|
436
|
+
requires_confirm=requires_confirm,
|
|
437
|
+
confirm_message=confirm_message,
|
|
438
|
+
)
|
|
439
|
+
self._launch_task(task)
|
|
440
|
+
|
|
441
|
+
def _launch_task(self, task_spec: TaskSpec) -> None:
|
|
442
|
+
if task_spec.requires_confirm:
|
|
443
|
+
self.app.push_screen(ConfirmScreen(task_spec))
|
|
444
|
+
else:
|
|
445
|
+
self.app.push_screen(RunScreen(task_spec))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class MortonFileScreen(InputFormScreen):
|
|
449
|
+
INPUT_ORDER = (
|
|
450
|
+
"input-path",
|
|
451
|
+
"output-path",
|
|
452
|
+
"feature-key",
|
|
453
|
+
"row-group-size",
|
|
454
|
+
"compression",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def compose(self) -> ComposeResult:
|
|
458
|
+
yield SafeHeader()
|
|
459
|
+
yield Static("Morton-sort CSV/Parquet", classes="screen-title")
|
|
460
|
+
with VerticalScroll():
|
|
461
|
+
yield Label("Input path")
|
|
462
|
+
yield Input(placeholder="input.csv or input.parquet", id="input-path")
|
|
463
|
+
yield Label("Output path")
|
|
464
|
+
yield Input(placeholder="output.parquet", id="output-path")
|
|
465
|
+
yield Label("Feature key column (optional)")
|
|
466
|
+
yield Input(placeholder="feature_name", id="feature-key")
|
|
467
|
+
yield Label("Row group size")
|
|
468
|
+
yield Input(value="50000", id="row-group-size")
|
|
469
|
+
yield Label("Compression")
|
|
470
|
+
yield Input(value="zstd", id="compression")
|
|
471
|
+
with Horizontal():
|
|
472
|
+
yield Button("Run", variant="primary", id="run")
|
|
473
|
+
yield Button("Back", id="back")
|
|
474
|
+
yield Footer()
|
|
475
|
+
|
|
476
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
477
|
+
if event.button.id == "back":
|
|
478
|
+
self.action_go_back()
|
|
479
|
+
return
|
|
480
|
+
self._submit()
|
|
481
|
+
|
|
482
|
+
def _submit(self) -> None:
|
|
483
|
+
input_path = self.query_one("#input-path", Input).value.strip()
|
|
484
|
+
output_path = self.query_one("#output-path", Input).value.strip()
|
|
485
|
+
if not input_path or not output_path:
|
|
486
|
+
self.notify("Input and output paths are required.", severity="error")
|
|
487
|
+
return
|
|
488
|
+
feature_key = self.query_one("#feature-key", Input).value.strip() or None
|
|
489
|
+
try:
|
|
490
|
+
row_group_size = _positive_int(
|
|
491
|
+
self.query_one("#row-group-size", Input).value, 50_000
|
|
492
|
+
)
|
|
493
|
+
except ValueError as exc:
|
|
494
|
+
self.notify(str(exc), severity="error")
|
|
495
|
+
return
|
|
496
|
+
compression = self.query_one("#compression", Input).value.strip() or "zstd"
|
|
497
|
+
|
|
498
|
+
def runner() -> dict[str, Any]:
|
|
499
|
+
return run_morton_points(
|
|
500
|
+
input_path,
|
|
501
|
+
output_path,
|
|
502
|
+
feature_key=feature_key,
|
|
503
|
+
row_group_size=row_group_size,
|
|
504
|
+
compression=compression,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
self.app.push_screen(
|
|
508
|
+
RunScreen(
|
|
509
|
+
TaskSpec(
|
|
510
|
+
command=CommandId.MORTON_POINTS,
|
|
511
|
+
title="Morton-sort file",
|
|
512
|
+
runner=runner,
|
|
513
|
+
verify_kind="morton",
|
|
514
|
+
verify_paths=[Path(output_path)],
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class MultiscaleScreen(InputFormScreen):
|
|
521
|
+
INPUT_ORDER = (
|
|
522
|
+
"input-path",
|
|
523
|
+
"output-path",
|
|
524
|
+
"metadata-json",
|
|
525
|
+
"row-group-size",
|
|
526
|
+
"compression",
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def compose(self) -> ComposeResult:
|
|
530
|
+
yield SafeHeader()
|
|
531
|
+
yield Static("Multiscale Points Parquet", classes="screen-title")
|
|
532
|
+
with VerticalScroll():
|
|
533
|
+
yield Label("Input path")
|
|
534
|
+
yield Input(placeholder="input.parquet", id="input-path")
|
|
535
|
+
yield Label("Output path")
|
|
536
|
+
yield Input(placeholder="output.parquet", id="output-path")
|
|
537
|
+
yield Label("Metadata JSON path (optional)")
|
|
538
|
+
yield Input(placeholder="metadata.json", id="metadata-json")
|
|
539
|
+
yield Label("Row group size")
|
|
540
|
+
yield Input(value="50000", id="row-group-size")
|
|
541
|
+
yield Label("Compression")
|
|
542
|
+
yield Input(value="zstd", id="compression")
|
|
543
|
+
with Horizontal():
|
|
544
|
+
yield Button("Run", variant="primary", id="run")
|
|
545
|
+
yield Button("Back", id="back")
|
|
546
|
+
yield Footer()
|
|
547
|
+
|
|
548
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
549
|
+
if event.button.id == "back":
|
|
550
|
+
self.action_go_back()
|
|
551
|
+
return
|
|
552
|
+
self._submit()
|
|
553
|
+
|
|
554
|
+
def _submit(self) -> None:
|
|
555
|
+
input_path = self.query_one("#input-path", Input).value.strip()
|
|
556
|
+
output_path = self.query_one("#output-path", Input).value.strip()
|
|
557
|
+
if not input_path or not output_path:
|
|
558
|
+
self.notify("Input and output paths are required.", severity="error")
|
|
559
|
+
return
|
|
560
|
+
metadata_json = self.query_one("#metadata-json", Input).value.strip() or None
|
|
561
|
+
try:
|
|
562
|
+
row_group_size = _positive_int(
|
|
563
|
+
self.query_one("#row-group-size", Input).value, 50_000
|
|
564
|
+
)
|
|
565
|
+
except ValueError as exc:
|
|
566
|
+
self.notify(str(exc), severity="error")
|
|
567
|
+
return
|
|
568
|
+
compression = self.query_one("#compression", Input).value.strip() or "zstd"
|
|
569
|
+
|
|
570
|
+
def runner() -> dict[str, Any]:
|
|
571
|
+
return run_multiscale_points(
|
|
572
|
+
input_path,
|
|
573
|
+
output_path,
|
|
574
|
+
metadata_json=metadata_json,
|
|
575
|
+
row_group_size=row_group_size,
|
|
576
|
+
compression=compression,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
self.app.push_screen(
|
|
580
|
+
RunScreen(
|
|
581
|
+
TaskSpec(
|
|
582
|
+
command=CommandId.MULTISCALE_POINTS,
|
|
583
|
+
title="Multiscale Points",
|
|
584
|
+
runner=runner,
|
|
585
|
+
verify_kind="multiscale",
|
|
586
|
+
verify_paths=[Path(output_path)],
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
class IndexPermutationsScreen(InputFormScreen):
|
|
593
|
+
INPUT_ORDER = (
|
|
594
|
+
"source-zarr",
|
|
595
|
+
"dest-zarr",
|
|
596
|
+
"points-key",
|
|
597
|
+
"max-rows",
|
|
598
|
+
"row-group-size",
|
|
599
|
+
"compression",
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def __init__(self, *, from_zarr_context: bool = False) -> None:
|
|
603
|
+
super().__init__()
|
|
604
|
+
self.from_zarr_context = from_zarr_context
|
|
605
|
+
|
|
606
|
+
def compose(self) -> ComposeResult:
|
|
607
|
+
yield SafeHeader()
|
|
608
|
+
yield Static("Write index permutations", classes="screen-title")
|
|
609
|
+
with VerticalScroll():
|
|
610
|
+
yield Label("Source Zarr")
|
|
611
|
+
yield Input(id="source-zarr")
|
|
612
|
+
yield Label("Destination Zarr")
|
|
613
|
+
yield Input(id="dest-zarr")
|
|
614
|
+
yield Label("Points key (optional if single element)")
|
|
615
|
+
yield Input(id="points-key")
|
|
616
|
+
yield Label("Max rows (optional)")
|
|
617
|
+
yield Input(id="max-rows")
|
|
618
|
+
yield Label("Row group size")
|
|
619
|
+
yield Input(value="50000", id="row-group-size")
|
|
620
|
+
yield Label("Compression")
|
|
621
|
+
yield Input(value="zstd", id="compression")
|
|
622
|
+
yield Checkbox("Overwrite destination if it exists", id="overwrite")
|
|
623
|
+
yield Static("Conditions (default: all)", classes="section-label")
|
|
624
|
+
with Vertical(id="conditions"):
|
|
625
|
+
for condition in DEFAULT_CONDITIONS:
|
|
626
|
+
yield Checkbox(condition.id, value=True, id=f"cond-{condition.id}")
|
|
627
|
+
with Horizontal():
|
|
628
|
+
yield Button("Run", variant="primary", id="run")
|
|
629
|
+
yield Button("Back", id="back")
|
|
630
|
+
yield Footer()
|
|
631
|
+
|
|
632
|
+
def on_mount(self) -> None:
|
|
633
|
+
if self.app.context.zarr_path:
|
|
634
|
+
self.query_one("#source-zarr", Input).value = self.app.context.zarr_path
|
|
635
|
+
if self.from_zarr_context and self.app.context.points_key:
|
|
636
|
+
self.query_one("#points-key", Input).value = self.app.context.points_key
|
|
637
|
+
super().on_mount()
|
|
638
|
+
|
|
639
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
640
|
+
if event.button.id == "back":
|
|
641
|
+
self.action_go_back()
|
|
642
|
+
return
|
|
643
|
+
self._submit()
|
|
644
|
+
|
|
645
|
+
def _submit(self) -> None:
|
|
646
|
+
source = self.query_one("#source-zarr", Input).value.strip()
|
|
647
|
+
dest = self.query_one("#dest-zarr", Input).value.strip()
|
|
648
|
+
if not source or not dest:
|
|
649
|
+
self.notify("Source and destination Zarr paths are required.", severity="error")
|
|
650
|
+
return
|
|
651
|
+
points_key = self.query_one("#points-key", Input).value.strip() or None
|
|
652
|
+
max_rows_text = self.query_one("#max-rows", Input).value.strip()
|
|
653
|
+
max_rows = None
|
|
654
|
+
if max_rows_text:
|
|
655
|
+
try:
|
|
656
|
+
max_rows = _positive_int(max_rows_text, 0)
|
|
657
|
+
except ValueError as exc:
|
|
658
|
+
self.notify(str(exc), severity="error")
|
|
659
|
+
return
|
|
660
|
+
try:
|
|
661
|
+
row_group_size = _positive_int(
|
|
662
|
+
self.query_one("#row-group-size", Input).value, 50_000
|
|
663
|
+
)
|
|
664
|
+
except ValueError as exc:
|
|
665
|
+
self.notify(str(exc), severity="error")
|
|
666
|
+
return
|
|
667
|
+
compression = self.query_one("#compression", Input).value.strip() or "zstd"
|
|
668
|
+
overwrite = self.query_one("#overwrite", Checkbox).value
|
|
669
|
+
selected = [
|
|
670
|
+
condition.id
|
|
671
|
+
for condition in DEFAULT_CONDITIONS
|
|
672
|
+
if self.query_one(f"#cond-{condition.id}", Checkbox).value
|
|
673
|
+
]
|
|
674
|
+
if not selected:
|
|
675
|
+
self.notify("Select at least one condition.", severity="error")
|
|
676
|
+
return
|
|
677
|
+
all_selected = len(selected) == len(DEFAULT_CONDITIONS)
|
|
678
|
+
condition_ids = None if all_selected else selected
|
|
679
|
+
|
|
680
|
+
def runner() -> dict[str, Any]:
|
|
681
|
+
return run_write_index_permutations(
|
|
682
|
+
source,
|
|
683
|
+
dest,
|
|
684
|
+
points_key=points_key,
|
|
685
|
+
max_rows=max_rows,
|
|
686
|
+
condition_ids=condition_ids,
|
|
687
|
+
overwrite=overwrite,
|
|
688
|
+
row_group_size=row_group_size,
|
|
689
|
+
compression=compression,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
self.app.push_screen(
|
|
693
|
+
RunScreen(
|
|
694
|
+
TaskSpec(
|
|
695
|
+
command=CommandId.INDEX_PERMUTATIONS,
|
|
696
|
+
title="Index permutations",
|
|
697
|
+
runner=runner,
|
|
698
|
+
verify_kind="manifest",
|
|
699
|
+
verify_paths=[Path(dest)],
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def list_tables_task(zarr: str) -> TaskSpec:
|
|
706
|
+
def runner() -> dict[str, Any]:
|
|
707
|
+
return run_list_tables(zarr)
|
|
708
|
+
|
|
709
|
+
return TaskSpec(
|
|
710
|
+
command=CommandId.LIST_TABLES,
|
|
711
|
+
title="List tables",
|
|
712
|
+
runner=runner,
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def codecs_info_task() -> TaskSpec:
|
|
717
|
+
def runner() -> dict[str, Any]:
|
|
718
|
+
from ..codecs.backends import backend_report
|
|
719
|
+
|
|
720
|
+
return backend_report()
|
|
721
|
+
|
|
722
|
+
return TaskSpec(
|
|
723
|
+
command=CommandId.CODECS_INFO,
|
|
724
|
+
title="HTJ2K backends",
|
|
725
|
+
runner=runner,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
class RecompressImagesScreen(InputFormScreen):
|
|
730
|
+
"""Recompress image rasters — the `images recompress` command."""
|
|
731
|
+
|
|
732
|
+
INPUT_ORDER = (
|
|
733
|
+
"source",
|
|
734
|
+
"dest",
|
|
735
|
+
"image-key",
|
|
736
|
+
"quality",
|
|
737
|
+
"chunks",
|
|
738
|
+
"workers",
|
|
739
|
+
"pyramid-levels",
|
|
740
|
+
"pyramid-downscale",
|
|
741
|
+
"pyramid-min-size",
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
def compose(self) -> ComposeResult:
|
|
745
|
+
yield SafeHeader()
|
|
746
|
+
yield Static("Recompress images", classes="screen-title")
|
|
747
|
+
with VerticalScroll():
|
|
748
|
+
yield Label("Source store")
|
|
749
|
+
yield Input(
|
|
750
|
+
value=self.app.context.zarr_path or "",
|
|
751
|
+
placeholder="/path/to/input.zarr",
|
|
752
|
+
id="source",
|
|
753
|
+
)
|
|
754
|
+
yield Label("Destination store")
|
|
755
|
+
yield Input(placeholder="/path/to/output.zarr", id="dest")
|
|
756
|
+
yield Label("Image key (blank = every image)")
|
|
757
|
+
yield Input(placeholder="morphology_focus", id="image-key")
|
|
758
|
+
yield Label("Codec")
|
|
759
|
+
yield Select(
|
|
760
|
+
[
|
|
761
|
+
("HTJ2K (experimental.openjph_htj2k)", CODEC_HTJ2K_OPENJPH),
|
|
762
|
+
("JPEG 2000 (imagecodecs_jpeg2k)", CODEC_JPEG2K),
|
|
763
|
+
],
|
|
764
|
+
value=CODEC_HTJ2K_OPENJPH,
|
|
765
|
+
allow_blank=False,
|
|
766
|
+
id="codec",
|
|
767
|
+
)
|
|
768
|
+
yield Label("Preset")
|
|
769
|
+
yield Select(
|
|
770
|
+
[("lossless", "lossless"), ("balanced", "balanced"), ("small", "small")],
|
|
771
|
+
value="lossless",
|
|
772
|
+
allow_blank=False,
|
|
773
|
+
id="preset",
|
|
774
|
+
)
|
|
775
|
+
yield Label(
|
|
776
|
+
"HTJ2K quality — quantization step relative to the dtype's full range, "
|
|
777
|
+
"lower = better (blank = use preset). Below one input LSB "
|
|
778
|
+
"(1/256 for 8-bit, 1/65536 for 16-bit) it encodes larger than lossless."
|
|
779
|
+
)
|
|
780
|
+
yield Input(placeholder="0.0002", id="quality")
|
|
781
|
+
yield Label("Chunks")
|
|
782
|
+
yield Input(value="auto", placeholder="auto, or one integer per axis", id="chunks")
|
|
783
|
+
yield Label("Workers")
|
|
784
|
+
yield Input(value=str(os.cpu_count() or 1), id="workers")
|
|
785
|
+
yield Checkbox("Write siblings instead of replacing images", id="sibling")
|
|
786
|
+
yield Checkbox("Overwrite destination if it exists", id="overwrite")
|
|
787
|
+
yield Static("Multiscale pyramid", classes="section-label")
|
|
788
|
+
yield Checkbox(
|
|
789
|
+
"Build a pyramid for images that have only one resolution level",
|
|
790
|
+
id="pyramid",
|
|
791
|
+
)
|
|
792
|
+
yield Label("Levels ('auto' halves until the largest axis fits the size below)")
|
|
793
|
+
yield Input(value="auto", id="pyramid-levels")
|
|
794
|
+
yield Label("Downscale between levels")
|
|
795
|
+
yield Input(value="2", id="pyramid-downscale")
|
|
796
|
+
yield Label("Auto stops at this size (px)")
|
|
797
|
+
yield Input(value=str(DEFAULT_PYRAMID_MIN_SIZE), id="pyramid-min-size")
|
|
798
|
+
yield Checkbox("Rebuild images that already have a pyramid", id="pyramid-force")
|
|
799
|
+
with Horizontal():
|
|
800
|
+
yield Button("Run", variant="primary", id="run")
|
|
801
|
+
yield Button("Back", id="back")
|
|
802
|
+
yield Footer()
|
|
803
|
+
|
|
804
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
805
|
+
if event.button.id == "back":
|
|
806
|
+
self.action_go_back()
|
|
807
|
+
return
|
|
808
|
+
self._submit()
|
|
809
|
+
|
|
810
|
+
def _submit(self) -> None:
|
|
811
|
+
source = self.query_one("#source", Input).value.strip()
|
|
812
|
+
dest = self.query_one("#dest", Input).value.strip()
|
|
813
|
+
if not source or not dest:
|
|
814
|
+
self.notify("Source and destination are required.", severity="error")
|
|
815
|
+
return
|
|
816
|
+
if not Path(source).is_dir():
|
|
817
|
+
self.notify(f"Not a directory: {source}", severity="error")
|
|
818
|
+
return
|
|
819
|
+
|
|
820
|
+
codec = str(self.query_one("#codec", Select).value)
|
|
821
|
+
preset = str(self.query_one("#preset", Select).value)
|
|
822
|
+
quality_raw = self.query_one("#quality", Input).value.strip()
|
|
823
|
+
quality: float | None = None
|
|
824
|
+
if quality_raw:
|
|
825
|
+
if codec != CODEC_HTJ2K_OPENJPH:
|
|
826
|
+
self.notify("Quality applies to HTJ2K only.", severity="error")
|
|
827
|
+
return
|
|
828
|
+
try:
|
|
829
|
+
quality = float(quality_raw)
|
|
830
|
+
except ValueError:
|
|
831
|
+
self.notify("Quality must be a number.", severity="error")
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
chunks_raw = self.query_one("#chunks", Input).value.strip() or "auto"
|
|
835
|
+
if chunks_raw == "auto":
|
|
836
|
+
chunks: Any = "auto"
|
|
837
|
+
else:
|
|
838
|
+
try:
|
|
839
|
+
chunks = tuple(int(part) for part in chunks_raw.split())
|
|
840
|
+
except ValueError:
|
|
841
|
+
self.notify(
|
|
842
|
+
"Chunks must be 'auto' or space-separated integers.", severity="error"
|
|
843
|
+
)
|
|
844
|
+
return
|
|
845
|
+
|
|
846
|
+
try:
|
|
847
|
+
workers = _positive_int(self.query_one("#workers", Input).value, os.cpu_count() or 1)
|
|
848
|
+
except ValueError as exc:
|
|
849
|
+
self.notify(str(exc), severity="error")
|
|
850
|
+
return
|
|
851
|
+
|
|
852
|
+
levels_raw = self.query_one("#pyramid-levels", Input).value.strip() or "auto"
|
|
853
|
+
if levels_raw == "auto":
|
|
854
|
+
pyramid_levels: Any = "auto"
|
|
855
|
+
else:
|
|
856
|
+
try:
|
|
857
|
+
pyramid_levels = _positive_int(levels_raw, 1)
|
|
858
|
+
except ValueError:
|
|
859
|
+
self.notify("Pyramid levels must be 'auto' or a positive integer.", severity="error")
|
|
860
|
+
return
|
|
861
|
+
try:
|
|
862
|
+
pyramid_downscale = _positive_int(self.query_one("#pyramid-downscale", Input).value, 2)
|
|
863
|
+
pyramid_min_size = _positive_int(
|
|
864
|
+
self.query_one("#pyramid-min-size", Input).value, DEFAULT_PYRAMID_MIN_SIZE
|
|
865
|
+
)
|
|
866
|
+
except ValueError as exc:
|
|
867
|
+
self.notify(str(exc), severity="error")
|
|
868
|
+
return
|
|
869
|
+
if pyramid_downscale < 2:
|
|
870
|
+
self.notify("Pyramid downscale must be at least 2.", severity="error")
|
|
871
|
+
return
|
|
872
|
+
|
|
873
|
+
image_key = self.query_one("#image-key", Input).value.strip() or None
|
|
874
|
+
sibling = self.query_one("#sibling", Checkbox).value
|
|
875
|
+
overwrite = self.query_one("#overwrite", Checkbox).value
|
|
876
|
+
pyramid = self.query_one("#pyramid", Checkbox).value
|
|
877
|
+
pyramid_force = self.query_one("#pyramid-force", Checkbox).value
|
|
878
|
+
self.app.context.zarr_path = source
|
|
879
|
+
|
|
880
|
+
def runner() -> dict[str, Any]:
|
|
881
|
+
from ..images import recompress_spatialdata
|
|
882
|
+
|
|
883
|
+
return recompress_spatialdata(
|
|
884
|
+
source,
|
|
885
|
+
dest,
|
|
886
|
+
image_key=image_key,
|
|
887
|
+
codec=codec,
|
|
888
|
+
# An explicit quality overrides the preset, so don't send both.
|
|
889
|
+
preset=None if quality is not None else preset,
|
|
890
|
+
chunks=chunks,
|
|
891
|
+
quality=quality,
|
|
892
|
+
sibling=sibling,
|
|
893
|
+
overwrite=overwrite,
|
|
894
|
+
workers=workers,
|
|
895
|
+
pyramid=pyramid or pyramid_force,
|
|
896
|
+
pyramid_levels=pyramid_levels,
|
|
897
|
+
pyramid_downscale=pyramid_downscale,
|
|
898
|
+
pyramid_min_size=pyramid_min_size,
|
|
899
|
+
pyramid_force=pyramid_force,
|
|
900
|
+
).manifest
|
|
901
|
+
|
|
902
|
+
dest_exists = Path(dest).exists()
|
|
903
|
+
task = TaskSpec(
|
|
904
|
+
command=CommandId.RECOMPRESS,
|
|
905
|
+
title="Recompress images",
|
|
906
|
+
runner=runner,
|
|
907
|
+
requires_confirm=dest_exists,
|
|
908
|
+
confirm_message=(
|
|
909
|
+
f"Destination already exists and will be replaced:\n{dest}\n\nProceed?"
|
|
910
|
+
),
|
|
911
|
+
)
|
|
912
|
+
if dest_exists and not overwrite:
|
|
913
|
+
self.notify(
|
|
914
|
+
"Destination exists — tick 'Overwrite destination' to replace it.",
|
|
915
|
+
severity="error",
|
|
916
|
+
)
|
|
917
|
+
return
|
|
918
|
+
self.app.push_screen(ConfirmScreen(task) if task.requires_confirm else RunScreen(task))
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
class TablesToCscScreen(InputFormScreen):
|
|
922
|
+
"""Convert table matrices to CSC — the `tables to-csc` command."""
|
|
923
|
+
|
|
924
|
+
INPUT_ORDER = ("source", "dest", "tables")
|
|
925
|
+
|
|
926
|
+
def compose(self) -> ComposeResult:
|
|
927
|
+
yield SafeHeader()
|
|
928
|
+
yield Static("Convert tables to CSC", classes="screen-title")
|
|
929
|
+
with VerticalScroll():
|
|
930
|
+
yield Static(
|
|
931
|
+
"CSC makes reading one variable (gene) a single contiguous range read "
|
|
932
|
+
"instead of a scan of every row. Values are unchanged.",
|
|
933
|
+
classes="section-label",
|
|
934
|
+
)
|
|
935
|
+
yield Label("Source store")
|
|
936
|
+
yield Input(
|
|
937
|
+
value=self.app.context.zarr_path or "",
|
|
938
|
+
placeholder="/path/to/store.zarr",
|
|
939
|
+
id="source",
|
|
940
|
+
)
|
|
941
|
+
yield Label("Destination store (blank = rewrite source in place)")
|
|
942
|
+
yield Input(placeholder="/path/to/output.zarr", id="dest")
|
|
943
|
+
yield Label("Table keys, comma-separated (blank = all tables)")
|
|
944
|
+
yield Input(placeholder="table", id="tables")
|
|
945
|
+
yield Checkbox("Convert named layers too", value=True, id="layers")
|
|
946
|
+
yield Checkbox("Also convert dense matrices (may increase size)", id="densify")
|
|
947
|
+
yield Checkbox("Overwrite destination if it exists", id="overwrite")
|
|
948
|
+
with Horizontal():
|
|
949
|
+
yield Button("Run", variant="primary", id="run")
|
|
950
|
+
yield Button("Back", id="back")
|
|
951
|
+
yield Footer()
|
|
952
|
+
|
|
953
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
954
|
+
if event.button.id == "back":
|
|
955
|
+
self.action_go_back()
|
|
956
|
+
return
|
|
957
|
+
self._submit()
|
|
958
|
+
|
|
959
|
+
def _submit(self) -> None:
|
|
960
|
+
source = self.query_one("#source", Input).value.strip()
|
|
961
|
+
if not source:
|
|
962
|
+
self.notify("Source store is required.", severity="error")
|
|
963
|
+
return
|
|
964
|
+
if not Path(source).is_dir():
|
|
965
|
+
self.notify(f"Not a directory: {source}", severity="error")
|
|
966
|
+
return
|
|
967
|
+
|
|
968
|
+
dest = self.query_one("#dest", Input).value.strip() or None
|
|
969
|
+
tables_raw = self.query_one("#tables", Input).value.strip()
|
|
970
|
+
tables = [key.strip() for key in tables_raw.split(",") if key.strip()] or None
|
|
971
|
+
layers = self.query_one("#layers", Checkbox).value
|
|
972
|
+
densify = self.query_one("#densify", Checkbox).value
|
|
973
|
+
overwrite = self.query_one("#overwrite", Checkbox).value
|
|
974
|
+
self.app.context.zarr_path = source
|
|
975
|
+
|
|
976
|
+
def runner() -> dict[str, Any]:
|
|
977
|
+
return run_tables_to_csc(
|
|
978
|
+
source,
|
|
979
|
+
dest,
|
|
980
|
+
tables=tables,
|
|
981
|
+
layers=layers,
|
|
982
|
+
densify=densify,
|
|
983
|
+
overwrite=overwrite,
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
in_place = dest is None
|
|
987
|
+
dest_exists = dest is not None and Path(dest).exists()
|
|
988
|
+
if dest_exists and not overwrite:
|
|
989
|
+
# The run would fail on this deep inside the conversion; say so while
|
|
990
|
+
# the form is still on screen and editable.
|
|
991
|
+
self.notify(
|
|
992
|
+
f"Destination already exists: {dest}\nTick overwrite to replace it.",
|
|
993
|
+
severity="error",
|
|
994
|
+
)
|
|
995
|
+
return
|
|
996
|
+
|
|
997
|
+
scope = "All tables" if tables is None else "Tables: " + ", ".join(tables)
|
|
998
|
+
if in_place:
|
|
999
|
+
confirm_message = (
|
|
1000
|
+
f"Rewrite table matrices in place:\n{source}\n\n{scope}\n\n"
|
|
1001
|
+
"Values are preserved; only the sparse layout changes. Proceed?"
|
|
1002
|
+
)
|
|
1003
|
+
else:
|
|
1004
|
+
confirm_message = (
|
|
1005
|
+
f"Replace the existing store:\n{dest}\n\n{scope}\n\n"
|
|
1006
|
+
"Its current contents are deleted before the copy. Proceed?"
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
# Anything that destroys existing data is confirmed, whether that is the
|
|
1010
|
+
# source (in place) or the destination (overwrite).
|
|
1011
|
+
needs_confirm = in_place or dest_exists
|
|
1012
|
+
task = TaskSpec(
|
|
1013
|
+
command=CommandId.TABLES_TO_CSC,
|
|
1014
|
+
title="Tables to CSC",
|
|
1015
|
+
runner=runner,
|
|
1016
|
+
requires_confirm=needs_confirm,
|
|
1017
|
+
confirm_message=confirm_message,
|
|
1018
|
+
)
|
|
1019
|
+
self.app.push_screen(ConfirmScreen(task) if needs_confirm else RunScreen(task))
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
class ConfirmScreen(WriterScreen):
|
|
1023
|
+
BINDINGS = [
|
|
1024
|
+
Binding("enter", "confirm", "Confirm overwrite"),
|
|
1025
|
+
Binding("escape", "cancel", "Cancel"),
|
|
1026
|
+
]
|
|
1027
|
+
|
|
1028
|
+
def __init__(self, task_spec: TaskSpec) -> None:
|
|
1029
|
+
super().__init__()
|
|
1030
|
+
self.task_spec = task_spec
|
|
1031
|
+
self._handled = False
|
|
1032
|
+
|
|
1033
|
+
def compose(self) -> ComposeResult:
|
|
1034
|
+
yield SafeHeader()
|
|
1035
|
+
yield Static("Confirm in-place write", classes="screen-title")
|
|
1036
|
+
yield Static(self.task_spec.confirm_message, id="confirm-message")
|
|
1037
|
+
with Horizontal():
|
|
1038
|
+
yield Button("Confirm overwrite", variant="error", id="confirm")
|
|
1039
|
+
yield Button("Cancel", id="cancel")
|
|
1040
|
+
yield Footer()
|
|
1041
|
+
|
|
1042
|
+
def on_mount(self) -> None:
|
|
1043
|
+
self.query_one("#confirm", Button).focus()
|
|
1044
|
+
|
|
1045
|
+
def action_confirm(self) -> None:
|
|
1046
|
+
self._confirm()
|
|
1047
|
+
|
|
1048
|
+
def action_cancel(self) -> None:
|
|
1049
|
+
self._cancel()
|
|
1050
|
+
|
|
1051
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1052
|
+
event.stop()
|
|
1053
|
+
if event.button.id == "cancel":
|
|
1054
|
+
self._cancel()
|
|
1055
|
+
return
|
|
1056
|
+
self._confirm()
|
|
1057
|
+
|
|
1058
|
+
def _confirm(self) -> None:
|
|
1059
|
+
if self._handled:
|
|
1060
|
+
return
|
|
1061
|
+
self._handled = True
|
|
1062
|
+
self.app.pop_screen()
|
|
1063
|
+
self.app.push_screen(RunScreen(self.task_spec))
|
|
1064
|
+
|
|
1065
|
+
def _cancel(self) -> None:
|
|
1066
|
+
if self._handled:
|
|
1067
|
+
return
|
|
1068
|
+
self._handled = True
|
|
1069
|
+
self.app.pop_screen()
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
class RunScreen(WriterScreen):
|
|
1073
|
+
def __init__(self, task_spec: TaskSpec) -> None:
|
|
1074
|
+
super().__init__()
|
|
1075
|
+
self.task_spec = task_spec
|
|
1076
|
+
|
|
1077
|
+
def compose(self) -> ComposeResult:
|
|
1078
|
+
yield SafeHeader()
|
|
1079
|
+
yield Static(self.task_spec.title, classes="screen-title")
|
|
1080
|
+
yield RichLog(id="run-log", highlight=True, markup=False)
|
|
1081
|
+
yield Footer()
|
|
1082
|
+
|
|
1083
|
+
def on_mount(self) -> None:
|
|
1084
|
+
self.run_task()
|
|
1085
|
+
|
|
1086
|
+
@work(thread=True)
|
|
1087
|
+
def run_task(self) -> None:
|
|
1088
|
+
log = self.query_one("#run-log", RichLog)
|
|
1089
|
+
result: dict[str, Any] | None = None
|
|
1090
|
+
error: str | None = None
|
|
1091
|
+
checks: list[VerifyCheck] = []
|
|
1092
|
+
try:
|
|
1093
|
+
self.app.call_from_thread(
|
|
1094
|
+
log.write, f"Running {self.task_spec.command.value}..."
|
|
1095
|
+
)
|
|
1096
|
+
result = self.task_spec.runner()
|
|
1097
|
+
self.app.call_from_thread(
|
|
1098
|
+
log.write, json.dumps(result, indent=2, sort_keys=True)
|
|
1099
|
+
)
|
|
1100
|
+
checks = self._verify()
|
|
1101
|
+
except WriterCommandError as exc:
|
|
1102
|
+
error = str(exc)
|
|
1103
|
+
self.app.call_from_thread(log.write, error)
|
|
1104
|
+
except Exception as exc:
|
|
1105
|
+
error = "".join(traceback.format_exception_only(exc)).strip()
|
|
1106
|
+
self.app.call_from_thread(log.write, error)
|
|
1107
|
+
|
|
1108
|
+
self.app.call_from_thread(
|
|
1109
|
+
self.app.push_screen,
|
|
1110
|
+
VerifyReportScreen(
|
|
1111
|
+
task_spec=self.task_spec,
|
|
1112
|
+
result=result,
|
|
1113
|
+
checks=checks,
|
|
1114
|
+
error=error,
|
|
1115
|
+
),
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
def _verify(self) -> list[VerifyCheck]:
|
|
1119
|
+
if self.task_spec.verify_kind == "none":
|
|
1120
|
+
return []
|
|
1121
|
+
if self.task_spec.verify_kind == "morton":
|
|
1122
|
+
checks: list[VerifyCheck] = []
|
|
1123
|
+
for path in self.task_spec.verify_paths:
|
|
1124
|
+
checks.extend(verify_morton_parquet(path))
|
|
1125
|
+
return checks
|
|
1126
|
+
if self.task_spec.verify_kind == "multiscale":
|
|
1127
|
+
checks = []
|
|
1128
|
+
for path in self.task_spec.verify_paths:
|
|
1129
|
+
checks.extend(verify_multiscale_parquet(path))
|
|
1130
|
+
return checks
|
|
1131
|
+
if self.task_spec.verify_kind == "manifest":
|
|
1132
|
+
checks = []
|
|
1133
|
+
for path in self.task_spec.verify_paths:
|
|
1134
|
+
checks.extend(verify_index_permutations_manifest(path))
|
|
1135
|
+
return checks
|
|
1136
|
+
return []
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
class VerifyReportScreen(WriterScreen):
|
|
1140
|
+
BINDINGS = [
|
|
1141
|
+
Binding("enter", "go_home", "Home"),
|
|
1142
|
+
Binding("escape", "go_home", "Home"),
|
|
1143
|
+
]
|
|
1144
|
+
|
|
1145
|
+
def __init__(
|
|
1146
|
+
self,
|
|
1147
|
+
*,
|
|
1148
|
+
task_spec: TaskSpec,
|
|
1149
|
+
result: dict[str, Any] | None,
|
|
1150
|
+
checks: list[VerifyCheck],
|
|
1151
|
+
error: str | None,
|
|
1152
|
+
) -> None:
|
|
1153
|
+
super().__init__()
|
|
1154
|
+
self.task_spec = task_spec
|
|
1155
|
+
self.result = result
|
|
1156
|
+
self.checks = checks
|
|
1157
|
+
self.error = error
|
|
1158
|
+
self._handled = False
|
|
1159
|
+
|
|
1160
|
+
def compose(self) -> ComposeResult:
|
|
1161
|
+
yield SafeHeader()
|
|
1162
|
+
yield Static("Run complete", classes="screen-title")
|
|
1163
|
+
with VerticalScroll():
|
|
1164
|
+
if self.error:
|
|
1165
|
+
yield Static(f"Error: {self.error}", id="error-text")
|
|
1166
|
+
elif self.result:
|
|
1167
|
+
yield from self._summary_lines(self.result)
|
|
1168
|
+
if self.checks:
|
|
1169
|
+
passed = all_passed(self.checks)
|
|
1170
|
+
status = "All checks passed" if passed else "Some checks failed"
|
|
1171
|
+
yield Static(f"Verification: {status}", id="verify-summary")
|
|
1172
|
+
table = DataTable(id="verify-table")
|
|
1173
|
+
table.add_columns("Check", "Status", "Detail")
|
|
1174
|
+
for check in self.checks:
|
|
1175
|
+
table.add_row(
|
|
1176
|
+
check.id,
|
|
1177
|
+
"PASS" if check.passed else "FAIL",
|
|
1178
|
+
check.detail,
|
|
1179
|
+
)
|
|
1180
|
+
yield table
|
|
1181
|
+
with Horizontal():
|
|
1182
|
+
yield Button("Home", variant="primary", id="home")
|
|
1183
|
+
yield Button("Quit", id="quit")
|
|
1184
|
+
yield Footer()
|
|
1185
|
+
|
|
1186
|
+
def _summary_lines(self, result: dict[str, Any]) -> ComposeResult:
|
|
1187
|
+
"""Render the headline facts per command; the full JSON is on the run log."""
|
|
1188
|
+
command = self.task_spec.command
|
|
1189
|
+
if command == CommandId.LIST_POINTS:
|
|
1190
|
+
keys = result.get("points_keys", [])
|
|
1191
|
+
yield Static(f"Points keys: {', '.join(keys) or '(none)'}")
|
|
1192
|
+
elif command == CommandId.LIST_TABLES:
|
|
1193
|
+
keys = result.get("table_keys", [])
|
|
1194
|
+
yield Static(f"Table keys: {', '.join(keys) or '(none)'}")
|
|
1195
|
+
elif command == CommandId.CODECS_INFO:
|
|
1196
|
+
yield Static(f"Selected backend: {result.get('selected') or '(none available)'}")
|
|
1197
|
+
for name, info in (result.get("backends") or {}).items():
|
|
1198
|
+
available = "available" if info.get("available") else "not available"
|
|
1199
|
+
probe = "probe OK" if info.get("passes_multicomponent_probe") else "probe FAILED"
|
|
1200
|
+
yield Static(f" {name}: {available}, {probe}")
|
|
1201
|
+
elif command == CommandId.RECOMPRESS:
|
|
1202
|
+
images = result.get("images") or []
|
|
1203
|
+
yield Static(f"Output: {result.get('output')}")
|
|
1204
|
+
yield Static(f"Rasters recompressed: {len(images)}")
|
|
1205
|
+
total = sum(int(image.get("encoded_bytes") or 0) for image in images)
|
|
1206
|
+
if total:
|
|
1207
|
+
yield Static(f"Encoded bytes: {total:,}")
|
|
1208
|
+
backend = (result.get("htj2k") or {}).get("selected")
|
|
1209
|
+
if backend:
|
|
1210
|
+
yield Static(f"HTJ2K backend: {backend}")
|
|
1211
|
+
for entry in result.get("pyramids") or []:
|
|
1212
|
+
if entry.get("action") == "rebuilt":
|
|
1213
|
+
built = ", ".join(entry.get("levels") or [])
|
|
1214
|
+
yield Static(f" pyramid {entry['path']}: {built}")
|
|
1215
|
+
else:
|
|
1216
|
+
yield Static(f" pyramid {entry['path']}: skipped ({entry.get('reason')})")
|
|
1217
|
+
elif command == CommandId.TABLES_TO_CSC:
|
|
1218
|
+
yield Static(f"Output: {result.get('output')}")
|
|
1219
|
+
for table in result.get("tables") or []:
|
|
1220
|
+
matrices = ", ".join(
|
|
1221
|
+
f"{key} {value}" for key, value in (table.get("matrices") or {}).items()
|
|
1222
|
+
)
|
|
1223
|
+
yield Static(f" {table.get('path')}: {matrices or '(no matrices)'}")
|
|
1224
|
+
else:
|
|
1225
|
+
rows = result.get("rows")
|
|
1226
|
+
output = result.get("output")
|
|
1227
|
+
if rows is not None:
|
|
1228
|
+
yield Static(f"Rows: {rows}")
|
|
1229
|
+
if output:
|
|
1230
|
+
yield Static(f"Output: {output}")
|
|
1231
|
+
|
|
1232
|
+
def on_mount(self) -> None:
|
|
1233
|
+
self.query_one("#home", Button).focus()
|
|
1234
|
+
|
|
1235
|
+
def action_go_home(self) -> None:
|
|
1236
|
+
self._go_home()
|
|
1237
|
+
|
|
1238
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1239
|
+
event.stop()
|
|
1240
|
+
if event.button.id == "home":
|
|
1241
|
+
self._go_home()
|
|
1242
|
+
return
|
|
1243
|
+
self._exit()
|
|
1244
|
+
|
|
1245
|
+
def _go_home(self) -> None:
|
|
1246
|
+
if self._handled:
|
|
1247
|
+
return
|
|
1248
|
+
self._handled = True
|
|
1249
|
+
self.app.go_home()
|
|
1250
|
+
|
|
1251
|
+
def _exit(self) -> None:
|
|
1252
|
+
if self._handled:
|
|
1253
|
+
return
|
|
1254
|
+
self._handled = True
|
|
1255
|
+
self.app.exit()
|