Files
hypertower/v3/classes/image_loader.py
T
2026-03-19 16:58:29 +01:00

150 lines
4.9 KiB
Python

"""
CachedImageLoader — shared image loading with optional in-memory cache.
A single instance can be passed to any dataset (SlotDataset, EyeDataset, etc.)
so that images are decoded from disk at most once per run, regardless of how
many folds or loaders reference the same file.
Usage:
loader = CachedImageLoader(enabled=True, workers=4)
loader.warm(paths, preprocessor=my_crop_fn) # optional: parallel pre-fill
img = loader.load(path, preprocessor=my_crop_fn) # returns PIL Image
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Callable, Iterable, Optional
import numpy as np
from PIL import Image
class CachedImageLoader:
"""
Loads PIL Images from disk with an optional shared in-memory cache.
The cache stores decoded, pre-preprocessed images as uint8 numpy arrays
(RGB, HWC). Storing after preprocessing means the preprocessor (e.g.
cropper) runs only once per image across all folds and epochs.
Parameters
----------
enabled : bool
When False the cache is disabled and every call hits disk.
workers : int
Number of threads used by warm(). 0 or 1 = single-threaded.
"""
def __init__(self, *, enabled: bool = True, workers: int = 4) -> None:
self._cache: dict[str, np.ndarray] | None = {} if enabled else None
self._workers = workers
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def enabled(self) -> bool:
return self._cache is not None
def __len__(self) -> int:
return len(self._cache) if self._cache is not None else 0
def load(
self,
path: str | Path,
preprocessor: Optional[Callable[..., Image.Image]] = None,
) -> Image.Image:
"""
Return a PIL Image for *path*.
If the cache is enabled the image is stored after preprocessing so
the preprocessor only runs once. Subsequent calls return a copy
reconstructed from the cached array.
"""
key = str(path)
if self._cache is not None:
cached = self._cache.get(key)
if cached is not None:
return Image.fromarray(cached, mode="RGB")
img = Image.open(path).convert("RGB")
if preprocessor is not None:
img = _call_preprocessor(preprocessor, img, path)
if self._cache is not None:
self._cache[key] = np.asarray(img, dtype=np.uint8)
return img
def warm(
self,
paths: Iterable[str | Path],
preprocessor: Optional[Callable[..., Image.Image]] = None,
) -> None:
"""
Pre-populate the cache for all *paths* (no-op when disabled).
Already-cached paths are skipped so calling warm() multiple times
(e.g. once per fold) is safe and only loads new images.
"""
if self._cache is None:
return
to_warm = [str(p) for p in paths if str(p) not in self._cache]
if not to_warm:
return
already = len(paths if isinstance(paths, (list, tuple)) else list(paths)) - len(to_warm)
print(
f"[image_cache] warming {len(to_warm)} images"
+ (f" ({already} already cached)" if already else ""),
flush=True,
)
def _warm_one(path_str: str) -> None:
if path_str in self._cache: # guard against races
return
img = Image.open(path_str).convert("RGB")
if preprocessor is not None:
img = _call_preprocessor(preprocessor, img, Path(path_str))
self._cache[path_str] = np.asarray(img, dtype=np.uint8)
try:
from tqdm import tqdm
except ImportError:
tqdm = None
if self._workers <= 1:
it = tqdm(to_warm, desc="Warm image cache", unit="img") if tqdm else to_warm
for p in it:
_warm_one(p)
else:
with ThreadPoolExecutor(max_workers=self._workers) as ex:
futures = {ex.submit(_warm_one, p): p for p in to_warm}
it = (
tqdm(as_completed(futures), total=len(futures),
desc="Warm image cache", unit="img")
if tqdm else as_completed(futures)
)
for fut in it:
fut.result()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _call_preprocessor(
fn: Callable[..., Image.Image],
img: Image.Image,
path: Path,
) -> Image.Image:
"""Call preprocessor with (img, path) or just (img) depending on arity."""
try:
return fn(img, path)
except TypeError:
return fn(img)