From 2a34f47c51c9cbd33b1e437be057f885356ef999 Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:25:24 -0800 Subject: [PATCH 1/3] pyvips version Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- pyproject.toml | 1 + src/spatial_tools/write_tile.py | 114 +++++++++++++------------------- uv.lock | 39 ++++++++++- 3 files changed, 84 insertions(+), 70 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5264716..b26a369 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "tifffile>=2025.9.30", "imagecodecs>=2025.8.2", "scanpy>=1.11.5", + "pyvips[binary]>=3.1.1", ] [build-system] diff --git a/src/spatial_tools/write_tile.py b/src/spatial_tools/write_tile.py index 534460c..4d58ce7 100644 --- a/src/spatial_tools/write_tile.py +++ b/src/spatial_tools/write_tile.py @@ -9,6 +9,7 @@ import PIL from PIL import Image +import pyvips from .rect import Fov, Rect from .vec2 import Vec2 @@ -66,9 +67,6 @@ def iterate_tiles(ctx: "Ctx") -> Generator[Tile]: yield Tile(ctx=ctx, z=z, pos_idx=Vec2(x, y)) -# todo(maximsmol): try pyvips - - # todo(maximsmol): webp-compress every FOV first? # todo(maximsmol): doing this inside-out is probably better # i.e. loop over each fov and add it to each zoom tile that requires it @@ -76,47 +74,18 @@ def iterate_tiles(ctx: "Ctx") -> Generator[Tile]: # todo(maximsmol): iterate over Z levels first and cache the open FOV images? # todo(maximsmol): stitch from high Z to low, reusing previous levels as thumbnails -_cached_fov_imgs: dict[str, Image.Image] = {} -_cached_zoom: int | None = None -_cached_category: str | None = None - - -@contextmanager -def load_image(path: Path) -> Generator[Image.Image]: - try: - with Image.open(path) as img: - yield img - return - except PIL.UnidentifiedImageError: - import tifffile # noqa: PLC0415 - - data = tifffile.imread(path) - yield Image.fromarray(data) - return - def _get_cached_fov_img(tile: Tile, fov: Fov, *, category: str) -> Image.Image: - global _cached_zoom, _cached_category - - if _cached_zoom != tile.z or _cached_category != category: - _cached_fov_imgs.clear() - _cached_zoom = tile.z - _cached_category = category + img = pyvips.Image.new_from_file(fov.paths[category]) - img = _cached_fov_imgs.get(fov.id) - if img is not None: - return img + target_size = tile.fov_size_spx(fov) + img = img.resize( + target_size.x / img.width, + vscale=target_size.y / img.height, + kernel=pyvips.Kernel.NEAREST, + ) - image_path = fov.paths[category] - - with load_image(image_path) as fov_img: - fov_img = fov_img.resize( - tile.fov_size_spx(fov).to_tuple(), resample=Image.Resampling.NEAREST - ) - - _cached_fov_imgs[fov.id] = fov_img.copy() - - return _cached_fov_imgs[fov.id] + return img def write_tile( @@ -131,40 +100,47 @@ def write_tile( start = time.monotonic() ctx.log(f"z={tile.z} x={tile.pos_idx.x} y={tile.pos_idx.y} @ {tile}") - with Image.new(color_mode, tile.resolution().to_tuple()) as res: - total_fovs = 0 - for fov in ctx.fovs: - if not tile.overlaps(fov): - continue - total_fovs += 1 + res_size = tile.resolution() + channel = pyvips.Image.black(res_size.x, res_size.y) + res = channel.bandjoin([channel, channel]) + # todo(maximsmol): support color_mode - fov_pct = total_fovs / len(ctx.fovs) - ctx.log(f" Using {total_fovs}/{len(ctx.fovs)} FOVs ({fov_pct * 100:.2f}%)") + total_fovs = 0 + for fov in ctx.fovs: + if not tile.overlaps(fov): + continue + total_fovs += 1 - i = 0 - for fov in ctx.fovs: - if not tile.overlaps(fov): - continue + fov_pct = total_fovs / len(ctx.fovs) + ctx.log(f" Using {total_fovs}/{len(ctx.fovs)} FOVs ({fov_pct * 100:.2f}%)") - box_pos_spx = tile.fov_pos_spx(fov) + i = 0 + for fov in ctx.fovs: + if not tile.overlaps(fov): + continue + + box_pos_spx = tile.fov_pos_spx(fov) + + progress_pct = i / total_fovs + ctx.log( + f" {i}/{total_fovs} ({progress_pct * 100:.2f}%): {box_pos_spx.x}px, {box_pos_spx.y}px <- FOV {fov.id} @ {fov.pos_str()}" + ) + i += 1 - progress_pct = i / total_fovs - ctx.log( - f" {i}/{total_fovs} ({progress_pct * 100:.2f}%): {box_pos_spx.x}px, {box_pos_spx.y}px <- FOV {fov.id} @ {fov.pos_str()}" - ) - i += 1 + fov_img = _get_cached_fov_img(tile, fov, category=category) + res = res.insert(fov_img, box_pos_spx.x, box_pos_spx.y) + del fov_img - fov_img = _get_cached_fov_img(tile, fov, category=category) - res.paste(fov_img, box=box_pos_spx.to_tuple()) + # if color_mode == "I;16" and rescale is not None: + # lo, hi = rescale.to_tuple() + # res = res.convert("I").point( + # [(x - lo) / (hi - lo) * 255 for x in range(256 * 256)], "L" + # ) - if color_mode == "I;16" and rescale is not None: - lo, hi = rescale.to_tuple() - res = res.convert("I").point( - [(x - lo) / (hi - lo) * 255 for x in range(256 * 256)], "L" - ) + res_p = out_dir / f"{tile.z}/{tile.pos_idx.x}-{tile.pos_idx.y}.webp" + res_p.parent.mkdir(parents=True, exist_ok=True) - res_p = out_dir / f"{tile.z}/{tile.pos_idx.x}-{tile.pos_idx.y}.webp" - res_p.parent.mkdir(parents=True, exist_ok=True) - res.save(res_p, format="webp") + res.write_to_file(res_p) + del res - ctx.log(f" Time {time.monotonic() - start:.1f}s") + ctx.log(f" Time {time.monotonic() - start:.1f}s") diff --git a/uv.lock b/uv.lock index 04340be..84e01c3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = "==3.11.*" resolution-markers = [ "sys_platform == 'darwin'", @@ -921,6 +921,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pyvips" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/6a/282936de9faac6addf6bc8792c18e006489d0023ffd8856b8643f54d0558/pyvips-3.1.1.tar.gz", hash = "sha256:84fe744d023b1084ac2516bb17064cacd41c7f8aabf8e524dd383534941b9301", size = 56951, upload-time = "2025-12-09T18:38:06.355Z" } + +[package.optional-dependencies] +binary = [ + { name = "pyvips-binary" }, +] + +[[package]] +name = "pyvips-binary" +version = "8.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/94/65b69d93df3bef0b45f4ca83b7a231df3caeab110844ab7d0960158ac5bd/pyvips_binary-8.18.0.tar.gz", hash = "sha256:2f9e509de6d0cf04ea9b429ff0649130a9cf04de8a4f0887d2bcb72e3973225a", size = 3725, upload-time = "2026-01-01T11:16:57.306Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/d9/18563c9cccf5852d458e692ca15d87df08f0f06ce327a2388d01ef606009/pyvips_binary-8.18.0-cp37-abi3-macosx_10_15_x86_64.whl", hash = "sha256:6ff72bd6c60bb6cf75b7827083b64e275a15a7d862628b5716998350c17426c8", size = 8383964, upload-time = "2026-01-01T11:16:37.016Z" }, + { url = "https://files.pythonhosted.org/packages/35/96/3c642e25921217c51caff7c1cffcf26bc7f3a6c64f983f2949d8732bffc4/pyvips_binary-8.18.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a570dbf76bb620efc9745d82d6493da504d56b21b035ccd876e358a0c182e018", size = 7500206, upload-time = "2026-01-01T11:16:39.577Z" }, + { url = "https://files.pythonhosted.org/packages/37/5d/01d77f7620b24dace147d11d7ee68a466c29f4463b2d7123376fd89d8a7a/pyvips_binary-8.18.0-cp37-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dad3012233b7b12f48180f2a407a50854e44654f37168fa8d42583d9e4f15882", size = 7645104, upload-time = "2026-01-01T11:16:41.491Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a7/8d8acdae7c507734d9d34c6076700606fec7557fb943cc125fcdfd451678/pyvips_binary-8.18.0-cp37-abi3-manylinux_2_26_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0906be336b8f775e2d33dfe61ffc480ff83c91c08d5eeff904c27c2c5164ff3a", size = 7400818, upload-time = "2026-01-01T11:16:43.595Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/065cdee9c5e004a3fc593e61b7ae56ca1675fd55f7714945f73546beedda/pyvips_binary-8.18.0-cp37-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4ddd4d344f758483d1630a9a08f201ab95162599acc6a8e6c62bb1563e94fe0", size = 7556718, upload-time = "2026-01-01T11:16:45.287Z" }, + { url = "https://files.pythonhosted.org/packages/05/35/3529e40931a92b879b7fa23e8228627dea0a56b0ddd0bd667d49e361ef89/pyvips_binary-8.18.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:076fb0affa2901af0fee90c728ded6eed2c72f00356af9895fa7a1fb6c9a2288", size = 7806440, upload-time = "2026-01-01T11:16:47.331Z" }, + { url = "https://files.pythonhosted.org/packages/21/a7/4588ab9bda60b0ed0d5b2be6caf9bd5f19216328b96825a4cd32ded9a1ff/pyvips_binary-8.18.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:659ef1e4af04b3472e7762a95caa1038fdeea530807c84a23a0f4c706af0338f", size = 7683956, upload-time = "2026-01-01T11:16:49.163Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/8ee7e74a878941c661d25b6518a8a9bf7a2b12c20b28040c0d047798aa21/pyvips_binary-8.18.0-cp37-abi3-win32.whl", hash = "sha256:fd331bcd75bff8651d73d09687d55ac8fb9014baa5682b770a4ea0fbcedf5f97", size = 8323911, upload-time = "2026-01-01T11:16:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/12550311fea85253acbb89808bed4b5f516f8e8245333ee3713d9d55ee52/pyvips_binary-8.18.0-cp37-abi3-win_amd64.whl", hash = "sha256:a67d73683f70c21bf2c336b6d5ddc2bd54ec36db72cc54ab63cb48bc2373feac", size = 8288206, upload-time = "2026-01-01T11:16:53.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a80cba68aef1faea4137d004548003074bc6468b07d5c8a974b6a64b8a8f/pyvips_binary-8.18.0-cp37-abi3-win_arm64.whl", hash = "sha256:0c1f9af910866bc8c2d55182e7a6e8684a828ee4d6084dd814e88e2ee9ec4be3", size = 7492382, upload-time = "2026-01-01T11:16:55.508Z" }, +] + [[package]] name = "pyzmq" version = "27.1.0" @@ -1134,6 +1169,7 @@ dependencies = [ { name = "pillow" }, { name = "py-spy" }, { name = "pyarrow" }, + { name = "pyvips", extra = ["binary"] }, { name = "scanpy" }, { name = "tables" }, { name = "tifffile" }, @@ -1159,6 +1195,7 @@ requires-dist = [ { name = "pillow", specifier = ">=11.2.1" }, { name = "py-spy", specifier = ">=0.4.0" }, { name = "pyarrow", specifier = ">=20.0.0" }, + { name = "pyvips", extras = ["binary"], specifier = ">=3.1.1" }, { name = "scanpy", specifier = ">=1.11.5" }, { name = "tables", specifier = ">=3.10.2" }, { name = "tifffile", specifier = ">=2025.9.30" }, From d3483d689760bf1db2e1909db7924cec15f9a907 Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:39:35 -0800 Subject: [PATCH 2/3] change version Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b26a369..6a59a16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spatial-tools" -version = "0.1.2" +version = "0.1.2+pyvips" description = "" requires-python = "==3.11.*" dependencies = [ From 0b334b4302299c69559582ad644054f0df39d0bc Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:23:04 -0800 Subject: [PATCH 3/3] flatten alpha Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- src/spatial_tools/write_tile.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/spatial_tools/write_tile.py b/src/spatial_tools/write_tile.py index 4d58ce7..0228640 100644 --- a/src/spatial_tools/write_tile.py +++ b/src/spatial_tools/write_tile.py @@ -102,7 +102,10 @@ def write_tile( res_size = tile.resolution() channel = pyvips.Image.black(res_size.x, res_size.y) - res = channel.bandjoin([channel, channel]) + if color_mode == "I;16": + res = channel.cast(pyvips.BandFormat.USHORT) + else: + res = channel.bandjoin([channel, channel]) # todo(maximsmol): support color_mode total_fovs = 0 @@ -128,9 +131,14 @@ def write_tile( i += 1 fov_img = _get_cached_fov_img(tile, fov, category=category) + if fov_img.hasalpha(): + fov_img = fov_img.flatten() + + # todo(maximsmol): use arrayjoin? res = res.insert(fov_img, box_pos_spx.x, box_pos_spx.y) del fov_img + # todo(maximsmol): implement # if color_mode == "I;16" and rescale is not None: # lo, hi = rescale.to_tuple() # res = res.convert("I").point(