Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ jobs:
assert sys.version_info[:2] == expected, sys.version;
assert sys.version_info.releaselevel == 'final', sys.version"

- name: Verify Zarr selection
run: >
uv run --no-sync python -c
"import sys, zarr;
expected = '3.1.6' if sys.version_info[:2] == (3, 11) else None;
assert expected is None or zarr.__version__ == expected, zarr.__version__;
print(zarr.__version__)"

- name: Run tests
run: uv run --no-sync pytest -q --cov=copick_shared_ui --cov-branch --cov-report=term-missing

Expand All @@ -56,3 +64,6 @@ jobs:

- name: Build artifacts
run: uv build

- name: Inspect wheel metadata
run: uv run --no-sync python tests/inspect_wheel_metadata.py dist/*.whl
13 changes: 6 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "copick-shared-ui"
requires-python = ">=3.10"
requires-python = ">=3.11"
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
Expand All @@ -13,21 +13,20 @@ classifiers = [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Scientific/Engineering :: Image Processing",
]
dynamic = ["version"]
dependencies = [
"qtpy",
"pydantic>=2",
"copick[all]>=1.25.3",
"zarr<3",
"numpy>=1.21.0",
"copick>=2.0.0a1,<3",
"zarr>=3.1.6,<4",
"numpy>=2.0.2",
"superqt"
]
authors = [
Expand Down Expand Up @@ -69,7 +68,7 @@ testing = [
addopts = "--strict-config --strict-markers"
testpaths = ["tests"]
markers = [
"migration_expected_failure: behavior intentionally fixed by the next migration stack layer",
"integration: validation requiring a separately published artifact or live service",
]

[tool.hatch.version]
Expand Down
52 changes: 52 additions & 0 deletions src/copick_shared_ui/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Read-only storage helpers used by shared UI components."""

from typing import TYPE_CHECKING, Any

import zarr
from copick.util.ome import get_level_path, get_multiscales

if TYPE_CHECKING:
from copick.models import CopickTomogram


def open_coarsest_tomogram_array(tomogram: "CopickTomogram") -> Any:
"""Open the final metadata-defined tomogram pyramid level without loading it.

The ordered OME multiscale metadata is the sole authority for pyramid
selection. Array labels and root iteration order are deliberately ignored.
"""
group = zarr.open_group(store=tomogram.zarr(), mode="r")
try:
multiscales = get_multiscales(group)
except KeyError as error:
raise ValueError("OME-Zarr multiscales metadata not found") from error
if not isinstance(multiscales, list) or not multiscales:
raise ValueError("OME-Zarr multiscales metadata is empty")

first_multiscale = multiscales[0]
if not isinstance(first_multiscale, dict):
raise ValueError("OME-Zarr first multiscale entry is malformed")

datasets = first_multiscale.get("datasets")
if not isinstance(datasets, list) or not datasets:
raise ValueError("OME-Zarr first multiscale has no datasets")

level = len(datasets) - 1
try:
dataset_path = get_level_path(group, level)
except (KeyError, IndexError, TypeError) as error:
raise ValueError("OME-Zarr coarsest dataset path is missing") from error
if not isinstance(dataset_path, str) or not dataset_path:
raise ValueError("OME-Zarr coarsest dataset path is missing")

try:
array = group[dataset_path]
except KeyError as error:
raise ValueError(f"OME-Zarr dataset path {dataset_path!r} does not exist") from error

if not isinstance(array, zarr.Array):
raise TypeError(f"OME-Zarr dataset path {dataset_path!r} is not an array")
if array.ndim != 3:
raise ValueError(f"Tomogram dataset {dataset_path!r} must be three-dimensional, got {array.ndim} dimensions")

return array
79 changes: 27 additions & 52 deletions src/copick_shared_ui/workers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,10 @@ def generate_thumbnail_pixmap(self) -> tuple[Optional[Any], Optional[str]]:
return cached_pixmap, None

# Generate thumbnail array
thumbnail_array = self._generate_thumbnail_array(tomogram)
if thumbnail_array is None:
return None, "Failed to generate thumbnail array"
try:
thumbnail_array = self._generate_thumbnail_array(tomogram)
except Exception as error:
return None, str(error)

# Convert to pixmap
pixmap = self._array_to_pixmap(thumbnail_array)
Expand All @@ -241,62 +242,36 @@ def _array_to_pixmap(self, array: Any) -> Optional[Any]:
"""Convert numpy array to platform-specific pixmap."""
pass

def _generate_thumbnail_array(self, tomogram: "CopickTomogram") -> Optional[Any]:
def _generate_thumbnail_array(self, tomogram: "CopickTomogram") -> Any:
"""Generate thumbnail array from tomogram data."""
try:
import numpy as np
import zarr

# Load tomogram data - handle multi-scale zarr properly
zarr_group = zarr.open(tomogram.zarr(), mode="r")

# Get the data array - handle multi-scale structure
if hasattr(zarr_group, "keys") and callable(zarr_group.keys):
# Multi-scale zarr group - get the HIGHEST binning level for faster thumbnails
scale_levels = sorted([k for k in zarr_group.keys() if k.isdigit()], key=int) # noqa: SIM118
if scale_levels:
# Use the highest scale level (most binned/smallest) for thumbnails
highest_scale = scale_levels[-1] # Last element is highest number = most binned
tomo_data = zarr_group[highest_scale]
else:
# Fallback to first key
first_key = list(zarr_group.keys())[0]
tomo_data = zarr_group[first_key]
else:
# Direct zarr array
tomo_data = zarr_group

# Calculate downsampling factor based on data size
target_size = 200
z_size, y_size, x_size = tomo_data.shape
import numpy as np

# Use middle slice for 2D thumbnail
mid_z = z_size // 2
from copick_shared_ui.storage import open_coarsest_tomogram_array

# Calculate downsampling for x and y dimensions
downsample_x = max(1, x_size // target_size)
downsample_y = max(1, y_size // target_size)
tomo_data = open_coarsest_tomogram_array(tomogram)

# Extract and downsample middle slice
slice_data = tomo_data[mid_z, ::downsample_y, ::downsample_x]
# Calculate downsampling factor based on data size
target_size = 200
z_size, y_size, x_size = tomo_data.shape

# Convert to numpy array
slice_array = np.array(slice_data)
# Use middle slice for 2D thumbnail
mid_z = z_size // 2

# Normalize to 0-255 range
slice_array = slice_array.astype(np.float32)
data_min, data_max = slice_array.min(), slice_array.max()
# Calculate downsampling for x and y dimensions
downsample_x = max(1, x_size // target_size)
downsample_y = max(1, y_size // target_size)

if data_max > data_min:
slice_array = ((slice_array - data_min) / (data_max - data_min) * 255).astype(np.uint8)
else:
slice_array = np.zeros_like(slice_array, dtype=np.uint8)
# Extract and downsample middle slice before materializing it.
slice_data = tomo_data[mid_z, ::downsample_y, ::downsample_x]
slice_array = np.array(slice_data)

return slice_array
# Normalize to 0-255 range
slice_array = slice_array.astype(np.float32)
data_min, data_max = slice_array.min(), slice_array.max()

except Exception as e:
print(f"Error generating thumbnail array: {e}")
import traceback
if data_max > data_min:
slice_array = ((slice_array - data_min) / (data_max - data_min) * 255).astype(np.uint8)
else:
slice_array = np.zeros_like(slice_array, dtype=np.uint8)

traceback.print_exc()
return None
return slice_array
4 changes: 2 additions & 2 deletions src/copick_shared_ui/workers/chimerax.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from copick_shared_ui.workers.unified_workers import (
QT_AVAILABLE,
UnifiedDataWorker,
UnifiedThumbnailWorker,
UnifiedWorkerManager,
create_worker_manager,
get_platform_info,
Expand All @@ -16,9 +17,8 @@
from copick.models import CopickRun, CopickTomogram

if is_threading_available():
from copick_shared_ui.workers.base import AbstractThumbnailWorker

class ChimeraXThumbnailWorker(AbstractThumbnailWorker):
class ChimeraXThumbnailWorker(UnifiedThumbnailWorker):
"""ChimeraX-specific thumbnail worker with enhanced UI responsiveness optimizations."""

def __init__(
Expand Down
47 changes: 41 additions & 6 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,30 @@ def gradient_volume(offset: int = 0) -> np.ndarray:
return np.arange(offset, offset + 3 * 6 * 8, dtype=np.float32).reshape(3, 6, 8)


def write_ome_zarr_04(path: Path, datasets: dict[str, np.ndarray], declared_paths: list[str]) -> DummyTomogram:
"""Build an OME-Zarr 0.4 / Zarr v2 fixture without using production helpers."""
group = zarr.open_group(str(path), mode="w")
def write_ome_zarr(
path: Path,
datasets: dict[str, np.ndarray],
declared_paths: list[str],
*,
ome_version: str,
zarr_format: int,
chunks: tuple[int, ...] | None = None,
shards: tuple[int, ...] | None = None,
chunk_key_encoding: dict[str, Any] | None = None,
) -> DummyTomogram:
"""Build a format-specific OME-Zarr fixture without production helpers."""
group = zarr.open_group(store=str(path), mode="w", zarr_format=zarr_format)
for name, values in datasets.items():
group.create_dataset(name, data=values, chunks=(1, 3, 4))
group.attrs["multiscales"] = [
options = {"chunks": chunks or tuple(max(1, size // 2) for size in values.shape)}
if shards is not None:
options["shards"] = shards
if chunk_key_encoding is not None:
options["chunk_key_encoding"] = chunk_key_encoding
group.create_array(name, data=values, **options)

multiscales = [
{
"version": "0.4",
"version": ome_version,
"axes": [
{"name": "z", "type": "space"},
{"name": "y", "type": "space"},
Expand All @@ -72,4 +88,23 @@ def write_ome_zarr_04(path: Path, datasets: dict[str, np.ndarray], declared_path
"datasets": [{"path": dataset_path} for dataset_path in declared_paths],
},
]
if ome_version == "0.4":
group.attrs["multiscales"] = multiscales
elif ome_version == "0.5":
group.attrs["ome"] = {"version": "0.5", "multiscales": multiscales}
else:
raise ValueError(f"Unsupported fixture OME-Zarr version: {ome_version}")

return DummyTomogram(str(path))


def write_ome_zarr_04(path: Path, datasets: dict[str, np.ndarray], declared_paths: list[str]) -> DummyTomogram:
"""Build an OME-Zarr 0.4 / Zarr v2 fixture."""
return write_ome_zarr(
path,
datasets,
declared_paths,
ome_version="0.4",
zarr_format=2,
chunks=(1, 3, 4),
)
30 changes: 30 additions & 0 deletions tests/inspect_wheel_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Inspect built wheel metadata for the shared-UI alpha runtime contract."""

import argparse
import email
import zipfile
from pathlib import Path


def inspect_wheel(path: Path) -> None:
with zipfile.ZipFile(path) as archive:
metadata_name = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA"))
metadata = email.message_from_bytes(archive.read(metadata_name))

requirements = metadata.get_all("Requires-Dist", [])
assert metadata["Requires-Python"] == ">=3.11"
assert any(requirement.startswith("copick<3,>=2.0.0a1") for requirement in requirements), requirements
assert any(requirement.startswith("numpy>=2.0.2") for requirement in requirements), requirements
assert any(requirement.startswith("zarr<4,>=3.1.6") for requirement in requirements), requirements
assert all("copick[all]" not in requirement for requirement in requirements), requirements


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("wheel", type=Path)
arguments = parser.parse_args()
inspect_wheel(arguments.wheel)


if __name__ == "__main__":
main()
42 changes: 42 additions & 0 deletions tests/test_core_direct_reader_fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Validation against the exact artifact published by the copick core alpha."""

import hashlib
import json
import os
import zipfile
from pathlib import Path

import pytest

from copick_shared_ui.storage import open_coarsest_tomogram_array
from tests.helpers import ArrayThumbnailWorker, DummyTomogram

CORE_ALPHA_REVISION = "5b3aff2ae4bf9bcf85b4a1d4132205640171f85b"
CORE_FIXTURE_SHA256 = "c6f8e58c9d89c4d4d76208c08563ad34ed9838952666c49f43b5c0c68652f51b"


@pytest.mark.integration
def test_published_core_alpha_direct_reader_fixture(tmp_path):
configured_path = os.environ.get("COPICK_DIRECT_READER_FIXTURE")
if configured_path is None:
pytest.skip("COPICK_DIRECT_READER_FIXTURE does not name the published core artifact")

archive_path = Path(configured_path)
assert hashlib.sha256(archive_path.read_bytes()).hexdigest() == CORE_FIXTURE_SHA256
with zipfile.ZipFile(archive_path) as archive:
archive.extractall(tmp_path)

fixture_root = tmp_path / "copick-v3-direct-reader-fixture"
manifest = json.loads((fixture_root / "manifest.json").read_text(encoding="utf-8"))
assert manifest["producer"]["source_revision"] == CORE_ALPHA_REVISION

for case in ("boolean", "floating", "integer"):
store_path = fixture_root / manifest["stores"][case]["path"]
tomogram = DummyTomogram(str(store_path))

array = open_coarsest_tomogram_array(tomogram)
thumbnail = ArrayThumbnailWorker(tomogram)._generate_thumbnail_array(tomogram)

assert array.path == manifest["stores"][case]["multiscale"]["datasets"][-1]["path"]
assert thumbnail.ndim == 2
assert thumbnail.size > 0
20 changes: 20 additions & 0 deletions tests/test_package_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Runtime metadata gates for the shared-UI 2.0 alpha line."""

import tomllib
from pathlib import Path

PROJECT_ROOT = Path(__file__).parents[1]


def test_alpha_runtime_contract_is_explicit():
project = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]

assert project["requires-python"] == ">=3.11"
assert set(project["dependencies"]) >= {
"copick>=2.0.0a1,<3",
"numpy>=2.0.2",
"zarr>=3.1.6,<4",
}
assert all("copick[all]" not in requirement for requirement in project["dependencies"])
assert "Programming Language :: Python :: 3.10" not in project["classifiers"]
assert "Programming Language :: Python :: 3.14" in project["classifiers"]
Loading
Loading