Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
5310aaf
CI: use info.cloudDatasetName (roundtrip's real field name)
claude Sep 26, 2026
ba67878
Wire the pyramid loader integration test to the cloud fixture
claude Sep 26, 2026
b320cf1
CI: run Test Cloud API on pyramid-loader PRs
claude Sep 27, 2026
94f01a6
CI: install dask[array] for the pyramid loader integration test
claude Sep 27, 2026
d0b35c6
Promote dask[array] to a runtime dependency
claude Sep 27, 2026
3496778
tests: skip pyramid loader integration when fixture is not accessible
claude Sep 27, 2026
87d0d31
batch_signed_url: retry the batch call before falling back per-member
claude Sep 27, 2026
ad618f0
cloud: log per-page walk and per-attempt retry so silent hangs surface
claude Sep 27, 2026
5ab9c04
filehandler: skip batch signed-URL lookup for single-manifest fetch
claude Sep 27, 2026
d4e31be
cloud: port async signed-URL-set-job family and route default signer …
claude Sep 27, 2026
6e4ca96
batch_signed_url: raise BatchScopeUnreachable instead of silent per-u…
claude Sep 27, 2026
20df586
lightsheetZarr CLI: configure INFO logging so cloud progress is visible
claude Sep 27, 2026
121d998
batch_signed_url: include scope + cause in retry log line
claude Sep 27, 2026
3fdee2c
batch_signed_url: cache scope-unreachable and re-raise on subsequent …
claude Sep 27, 2026
5454223
pyramid.multiscale: propagate BatchScopeUnreachable past _resolve's p…
claude Sep 27, 2026
ce93ada
pyramid.multiscale: surface systemic BatchScopeUnreachable through as…
claude Sep 27, 2026
4b605e1
batch_signed_url: log the async job's happy path so long waits are le…
claude Sep 27, 2026
d7b14b7
cloud.client: reauthenticate on 401/403 mid-request so long jobs surv…
claude Sep 27, 2026
18ec747
signed_url_disk_cache: persistent signed-URL disk cache for reopen
claude Sep 28, 2026
3ffd33c
chore: apply black formatting to disk-cache commit's untouched files
claude Sep 28, 2026
8eb7927
Fix three CI regressions on the disk-cache commit
claude Sep 28, 2026
d7231cc
bridge: bump three drifted matlab_last_sync_hash values
claude Sep 28, 2026
4dff8bf
batch_signed_url: exponential backoff for partial-map retry
claude Sep 28, 2026
e67e17c
bridge: bump batchSignedUrlLookup hash to the exponential-backoff commit
claude Sep 28, 2026
39be040
filehandler: single-file DID fetches bypass batch signed-URL scope
claude Sep 28, 2026
87176eb
lightsheet: eager signed-URL prefetch across pyramid levels
claude Sep 28, 2026
bdd2d6f
lightsheet viewer: timestamp key log lines + upsample fallback on by …
claude Sep 28, 2026
dc91fd3
batch_signed_url: prefetch_scope consults the disk cache
claude Sep 30, 2026
40ec43d
batch_signed_url: carry filesExpireAt/expiresAt from job status into …
claude Sep 30, 2026
b8d49fd
signed_url_disk_cache: warn loudly when refusing to persist un-cachea…
claude Sep 30, 2026
298fabd
bridge: note lightsheet tileBudgetBytes default moved 8 MB -> 32 MB
claude Oct 4, 2026
eb2387a
bridge: bump lightsheet sync hashes to NDI-matlab@5c9e8a8
claude Oct 4, 2026
9f1f33c
bridge: name 5c9e8a8 in lightsheet decision_logs for hash-justify guard
claude Oct 4, 2026
a814f7c
lightsheet viewer: set viewer.dims.units/axis_labels from the pyramid
claude Oct 4, 2026
c129852
lightsheet viewer: default to native multiscale + add loading overlay
claude Oct 4, 2026
e65ed93
tests: loader integration tolerates multiscale default
claude Oct 4, 2026
18f7042
lightsheet viewer: use pint.Unit for viewer.dims.units
claude Oct 4, 2026
c626496
lightsheet viewer: revert multiscale default; keep knob and dims.units
claude Oct 4, 2026
4f6ee46
lightsheet viewer: multiscale default + thread-safe status reporter
claude Oct 4, 2026
2892e18
lightsheet viewer: camera-nudge workaround for the slicer wedge
claude Oct 4, 2026
3733ac6
lightsheet viewer: add Refresh View button
claude Oct 4, 2026
f9ac7bb
lightsheet viewer: NDI Cloud sign-in panel + BETA badge
claude Oct 4, 2026
17a398a
napari slicer-wedge bisect: baseline (v1) + background threads (v2)
claude Oct 4, 2026
6d07226
lightsheet viewer: real zoom nudge in Refresh View; always show NDI C…
claude Oct 4, 2026
7488367
napari slicer-wedge bisect v3: cross-thread Qt bridge + leaner pyramid
claude Oct 4, 2026
f5ec39b
napari slicer-wedge bisect v4: v2 threads + v3 smaller pyramid, no br…
claude Oct 4, 2026
5bb095a
napari slicer-wedge bisect v5: add QApplication pre-create on top of v4
claude Oct 4, 2026
b89e9ad
napari slicer-wedge bisect v6: v5 + cross-thread Qt bridge (predicted…
claude Oct 4, 2026
cd28e6d
lightsheet: surface silent fine-decode failures in the upsample fallback
claude Oct 4, 2026
ca19fd5
lightsheet: stop the misleading "no tiles" heartbeat; count refresh h…
claude Oct 4, 2026
d332e87
lightsheet: trace the refresh-hint chain to find where it breaks
claude Oct 4, 2026
4c51e2d
lightsheet: fix RefreshHint cross-thread marshalling (make it a QObject)
claude Oct 4, 2026
9e2dffc
lightsheet: real debounce + synthetic QWheelEvent in refresh hint
claude Oct 4, 2026
9c5134f
lightsheet: default upsample fallback OFF (it was hiding arrived fine…
claude Oct 5, 2026
0e5f8d3
tests: upsample fallback default is OFF now, update the gate tests
claude Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build-lightsheet-fixture.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,11 @@ jobs:
% them up: on stdout for the log, and via
% GITHUB_OUTPUT so a follow-up job can consume them.
fprintf('CLOUD_DATASET_ID=%s\n', info.cloudDatasetId);
fprintf('DATASET_NAME=%s\n', info.remoteDatasetName);
fprintf('DATASET_NAME=%s\n', info.cloudDatasetName);

fid = fopen(getenv("GITHUB_OUTPUT"), "a");
fprintf(fid, "cloud_dataset_id=%s\n", info.cloudDatasetId);
fprintf(fid, "dataset_name=%s\n", info.remoteDatasetName);
fprintf(fid, "dataset_name=%s\n", info.cloudDatasetName);
fclose(fid);

- name: Write summary
Expand Down
19 changes: 18 additions & 1 deletion .github/workflows/test-cloud-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ on:
# Allow manual runs from the Actions tab
workflow_dispatch:

# Run on pull requests that touch the pyramid loader, its cloud
# fixture, or the workflow itself. Kept path-filtered so a PR that
# only edits, say, gene-pyramid code does not burn cloud API calls
# and bill the two shared test accounts on every push.
pull_request:
paths:
- 'src/ndi/pyramid/**'
- 'tests/test_pyramid_loader_integration.py'
- 'tests/fixtures/lightsheet_cloud_fixture.json'
- 'tests/test_cloud_*.py'
- '.github/workflows/test-cloud-api.yml'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down Expand Up @@ -49,4 +61,9 @@ jobs:
# All live cloud test files, not just test_cloud_live.py -- the
# others were previously exercised only by ci.yml, so they had no
# coverage on days when nobody opened a PR.
pytest tests/test_cloud_*.py -v --tb=short
#
# test_pyramid_loader_integration.py points at the permanent
# lightsheet blob fixture (see tests/fixtures/lightsheet_cloud_fixture.json,
# built by build-lightsheet-fixture.yml). It runs under the
# same account-and-env matrix as the rest of the cloud tests.
pytest tests/test_cloud_*.py tests/test_pyramid_loader_integration.py -v --tb=short
233 changes: 233 additions & 0 deletions napari_slicer_repro_v1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""napari slicer-wedge diagnostic -- v1 (BASELINE: works).

Minimal reproducer skeleton for the slicer wedge seen in the
lightsheet viewer on macOS + napari 0.9.1 + PyQt6. This file is
the control: a lazy dask MultiScaleData pyramid, two channels,
same shape ratios as the production dataset. On the user's own
machine (vanhoosr, 2026-10-04) this version RUNS CORRECTLY --
~1,600 block reads, no vispy "QBasicTimer::start: current
thread's event dispatcher has already been destroyed" warnings,
zoom and pan work.

That means the wedge is caused by something our launcher does
on top of this baseline, NOT by napari/vispy on this shape of
data. Subsequent versions (_v2, _v3, ...) each add one more
piece of the production launcher's startup until the wedge
reproduces. The one that triggers it is the fix target.

Usage:
python napari_slicer_repro_v1.py
# Zoom in. "block read" lines in the terminal tell you
# whether napari ever actually asks for a chunk. Press
# Shift+V to apply the visibility-toggle workaround.
"""

# Diagnostic output BEFORE any heavy imports: if nothing below
# prints, Python isn't actually running this file at all.
import sys

print("[repro] script started", flush=True)
print(f"[repro] python = {sys.version.splitlines()[0]}", flush=True)
print(f"[repro] argv = {sys.argv}", flush=True)

# Guarded imports -- surface any missing dependency as a message,
# not a traceback that might scroll off.
try:
import os
import time

import numpy as np

print("[repro] stdlib + numpy ok", flush=True)
import dask
import dask.array as da

print(f"[repro] dask = {dask.__version__}", flush=True)
import napari

print(f"[repro] napari = {napari.__version__}", flush=True)
import vispy

print(f"[repro] vispy = {vispy.__version__}", flush=True)
import qtpy

print(f"[repro] qtpy = {qtpy.API_NAME}", flush=True)
except Exception as exc:
print(f"[repro] IMPORT FAILED: {type(exc).__name__}: {exc}", flush=True)
sys.exit(1)


# --- fake lightsheet pyramid --------------------------------------
# 4 levels, dyadic, matching the production dataset's shape ratios.
LEVELS = [
(3844, 8751, 7390),
(1922, 4376, 3695),
(961, 2188, 1848),
(481, 1094, 924),
]
N_CHANNELS = 2
DTYPE = np.uint16
CHUNK_SHAPE = {
0: (16, 128, 128),
1: (16, 128, 128),
2: (16, 128, 128),
3: (16, 128, 128),
}


_READ_COUNT = {"n": 0}


def _read_block(level, zi, yi, xi, shape):
"""Fake chunk reader. Prints every time napari asks for a chunk.

If this NEVER prints after the viewer window is up, the slicer
is wedged -- which is the bug this script demonstrates.
"""
_READ_COUNT["n"] += 1
n = _READ_COUNT["n"]
print(
f"[repro] block read #{n}: level={level} indices=({zi},{yi},{xi}) shape={shape}",
flush=True,
)
z, y, x = np.indices(shape)
arr = ((z + zi * shape[0]) + (y + yi * shape[1]) + (x + xi * shape[2])) * (level + 1)
return (arr % 4096).astype(DTYPE)


def _build_level(level, shape_zyx):
"""Build one level as a lazy dask.array of per-chunk delayed reads."""
cz, cy, cx = CHUNK_SHAPE[level]
sz, sy, sx = shape_zyx
nz = -(-sz // cz)
ny = -(-sy // cy)
nx = -(-sx // cx)
blocks = np.empty((nz, ny, nx), dtype=object)
for zi in range(nz):
for yi in range(ny):
for xi in range(nx):
this_shape = (
min(cz, sz - zi * cz),
min(cy, sy - yi * cy),
min(cx, sx - xi * cx),
)
blk = dask.delayed(_read_block)(level, zi, yi, xi, this_shape)
blocks[zi, yi, xi] = da.from_delayed(blk, shape=this_shape, dtype=DTYPE)
return da.block(blocks.tolist())


def build_pyramid():
print("[repro] building lazy dask pyramid ...", flush=True)
t0 = time.monotonic()
per_channel = []
for _c in range(N_CHANNELS):
levels_for_this_channel = []
for lvl_idx, zyx in enumerate(LEVELS):
levels_for_this_channel.append(_build_level(lvl_idx, zyx))
per_channel.append(levels_for_this_channel)
print(
f"[repro] built pyramid in {time.monotonic() - t0:.2f}s "
f"(no chunk read fired during build)",
flush=True,
)
return per_channel


def _attach_unstick_hotkey(viewer, layers):
"""Shift+V applies the visibility-toggle workaround."""
try:
from qtpy.QtCore import QTimer
except ImportError:
return

@viewer.bind_key("Shift-V", overwrite=True)
def _unstick(_viewer):
print("[repro] applying unstick workaround ...", flush=True)
hidden = []
for layer in layers:
try:
if layer.visible:
layer.visible = False
hidden.append(layer)
except Exception:
pass

def _finish():
for layer in hidden:
try:
layer.visible = True
except Exception:
pass
for layer in layers:
try:
layer.refresh()
except Exception:
pass
print(
f"[repro] unstick: toggled {len(hidden)} layer(s). "
"If the slicer was wedged, you should now see "
"'block read' lines when you zoom.",
flush=True,
)

QTimer.singleShot(50, _finish)


def main():
print(
"[repro] env: NAPARI_ASYNC=" + os.environ.get("NAPARI_ASYNC", "<unset>"),
flush=True,
)

per_channel = build_pyramid()

print("[repro] creating napari.Viewer() ...", flush=True)
viewer = napari.Viewer()
print("[repro] Viewer created; adding images ...", flush=True)
layers = []
for c, levels in enumerate(per_channel):
layer = viewer.add_image(
levels,
multiscale=True,
name=f"Ch{c + 1}",
colormap="green" if c == 0 else "magenta",
blending="additive",
contrast_limits=(0, 4096),
)
layers.append(layer)
try:
viewer.dims.set_point(0, LEVELS[0][0] // 2)
except Exception:
pass
print(
"[repro] after add_image: "
+ ", ".join(
f"{lyr.name}: loaded={lyr.loaded} multiscale={lyr.multiscale}" for lyr in layers
),
flush=True,
)
print(
"[repro] zoom/pan now. 'block read' lines => slicer working. "
"No 'block read' + vispy QBasicTimer spam => slicer wedged. "
"Press Shift+V in the viewer window to apply the workaround.",
flush=True,
)
_attach_unstick_hotkey(viewer, layers)

print("[repro] entering napari.run() -- window should open now.", flush=True)
napari.run()

print(f"[repro] session end: total chunk reads = {_READ_COUNT['n']}", flush=True)


if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as exc:
import traceback

print(f"[repro] UNCAUGHT: {type(exc).__name__}: {exc}", flush=True)
traceback.print_exc()
sys.exit(2)
Loading
Loading