"""FlorenceForge 图像缓存层 从 ``dataset.py`` 抽出的按字节预算 LRU 图像 payload 缓存。 设计要点: - 缓存的是 RGB ``bytes`` payload,而非可变的 PIL ``Image`` 对象,避免调用方的数据 增强或多线程处理修改共享对象。 - 按总字节预算淘汰(LRU),避免固定条数缓存高分辨率图像导致 RSS 放大。 - 默认预算为 256 MiB,可通过环境变量 ``FLORENCE_FORGE_IMAGE_CACHE_MAX_BYTES`` 调整。 ``_load_image_cached`` 暴露 ``cache_clear`` / ``cache_info`` / ``cache_bytes`` / ``set_cache_max_bytes`` 几个属性,保留测试与调试入口。``dataset.py`` 重新导出 ``_load_image_cached`` 以保持历史导入路径与单测 patch 目标不变。 """ import os import threading from collections import OrderedDict, namedtuple from pathlib import Path from typing import Tuple from PIL import Image _ImagePayloadCacheInfo = namedtuple( "_ImagePayloadCacheInfo", ["hits", "misses", "maxsize", "currsize"], ) _IMAGE_PAYLOAD_CACHE_DEFAULT_MAX_BYTES = int( os.environ.get("FLORENCE_FORGE_IMAGE_CACHE_MAX_BYTES", str(256 * 1024 * 1024)) ) _image_payload_cache: "OrderedDict[str, Tuple[Tuple[int, int], bytes]]" = OrderedDict() _image_payload_cache_hits = 0 _image_payload_cache_misses = 0 _image_payload_cache_bytes = 0 _image_payload_cache_max_bytes = _IMAGE_PAYLOAD_CACHE_DEFAULT_MAX_BYTES _image_payload_cache_lock = threading.RLock() def _load_image_payload_cached(image_path: str) -> Tuple[Tuple[int, int], bytes]: """缓存图像 RGB payload,避免跨调用复用可变 PIL Image 对象 使用按字节预算的 LRU 缓存策略,避免固定条数缓存高分辨率 RGB bytes 导致 RSS 放大。默认预算可通过 FLORENCE_FORGE_IMAGE_CACHE_MAX_BYTES 调整。 Args: image_path: 图像文件路径 Returns: (图像尺寸, RGB 字节) 元组 Raises: FileNotFoundError: 当图像文件不存在时 IOError: 当图像文件无法解码时 """ global _image_payload_cache_hits global _image_payload_cache_misses global _image_payload_cache_bytes if not os.path.exists(image_path): raise FileNotFoundError(f"图像文件不存在: {image_path}") cache_key = str(Path(image_path).resolve()) with _image_payload_cache_lock: cached = _image_payload_cache.get(cache_key) if cached is not None: _image_payload_cache_hits += 1 _image_payload_cache.move_to_end(cache_key) return cached _image_payload_cache_misses += 1 try: with Image.open(image_path) as img: rgb = img.convert('RGB') payload = rgb.tobytes() except Exception as e: raise IOError(f"无法加载图像 {image_path}: {e}") from e payload_size = len(payload) result = (rgb.size, payload) with _image_payload_cache_lock: if _image_payload_cache_max_bytes <= 0 or payload_size > _image_payload_cache_max_bytes: return result existing = _image_payload_cache.get(cache_key) if existing is not None: _image_payload_cache.move_to_end(cache_key) return existing _image_payload_cache[cache_key] = result _image_payload_cache_bytes += payload_size while _image_payload_cache_bytes > _image_payload_cache_max_bytes and _image_payload_cache: _, (_, evicted_payload) = _image_payload_cache.popitem(last=False) _image_payload_cache_bytes -= len(evicted_payload) return result def _image_payload_cache_clear() -> None: global _image_payload_cache_hits global _image_payload_cache_misses global _image_payload_cache_bytes with _image_payload_cache_lock: _image_payload_cache.clear() _image_payload_cache_hits = 0 _image_payload_cache_misses = 0 _image_payload_cache_bytes = 0 def _image_payload_cache_info() -> _ImagePayloadCacheInfo: with _image_payload_cache_lock: return _ImagePayloadCacheInfo( hits=_image_payload_cache_hits, misses=_image_payload_cache_misses, maxsize=_image_payload_cache_max_bytes, currsize=len(_image_payload_cache), ) def _image_payload_cache_current_bytes() -> int: with _image_payload_cache_lock: return _image_payload_cache_bytes def _set_image_payload_cache_max_bytes(max_bytes: int) -> int: """设置图像 payload 缓存预算,返回旧预算,供测试/诊断使用。""" global _image_payload_cache_bytes global _image_payload_cache_max_bytes old_value = _image_payload_cache_max_bytes with _image_payload_cache_lock: _image_payload_cache_max_bytes = max(0, int(max_bytes)) while _image_payload_cache_bytes > _image_payload_cache_max_bytes and _image_payload_cache: _, (_, evicted_payload) = _image_payload_cache.popitem(last=False) _image_payload_cache_bytes -= len(evicted_payload) return old_value def _load_image_cached(image_path: str) -> Image.Image: """加载图像并复用缓存的 RGB payload。 每次调用都返回新的 PIL Image,避免调用方的数据增强或多线程处理修改共享对象。 cache_clear/cache_info 代理到底层 payload 缓存,保留测试和调试入口。 """ size, payload = _load_image_payload_cached(image_path) return Image.frombytes('RGB', size, payload) _load_image_cached.cache_clear = _image_payload_cache_clear # type: ignore[attr-defined] _load_image_cached.cache_info = _image_payload_cache_info # type: ignore[attr-defined] _load_image_cached.cache_bytes = _image_payload_cache_current_bytes # type: ignore[attr-defined] _load_image_cached.set_cache_max_bytes = _set_image_payload_cache_max_bytes # type: ignore[attr-defined]