Skip to content

Commit 02b6456

Browse files
Aaron Kanzerclaude
authored andcommitted
0.2.0: fixes and bounded previews from a full-archive stress test
Fixed: percent-encoding of paths with #, spaces, non-ASCII (39 datasets were unreachable); decoded listing names; one- and two-word dataset ids; a temp-file race on concurrent cold catalog loads; unbounded crawls in find_zarr/thumbnail on million-file trees (20+ minutes); manifest size guard (710 MB gzipped on a 4.7 M-file dataset); strip-sampled preview shape; clear errors for renamed and landing-zone paths; tiff_info on files without a buildable series. Added: preview_plane (bounded reads for any file size: pyramid level, row sampling by offset, strip sampling, tiled centre region), read_region, tiff_info, terafly_levels/terafly_thumbnail for fMOST trees, first_images, extensions_under, manifest_size. HttpFile: 256 KB blocks for metadata, direct range requests for large reads (21.7 GB OME-TIFF open 81 MB to 25 MB; 1 GB page 108 s to 27 s). Parallel pool for latency-bound sampling only. imagecodecs and pillow become dependencies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDpV8346GxohHCft1jrrGT
1 parent dd42049 commit 02b6456

14 files changed

Lines changed: 921 additions & 69 deletions

‎CHANGELOG.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,61 @@
11
# Changelog
22

3+
## 0.2.0
4+
5+
Everything here came out of running the package against the whole archive
6+
(random samples across techniques, the largest trees and files, odd paths,
7+
sixteen threads on a cold cache) on 2026-09-08.
8+
9+
Fixed:
10+
11+
- Paths with `#`, spaces or non-ASCII characters are percent-encoded
12+
(`encode_path`, `encode_url`, idempotent). An unencoded `#` was a URL
13+
fragment, so 39 inventory datasets could not be listed or read.
14+
- Directory-listing names are decoded (`Virus_tracing-B1-#6`), URLs keep
15+
the server's encoding.
16+
- Two-word and one-word dataset ids (`act-nod`, 861 of them) were rejected
17+
by the id pattern.
18+
- Six concurrent cold catalog loads crashed on a shared temp file name;
19+
each writer now uses its own.
20+
- `walk()`/`thumbnail()` sat for over twenty minutes on a 1.6 million file
21+
fMOST tree. `find_zarr()` now has a directory budget, `thumbnail()`
22+
uses a bounded first-folder search (`first_images`), and TeraFly trees
23+
are recognised and previewed from their coarsest resolution folder.
24+
- `manifest()` refuses manifests over 64 MB gzipped (a 4.7 million file
25+
dataset's is 710 MB) with `ManifestTooLargeError`; `walk()` and
26+
`find_zarr()` fall back to crawling. `manifest_size()` is the cheap
27+
check.
28+
- A strip-sampled preview came out one pixel wide (decoded strip shape was
29+
misread).
30+
- A dataset renamed on the server since the inventory was built, and a
31+
dataset still under the landing zone (`/bil/lz/`), each get a clear
32+
`BilNotFoundError` instead of a bare 404.
33+
- `tiff_info()` no longer fails on files where tifffile cannot build a
34+
series ("incompatible keyframe"); pages are reported anyway.
35+
36+
Added:
37+
38+
- `preview_plane()`: bounded preview of a TIFF of any size (coarsest
39+
pyramid level, or every k-th row by offset for uncompressed planes, or
40+
sampled strips, or a centre region of a tiled page). A 10 GB single-strip
41+
plane previews from 63 MB of reads in 11 s.
42+
- `read_region()`: a rectangle of a tiled page through tifffile's zarr
43+
store, fetching only the covering tiles. `tiff_info()`: shape, pages,
44+
tiling and pyramid levels from the IFDs alone.
45+
- `terafly_levels()`, `terafly_thumbnail()`, `first_images()`,
46+
`extensions_under()`, `manifest_size()`.
47+
- `HttpFile` uses 256 KB blocks for metadata walks and one direct range
48+
request for any read of a block or more. Opening a 21.7 GB, 92-page
49+
BigTIFF went from 81 MB in 78 requests to 25 MB; reading a 1 GB page
50+
went from 108 s at 10 MB/s to 27 s at 38 MB/s.
51+
- Latency-bound sampling reads (rows 20 MB apart, TeraFly blocks) run
52+
through a small thread pool; bulk transfers stay sequential.
53+
- `imagecodecs` and `pillow` are now dependencies: fMOST blocks are LZW,
54+
and ImageJ Deflate stacks that libdeflate rejects with
55+
INSUFFICIENT_SPACE decode with Pillow.
56+
- Unsupported-format errors name the tool for the extension found (glymur
57+
for `.jp2`, navis for `.swc`, nibabel, h5py, numpy for `.dax`).
58+
359
## 0.1.0
460

561
First release.

‎README.md‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,18 @@ The Brain Image Library is the BRAIN Initiative's repository for whole-brain mic
3636

3737
BIL's own [`brainimagelibrary`](https://pypi.org/project/brainimagelibrary/) package (py-brain-sdk, 0.0.23, GPL-3.0) wraps the metadata API and downloads datasets, with resumable transfers and citation lookups. Its source (read, not assumed, on 2026-09-08) contains no image reading at all: the only route from a BIL id to pixels is `DatasetInventory.download()`, which fetches the files to disk. For a 27 GB light-sheet stack that is the whole stack, to look at one slice.
3838

39+
This package was then run against the archive rather than a handful of friendly datasets: random samples across every technique, the largest trees (4.7 million files), the largest single files (a 21.7 GB OME-TIFF, a 10 GB single-strip plane, 199 GB TIFFs), paths with `#` and spaces and non-ASCII characters, two-word and one-word dataset ids, stale inventory paths, landing-zone paths, and sixteen threads on a cold cache. What that found is fixed and covered by tests; what it cannot read yet is stated below.
40+
3941
This package is the other half. It reads BIL in place:
4042

4143
- **A typed, in-memory catalog of the whole archive** from BIL's daily inventory TSV, with structured filters and a light-sheet finder that unions the inventory's technique field with BIL's fulltext index. The technique field alone says light sheet for 348 datasets; the union finds 808. The rest are tagged `other` and only say light sheet in their abstract or instrument record.
4244
- **Single-slice TIFF reads** straight from the download server. The dominant BIL layout is one TIFF per z-plane, about 16 MB each; one slice is one request.
4345
- **OME-Zarr stores** opened lazily through zarr's HTTP store, with pyramid levels checked against what the server actually has (one BIL store declares eight levels and serves seven).
4446
- **Thumbnails that read as little as possible**: the middle slice of a TIFF stack, or the coarsest level of a zarr pyramid.
45-
- **A seekable HTTP file object** (`HttpFile`) so tifffile fetches only the pages you ask for from a large multi-page TIFF.
46-
- **BIL's per-dataset manifest** (path, size, MD5, URL for every file) as one gzipped GET, so a deep tree lists in one request.
47+
- **Bounded previews of files of any size.** `preview_plane()` reads the coarsest pyramid level, or every k-th row by offset from an uncompressed plane, or a sample of strips, or a centre region of a tiled page. A 10 GB single-strip MERFISH mosaic previews from 63 MB of reads; `asarray()` would fetch all 10 GB.
48+
- **fMOST TeraFly trees** (`RES_<x>x<y>x<z>_/` folders, 985 datasets) previewed from the coarsest resolution folder, stitched. The full-resolution folder of one such brain holds 1.6 million files and is never listed.
49+
- **A seekable HTTP file object** (`HttpFile`) so tifffile fetches only the IFDs, pages, tiles or regions you ask for from a large TIFF, with `read_region()` for a rectangle of a tiled page.
50+
- **BIL's per-dataset manifest** (path, size, MD5, URL for every file) as one gzipped GET, so a deep tree lists in one request. Guarded by size: a 4.7 million file dataset's manifest is 710 MB gzipped, and `walk()` crawls instead of downloading that.
4751

4852
Measured on 2026-09-08 from a residential connection, against the live archive:
4953

@@ -52,12 +56,19 @@ Measured on 2026-09-08 from a residential connection, against the live archive:
5256
| Load the full catalog (14,224 datasets) | 1.0 s cold, 0.3 s from the on-disk copy |
5357
| Light-sheet datasets found | 348 by technique field, 808 with fulltext union |
5458
| Read one 16 MB light-sheet slice (4501 x 3828 uint16) | 0.40 s, 45 MB/s single stream |
55-
| Thumbnail of a 1,923-slice, 27 GB stack | 0.25 s to 0.56 s, one file read |
59+
| Thumbnail of a 1,923-slice, 27 GB stack | 0.3 s, one file read |
5660
| Thumbnail of a 50 GB OME-Zarr store (848 x 6300 x 9600) | 0.65 s, coarsest level only |
57-
| Eight parallel streams | 19 MB/s aggregate, slower than one stream, so reads are sequential |
61+
| Thumbnail of a 4.5 TB fMOST TeraFly brain | 4 to 5 s, coarsest resolution folder, about 25 small block reads |
62+
| Preview of a 10 GB single-strip plane (84289 x 61974 uint16) | 11 s, 512 rows fetched by offset |
63+
| Open a 21.7 GB, 92-page BigTIFF OME-TIFF and read a 512 x 512 region | 7 s to walk the IFDs, 1.3 s for the region |
64+
| Read one full 1 GB page of that file | 27 s at 38 MB/s in 6 range requests |
65+
| Eight parallel streams on 16 MB slices | 32 MB/s aggregate against 39 MB/s for one stream |
5866
| Manifest for a 1,923-file dataset | 352 KB gzipped, one request |
67+
| 1,000 full metadata records | 1.4 s via batched POST |
68+
69+
Bulk transfers are sequential because parallel streams measured slower. Sampling reads (rows 20 MB apart, TeraFly blocks) are latency-bound and go through a small pool: 512 sequential range requests took 68 s for 63 MB, eight workers take a few seconds.
5970

60-
Dependencies are `requests`, `numpy` and `tifffile`. zarr and pandas are extras.
71+
Dependencies are `requests`, `numpy`, `tifffile`, `imagecodecs` (LZW and Deflate strips are common in BIL TIFFs) and `pillow` (a second decoder for ImageJ Deflate stacks that libdeflate rejects). zarr and pandas are extras.
6172

6273
## Data license
6374

@@ -113,7 +124,7 @@ bil.find_zarr("ace-cup-eel") # ['https://download.brainimagelibrary.
113124
bil.download(entry, "out/") # the one function that writes image bytes to disk
114125
```
115126

116-
Any of these accept a BIL id, a `Dataset`, a `/bil/data/...` path from the metadata, or a download-server URL.
127+
Any of these accept a BIL id, a `Dataset`, a `/bil/data/...` path from the metadata, or a download-server URL. Paths are percent-encoded for you: 39 inventory paths carry spaces, `#` or non-ASCII characters, and an unencoded `#` is a URL fragment. Two things the inventory gets wrong are reported rather than hidden: a directory renamed on the server since the inventory was built (the error names the parent to list) and a dataset still under BIL's landing zone, which is not public.
117128

118129
## Images
119130

@@ -125,7 +136,22 @@ bil.thumbnail("ace-bin-run", max_size=512) # middle sli
125136
bil.thumbnail("ace-bin-run", index=100, channel="ch02")
126137
```
127138

128-
Formats this package does not decode raise `UnsupportedFormatError` naming what does: JPEG 2000 (`.jp2`, 5,787 datasets, mostly STPT and fMOST sections; use `download()` then glymur), Imaris (`.ims`, h5py), NIfTI. Reading `.jp2` in place is the obvious next addition; the format has resolution levels built in, so a thumbnail should not need the whole file.
139+
### Previews of large files
140+
141+
```python
142+
bil.tiff_info(entry) # pages, shape, dtype, tiled, pyramid levels, from the IFDs only
143+
bil.preview_plane(entry, max_size=512) # bounded read whatever the file size, see the table above
144+
bil.read_region(entry, rows=(16000, 18048), cols=(7000, 9048), page=0) # tiles covering a rectangle, [zarr] extra
145+
bil.terafly_levels("ace-cap-cop") # [((793, 1268, 350), url), ...] coarsest first, for fMOST trees
146+
bil.terafly_thumbnail("ace-cap-cop") # one stitched plane through the middle of the coarsest level
147+
bil.first_images("ace-owl-cot") # the first folder of TIFFs by bounded descent, never a crawl
148+
```
149+
150+
`thumbnail()` tries these in order: a TeraFly tree, an OME-Zarr store, then the first folder of TIFFs. It never lists a whole tree; the unbounded version sat on a 1.6 million file fMOST dataset for over twenty minutes before the bound existed.
151+
152+
### What it does not read
153+
154+
Formats this package does not decode raise `UnsupportedFormatError` naming what does. Across the archive's techniques the big one is JPEG 2000 (`.jp2`, 5,787 datasets: most STPT sections, viral tracing and enhancer-labelling brains): `download()` then glymur. Also Imaris `.ims` (HDF5, h5py), NIfTI (nibabel), MERFISH `.dax` frames (raw uint16, numpy), SWC morphologies (navis), and tables (pandas). Reading `.jp2` in place is the obvious next addition; the format has resolution levels built in, so a preview should not need the whole file.
129155

130156
### OME-Zarr
131157

@@ -162,7 +188,7 @@ $ scigantic-bil thumbnail ace-bin-run slice.png --size 512
162188

163189
## Testing
164190

165-
Every test runs live against BIL, no mocks, the same philosophy as the rest of the scigantic-* packages. The suite takes about 15 seconds. CI runs Python 3.10 through 3.14 plus `mypy --strict`.
191+
Every test runs live against BIL, no mocks, the same philosophy as the rest of the scigantic-* packages. The suite takes one to two minutes depending on the server and includes the stress cases above: the 21 GB OME-TIFF, the 10 GB plane, the `#` path, the 4.7 million file manifest guard, the TeraFly tree, the Pillow fallback, six threads loading the catalog cold. CI runs Python 3.10 through 3.14 plus `mypy --strict`.
166192

167193
## License
168194

‎pyproject.toml‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "scigantic-bil"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Search the Brain Image Library (14,000+ BICCN/BICAN brain microscopy datasets, 6 PB) and read light-sheet, fMOST and STPT volumes over HTTP without downloading them: a typed catalog over BIL's daily inventory and metadata API, lazy file listing, single-slice TIFF reads, thumbnails, and OME-Zarr access."
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -31,6 +31,15 @@ dependencies = [
3131
"requests>=2.28,<3",
3232
"numpy>=1.24,<3",
3333
"tifffile>=2023.7,<2027",
34+
# tifffile decodes LZW, Deflate and JPEG-in-TIFF only through imagecodecs.
35+
# BIL's fMOST TeraFly blocks are LZW (ace-cap-cop, verified 2026-09-08);
36+
# without this, read_tiff() on them raises "requires the 'imagecodecs'
37+
# package". Binary wheels exist for every CI target.
38+
"imagecodecs>=2023.9",
39+
# Fallback decoder for TIFFs tifffile rejects (ImageJ Deflate stacks whose
40+
# strips overrun their declared rows: libdeflate says INSUFFICIENT_SPACE,
41+
# Pillow decodes them). Also renders the CLI's thumbnail PNGs.
42+
"pillow>=10,<13",
3443
]
3544

3645
[project.optional-dependencies]
@@ -48,7 +57,7 @@ zarr = [
4857
"aiohttp>=3.9,<4; python_version >= '3.11'",
4958
]
5059
pandas = ["pandas>=1.5,<4"]
51-
dev = ["pytest>=7", "mypy>=1.10", "types-requests", "pandas-stubs", "pillow>=10"]
60+
dev = ["pytest>=7", "mypy>=1.10", "types-requests", "pandas-stubs"]
5261

5362
[project.urls]
5463
Homepage = "https://scigantic.com"

‎src/scigantic_bil/__init__.py‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,56 @@
1515
from .api import fulltext, query, retrieve, retrieve_many
1616
from .cache import cache_dir, clear as clear_cache, disable_cache, enable_cache, is_cache_enabled
1717
from .catalog import BilCatalog, available_inventory_dates
18-
from .files import download, find, find_zarr, is_store_dir, list_files, manifest, resolve_url, walk
18+
from .files import (
19+
MANIFEST_MAX_BYTES,
20+
ManifestTooLargeError,
21+
download,
22+
extensions_under,
23+
find,
24+
find_zarr,
25+
first_images,
26+
is_store_dir,
27+
list_files,
28+
manifest,
29+
manifest_size,
30+
resolve_url,
31+
walk,
32+
)
1933
from .images import (
2034
HttpFile,
2135
UnsupportedFormatError,
2236
downsample,
2337
open_zarr,
38+
preview_plane,
39+
read_region,
2440
read_stack,
2541
read_tiff,
2642
slices,
43+
terafly_levels,
44+
terafly_thumbnail,
2745
thumbnail,
46+
tiff_info,
2847
zarr_levels,
2948
zarr_thumbnail,
3049
)
31-
from .models import Contributor, Dataset, DatasetDetail, FileEntry, Publication, dataset_url
50+
from .models import (
51+
Contributor,
52+
Dataset,
53+
DatasetDetail,
54+
FileEntry,
55+
Publication,
56+
dataset_url,
57+
encode_path,
58+
encode_url,
59+
)
3260

3361
__all__ = [
3462
"__version__",
3563
"BilError",
3664
"BilNotFoundError",
3765
"UnsupportedFormatError",
66+
"ManifestTooLargeError",
67+
"MANIFEST_MAX_BYTES",
3868
"BilCatalog",
3969
"available_inventory_dates",
4070
"Dataset",
@@ -43,22 +73,32 @@
4373
"Publication",
4474
"FileEntry",
4575
"dataset_url",
76+
"encode_path",
77+
"encode_url",
4678
"query",
4779
"fulltext",
4880
"retrieve",
4981
"retrieve_many",
5082
"list_files",
5183
"manifest",
84+
"manifest_size",
5285
"walk",
5386
"find",
5487
"find_zarr",
88+
"first_images",
5589
"is_store_dir",
5690
"download",
91+
"extensions_under",
5792
"resolve_url",
5893
"read_tiff",
94+
"read_region",
95+
"preview_plane",
96+
"tiff_info",
5997
"read_stack",
6098
"slices",
6199
"thumbnail",
100+
"terafly_levels",
101+
"terafly_thumbnail",
62102
"downsample",
63103
"open_zarr",
64104
"zarr_levels",

‎src/scigantic_bil/_version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.0"
1+
__version__ = "0.2.0"

‎src/scigantic_bil/cache.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def clear() -> int:
120120
Returns how many files were removed."""
121121
d = _resolve_dir()
122122
n = 0
123-
for pattern in ("*.json", "*.part", "inventory-*.tsv"):
123+
for pattern in ("*.json", "*.part", "inventory-*.tsv", "inventory-*.tsv.*.part"):
124124
for f in d.glob(pattern):
125125
f.unlink()
126126
n += 1

‎src/scigantic_bil/catalog.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919

2020
import csv
2121
import io
22+
import os
2223
import re
24+
import uuid
2325
from collections import Counter
2426
from dataclasses import dataclass, field
2527
from typing import TYPE_CHECKING, Any, Iterable, Iterator
@@ -57,9 +59,15 @@ def _inventory_text(date: str, refresh: bool = False) -> str:
5759
resp = send("GET", f"{_INVENTORY_DIR}{date}.tsv", timeout=300.0)
5860
text = resp.content.decode("utf-8")
5961
if cache.is_cache_enabled():
60-
tmp = path.with_suffix(".tsv.part")
62+
# Unique per writer: two threads loading the catalog cold at the same
63+
# time (a notebook with a thread pool, a test session) must not share
64+
# a temp path, or the second os.replace() fails with FileNotFoundError
65+
# once the first has moved it. Measured: 2 of 6 concurrent cold loads
66+
# crashed before this. Both write an identical immutable snapshot, so
67+
# last-writer-wins is fine.
68+
tmp = path.with_suffix(f".tsv.{uuid.uuid4().hex}.part")
6169
tmp.write_text(text, encoding="utf-8")
62-
tmp.replace(path)
70+
os.replace(tmp, path)
6371
return text
6472

6573

0 commit comments

Comments
 (0)