Coverage for ivatar/pagan_optimized.py: 78%
140 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
1"""
2Optimized pagan avatar generator for ivatar
3Provides 95x+ performance improvement through intelligent caching
4"""
6import contextlib
7import threading
8from io import BytesIO
9from typing import Dict, Optional, Tuple
10from PIL import Image
11from django.conf import settings
12import pagan
15def _fast_scale_pixels(color, layer):
16 """
17 Drop-in replacement for pagan.generator.scale_pixels with identical
18 output.
20 The upstream function iterates ALL IMAGE_SIZE x IMAGE_SIZE native pixels
21 (128x128) for every layer and tests `in layer` (a list, O(n)) to find
22 which virtual pixel a native pixel belongs to - millions of tuple
23 comparisons per avatar. Only the VIRTUAL_RESOLUTION x VIRTUAL_RESOLUTION
24 grid (16x16) can ever pass the bounds check, so iterate that directly
25 and use a set for membership. Produces an identical pixelmap (same
26 iteration order), validated byte-for-byte.
27 """
28 pixelmap = []
29 layer_set = set(layer)
30 ds_x = pagan.generator.dotsize[0]
31 ds_y = pagan.generator.dotsize[1]
32 max_px = int((pagan.generator.MAX_X - (ds_y - 1)) / ds_y) + 1
33 max_py = int((pagan.generator.MAX_Y - (ds_x - 1)) / ds_x) + 1
34 for pix_x in range(max_px):
35 x1 = pix_x * ds_y
36 x2 = x1 + (ds_y - 1)
37 for pix_y in range(max_py):
38 y1 = pix_y * ds_x
39 y2 = y1 + (ds_x - 1)
40 if (pix_x, pix_y) in layer_set:
41 pixelmap.append([(y1, x1), (y2, x2), color])
42 return pixelmap
45def _install_fast_scale_pixels() -> None:
46 """
47 Patch pagan.generator.scale_pixels with the O(256) set-based version.
48 Output is bit-identical (see test_pagan_optimized); only the iteration
49 count drops (~115k -> ~2k per avatar).
50 """
51 pagan.generator.scale_pixels = _fast_scale_pixels
54_install_fast_scale_pixels()
57class OptimizedPagan:
58 """
59 Optimized pagan avatar generator that caches Avatar objects
61 Provides 95x+ performance improvement by caching expensive pagan.Avatar
62 object creation while maintaining 100% visual compatibility
63 """
65 # Class-level cache shared across all instances
66 _avatar_cache: Dict[str, pagan.Avatar] = {}
67 _cache_lock = threading.Lock()
68 _cache_stats = {"hits": 0, "misses": 0, "size": 0}
70 # Cache configuration
71 _max_cache_size = getattr(settings, "PAGAN_CACHE_SIZE", 100) # Max cached avatars
72 _cache_enabled = True # Always enabled - this is the default implementation
74 @classmethod
75 def _get_cached_avatar(cls, digest: str) -> Optional[pagan.Avatar]:
76 """Get cached pagan Avatar object or create and cache it"""
78 # Try to get from cache first
79 with cls._cache_lock:
80 if digest in cls._avatar_cache:
81 cls._cache_stats["hits"] += 1
82 return cls._avatar_cache[digest]
84 # Cache miss - create new Avatar object
85 try:
86 avatar = pagan.Avatar(digest)
88 with cls._cache_lock:
89 # Cache management - remove oldest entries if cache is full
90 if len(cls._avatar_cache) >= cls._max_cache_size:
91 # Remove 20% of oldest entries to make room
92 remove_count = max(1, cls._max_cache_size // 5)
93 keys_to_remove = list(cls._avatar_cache.keys())[:remove_count]
94 for key in keys_to_remove:
95 del cls._avatar_cache[key]
97 # Cache the Avatar object
98 cls._avatar_cache[digest] = avatar
99 cls._cache_stats["misses"] += 1
100 cls._cache_stats["size"] = len(cls._avatar_cache)
102 return avatar
104 except Exception as e:
105 if getattr(settings, "DEBUG", False):
106 print(f"Failed to create pagan avatar {digest}: {e}")
107 return None
109 @classmethod
110 def get_cache_stats(cls) -> Dict:
111 """Get cache performance statistics"""
112 with cls._cache_lock:
113 total_requests = cls._cache_stats["hits"] + cls._cache_stats["misses"]
114 hit_rate = (
115 (cls._cache_stats["hits"] / total_requests * 100)
116 if total_requests > 0
117 else 0
118 )
120 return {
121 "size": cls._cache_stats["size"],
122 "max_size": cls._max_cache_size,
123 "hits": cls._cache_stats["hits"],
124 "misses": cls._cache_stats["misses"],
125 "hit_rate": f"{hit_rate:.1f}%",
126 "total_requests": total_requests,
127 }
129 @classmethod
130 def clear_cache(cls):
131 """Clear the pagan avatar cache (useful for testing or memory management)"""
132 with cls._cache_lock:
133 cls._avatar_cache.clear()
134 cls._cache_stats = {"hits": 0, "misses": 0, "size": 0}
136 @classmethod
137 def generate_optimized(cls, digest: str, size: int = 80) -> Optional[Image.Image]:
138 """
139 Generate optimized pagan avatar
141 Args:
142 digest (str): MD5 hash as hex string
143 size (int): Output image size in pixels
145 Returns:
146 PIL.Image: Resized pagan avatar image, or None on error
147 """
148 try:
149 # Get cached Avatar object (this is where the 95x speedup comes from)
150 avatar = cls._get_cached_avatar(digest)
151 if avatar is None:
152 return None
154 # Resize the cached avatar's image (this is very fast ~0.2ms)
155 # The original pagan avatar is 128x128 RGBA
156 resized_img = avatar.img.resize((size, size), Image.LANCZOS)
158 return resized_img
160 except Exception as e:
161 if getattr(settings, "DEBUG", False):
162 print(f"Optimized pagan generation failed for {digest}: {e}")
163 return None
166# Bounded thread-safe in-memory cache for final PNG bytes
167_pagan_png_cache: Dict[Tuple[str, int], bytes] = {}
168_pagan_png_cache_lock = threading.Lock()
169_MAX_PAGAN_PNG_CACHE_SIZE = getattr(settings, "PAGAN_CACHE_SIZE", 100)
172def _redis_pagan_key(digest: str, size: int) -> str:
173 """
174 Shared (cross-worker) cache key, matching the layout used by the other
175 default avatar styles via _get_cached_default_avatar().
176 """
177 return f"default_avatar:pagan:{digest}:{size}"
180def _redis_get_pagan(digest: str, size: int):
181 from django.core.cache import cache # pylint: disable=import-outside-toplevel
183 with contextlib.suppress(Exception):
184 return cache.get(_redis_pagan_key(digest, size))
185 return None
188def _redis_set_pagan(digest: str, size: int, png_bytes: bytes) -> None:
189 from django.core.cache import cache # pylint: disable=import-outside-toplevel
191 with contextlib.suppress(Exception):
192 timeout = getattr(settings, "DEFAULT_AVATAR_CACHE_TIMEOUT", 604800)
193 cache.set(_redis_pagan_key(digest, size), png_bytes, timeout=timeout)
196def create_optimized_pagan(digest: str, size: int = 80) -> BytesIO:
197 """
198 Create pagan avatar using optimized implementation
199 Returns BytesIO object ready for HTTP response
201 Performance improvement: 95x+ faster than original pagan generation
203 Args:
204 digest (str): MD5 hash as hex string
205 size (int): Output image size in pixels
207 Returns:
208 BytesIO: PNG image data ready for HTTP response
209 """
210 cache_key = (digest, size)
211 with _pagan_png_cache_lock:
212 if cache_key in _pagan_png_cache:
213 return BytesIO(_pagan_png_cache[cache_key])
215 # Shared (cross-worker) cache, mirroring retro/monsterid/identicon/mmng
216 shared_bytes = _redis_get_pagan(digest, size)
217 if shared_bytes:
218 with _pagan_png_cache_lock:
219 _pagan_png_cache[cache_key] = shared_bytes
220 return BytesIO(shared_bytes)
222 try:
223 # Generate optimized pagan avatar
224 img = OptimizedPagan.generate_optimized(digest, size)
226 if img is not None:
227 # Save to BytesIO for HTTP response
228 data = BytesIO()
229 img.save(data, format="PNG")
230 png_bytes = data.getvalue()
232 with _pagan_png_cache_lock:
233 if len(_pagan_png_cache) >= _MAX_PAGAN_PNG_CACHE_SIZE:
234 # Evict oldest 20%
235 remove_count = max(1, _MAX_PAGAN_PNG_CACHE_SIZE // 5)
236 keys_to_remove = list(_pagan_png_cache.keys())[:remove_count]
237 for key in keys_to_remove:
238 del _pagan_png_cache[key]
239 _pagan_png_cache[cache_key] = png_bytes
241 _redis_set_pagan(digest, size, png_bytes)
243 data.seek(0)
244 return data
245 else:
246 # Fallback to original implementation if optimization fails
247 if getattr(settings, "DEBUG", False):
248 print(f"Falling back to original pagan for {digest}")
250 paganobj = pagan.Avatar(digest)
251 img = paganobj.img.resize((size, size), Image.LANCZOS)
252 data = BytesIO()
253 img.save(data, format="PNG")
254 data.seek(0)
255 return data
257 except Exception as e:
258 if getattr(settings, "DEBUG", False):
259 print(f"Pagan generation failed: {e}")
261 # Return simple fallback image on error
262 fallback_img = Image.new("RGBA", (size, size), (100, 100, 150, 255))
263 data = BytesIO()
264 fallback_img.save(data, format="PNG")
265 data.seek(0)
266 return data
269# Management utilities
270def get_pagan_cache_info():
271 """Get cache information for monitoring/debugging"""
272 return OptimizedPagan.get_cache_stats()
275def clear_pagan_cache():
276 """Clear the pagan avatar cache"""
277 OptimizedPagan.clear_cache()
280# Backward compatibility - maintain same interface as original
281def create_pagan_avatar(digest: str, size: int = 80) -> BytesIO:
282 """Backward compatibility alias for create_optimized_pagan"""
283 return create_optimized_pagan(digest, size)