setiastrosuitepro 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,666 @@
1
+ # pro/memory_utils.py
2
+ """
3
+ Memory management utilities for Seti Astro Suite Pro.
4
+
5
+ Provides:
6
+ - Memory-mapped array creation for large datasets
7
+ - Reusable buffer pools to reduce allocation overhead
8
+ - Lazy image loading with preview-first strategy
9
+ """
10
+ from __future__ import annotations
11
+ import os
12
+ import tempfile
13
+ import hashlib
14
+ import numpy as np
15
+ from pathlib import Path
16
+ from typing import Tuple, Optional, Dict, Any
17
+ import threading
18
+ import weakref
19
+
20
+
21
+ # ============================================================================
22
+ # COPY-ON-WRITE ARRAY WRAPPER
23
+ # ============================================================================
24
+
25
+ class CopyOnWriteArray:
26
+ """
27
+ A wrapper that defers copying a numpy array until it's actually modified.
28
+
29
+ This is used to optimize duplicate_document: instead of copying the
30
+ full image immediately, we share the source array and only copy when
31
+ the duplicate is about to be modified (via apply_edit).
32
+
33
+ Usage:
34
+ cow = CopyOnWriteArray(source_array)
35
+ arr = cow.get_array() # Returns view of source (no copy)
36
+ arr = cow.get_writable() # Forces copy if not already copied
37
+ """
38
+
39
+ __slots__ = ('_source', '_copy', '_lock', '_copied')
40
+
41
+ def __init__(self, source: np.ndarray):
42
+ """
43
+ Initialize with source array (no copy made yet).
44
+
45
+ Args:
46
+ source: The source numpy array to potentially copy later
47
+ """
48
+ self._source = source
49
+ self._copy: Optional[np.ndarray] = None
50
+ self._lock = threading.Lock()
51
+ self._copied = False
52
+
53
+ def get_array(self) -> np.ndarray:
54
+ """
55
+ Get the array for read-only access.
56
+
57
+ Returns the copy if one was made, otherwise the source.
58
+ This does NOT make a copy.
59
+ """
60
+ if self._copied:
61
+ return self._copy
62
+ return self._source
63
+
64
+ def get_writable(self) -> np.ndarray:
65
+ """
66
+ Get a writable copy of the array.
67
+
68
+ Forces a copy if one hasn't been made yet.
69
+ Thread-safe.
70
+ """
71
+ if self._copied:
72
+ return self._copy
73
+
74
+ with self._lock:
75
+ # Double-check after acquiring lock
76
+ if self._copied:
77
+ return self._copy
78
+
79
+ # Make the copy now
80
+ if self._source is not None:
81
+ self._copy = self._source.copy()
82
+ else:
83
+ self._copy = None
84
+ self._copied = True
85
+ self._source = None # Release reference to source
86
+ return self._copy
87
+
88
+ @property
89
+ def is_copied(self) -> bool:
90
+ """Check if a copy has been made."""
91
+ return self._copied
92
+
93
+ @property
94
+ def shape(self) -> tuple:
95
+ """Get shape of the underlying array."""
96
+ arr = self.get_array()
97
+ return arr.shape if arr is not None else ()
98
+
99
+ @property
100
+ def ndim(self) -> int:
101
+ """Get number of dimensions."""
102
+ arr = self.get_array()
103
+ return arr.ndim if arr is not None else 0
104
+
105
+ @property
106
+ def dtype(self):
107
+ """Get dtype of the underlying array."""
108
+ arr = self.get_array()
109
+ return arr.dtype if arr is not None else None
110
+
111
+ def __array__(self, dtype=None):
112
+ """Support numpy array conversion."""
113
+ arr = self.get_array()
114
+ if dtype is None:
115
+ return arr
116
+ return arr.astype(dtype)
117
+
118
+
119
+ # ============================================================================
120
+ # LRU DICTIONARY FOR BOUNDED CACHES
121
+ # ============================================================================
122
+
123
+ from collections import OrderedDict
124
+
125
+ class LRUDict(OrderedDict):
126
+ """
127
+ Simple LRU cache based on OrderedDict.
128
+ When maxsize is exceeded, oldest items are evicted.
129
+ Thread-safe for basic operations.
130
+ """
131
+ __slots__ = ('maxsize',)
132
+
133
+ def __init__(self, maxsize: int = 500):
134
+ super().__init__()
135
+ self.maxsize = maxsize
136
+
137
+ def __getitem__(self, key):
138
+ # Move to end on access (most recently used)
139
+ self.move_to_end(key)
140
+ return super().__getitem__(key)
141
+
142
+ def get(self, key, default=None):
143
+ if key in self:
144
+ self.move_to_end(key)
145
+ return super().__getitem__(key)
146
+ return default
147
+
148
+ def __setitem__(self, key, value):
149
+ if key in self:
150
+ self.move_to_end(key)
151
+ super().__setitem__(key, value)
152
+ # Evict oldest if over limit
153
+ while len(self) > self.maxsize:
154
+ self.popitem(last=False) # Remove oldest
155
+
156
+
157
+ # ============================================================================
158
+ # MEMORY-MAPPED ARRAY UTILITIES
159
+ # ============================================================================
160
+
161
+ _TEMP_DIR: Optional[Path] = None
162
+ _MEMMAP_FILES: weakref.WeakSet = weakref.WeakSet()
163
+
164
+
165
+ def get_temp_dir() -> Path:
166
+ """Get or create the temporary directory for memory-mapped files."""
167
+ global _TEMP_DIR
168
+ if _TEMP_DIR is None or not _TEMP_DIR.exists():
169
+ _TEMP_DIR = Path(tempfile.mkdtemp(prefix="sasp_memmap_"))
170
+ return _TEMP_DIR
171
+
172
+
173
+ def create_memmap_array(
174
+ shape: Tuple[int, ...],
175
+ dtype: np.dtype = np.float32,
176
+ mode: str = 'w+',
177
+ prefix: str = "array_"
178
+ ) -> Tuple[np.memmap, Path]:
179
+ """
180
+ Create a memory-mapped array backed by a temporary file.
181
+
182
+ Args:
183
+ shape: Shape of the array
184
+ dtype: Data type (default float32)
185
+ mode: File mode ('w+' for read/write, 'r+' for existing)
186
+ prefix: Prefix for temp file name
187
+
188
+ Returns:
189
+ Tuple of (memmap array, path to backing file)
190
+ """
191
+ temp_dir = get_temp_dir()
192
+ temp_file = tempfile.NamedTemporaryFile(
193
+ prefix=prefix,
194
+ suffix=".npy",
195
+ dir=temp_dir,
196
+ delete=False
197
+ )
198
+ temp_path = Path(temp_file.name)
199
+ temp_file.close()
200
+
201
+ mm = np.memmap(str(temp_path), dtype=dtype, mode=mode, shape=shape)
202
+ return mm, temp_path
203
+
204
+
205
+ def cleanup_memmap(mm: np.memmap, path: Path) -> None:
206
+ """
207
+ Properly cleanup a memory-mapped array and its backing file.
208
+
209
+ Args:
210
+ mm: The memmap array to cleanup
211
+ path: Path to the backing file
212
+ """
213
+ try:
214
+ del mm
215
+ except Exception:
216
+ pass
217
+
218
+ try:
219
+ if path.exists():
220
+ path.unlink()
221
+ except Exception:
222
+ pass
223
+
224
+
225
+ def should_use_memmap(shape: Tuple[int, ...], dtype: np.dtype = np.float32) -> bool:
226
+ """
227
+ Determine if memmap should be used based on array size.
228
+
229
+ Uses memmap for arrays larger than 500MB to reduce RAM usage.
230
+
231
+ Args:
232
+ shape: Shape of the array
233
+ dtype: Data type
234
+
235
+ Returns:
236
+ True if memmap should be used
237
+ """
238
+ itemsize = np.dtype(dtype).itemsize
239
+ size_bytes = int(np.prod(shape)) * itemsize
240
+ threshold = 500 * 1024 * 1024 # 500 MB
241
+ return size_bytes > threshold
242
+
243
+
244
+ def smart_zeros(
245
+ shape: Tuple[int, ...],
246
+ dtype: np.dtype = np.float32,
247
+ force_memmap: bool = False
248
+ ) -> Tuple[np.ndarray, Optional[Path]]:
249
+ """
250
+ Create a zeros array, using memmap for large arrays.
251
+
252
+ Args:
253
+ shape: Shape of the array
254
+ dtype: Data type
255
+ force_memmap: Force use of memmap regardless of size
256
+
257
+ Returns:
258
+ Tuple of (array, optional path to memmap file)
259
+ """
260
+ if force_memmap or should_use_memmap(shape, dtype):
261
+ mm, path = create_memmap_array(shape, dtype, 'w+', "zeros_")
262
+ mm[:] = 0
263
+ return mm, path
264
+ else:
265
+ return np.zeros(shape, dtype=dtype), None
266
+
267
+
268
+ def smart_empty(
269
+ shape: Tuple[int, ...],
270
+ dtype: np.dtype = np.float32,
271
+ force_memmap: bool = False
272
+ ) -> Tuple[np.ndarray, Optional[Path]]:
273
+ """
274
+ Create an empty array, using memmap for large arrays.
275
+
276
+ Args:
277
+ shape: Shape of the array
278
+ dtype: Data type
279
+ force_memmap: Force use of memmap regardless of size
280
+
281
+ Returns:
282
+ Tuple of (array, optional path to memmap file)
283
+ """
284
+ if force_memmap or should_use_memmap(shape, dtype):
285
+ mm, path = create_memmap_array(shape, dtype, 'w+', "empty_")
286
+ return mm, path
287
+ else:
288
+ return np.empty(shape, dtype=dtype), None
289
+
290
+
291
+ # ============================================================================
292
+ # BUFFER POOL FOR REUSABLE MEMORY
293
+ # ============================================================================
294
+
295
+ class BufferPool:
296
+ """
297
+ A pool of reusable numpy buffers to reduce allocation overhead.
298
+
299
+ Thread-safe buffer management for frequently allocated arrays.
300
+ """
301
+
302
+ def __init__(self, max_buffers: int = 8):
303
+ """
304
+ Initialize buffer pool.
305
+
306
+ Args:
307
+ max_buffers: Maximum number of buffers to keep per shape/dtype
308
+ """
309
+ self._pools: Dict[Tuple, list] = {}
310
+ self._lock = threading.Lock()
311
+ self._max_buffers = max_buffers
312
+
313
+ def _key(self, shape: Tuple[int, ...], dtype: np.dtype) -> Tuple:
314
+ """Create a hashable key for shape/dtype combination."""
315
+ return (shape, str(dtype))
316
+
317
+ def get(self, shape: Tuple[int, ...], dtype: np.dtype = np.float32) -> np.ndarray:
318
+ """
319
+ Get a buffer from the pool or create a new one.
320
+
321
+ The buffer contents are NOT zeroed - caller should initialize if needed.
322
+
323
+ Args:
324
+ shape: Desired shape
325
+ dtype: Desired dtype
326
+
327
+ Returns:
328
+ A numpy array of the requested shape/dtype
329
+ """
330
+ key = self._key(shape, dtype)
331
+
332
+ with self._lock:
333
+ pool = self._pools.get(key, [])
334
+ if pool:
335
+ return pool.pop()
336
+
337
+ # Create new buffer
338
+ return np.empty(shape, dtype=dtype)
339
+
340
+ def get_zeros(self, shape: Tuple[int, ...], dtype: np.dtype = np.float32) -> np.ndarray:
341
+ """
342
+ Get a zeroed buffer from the pool.
343
+
344
+ Args:
345
+ shape: Desired shape
346
+ dtype: Desired dtype
347
+
348
+ Returns:
349
+ A zeroed numpy array of the requested shape/dtype
350
+ """
351
+ buf = self.get(shape, dtype)
352
+ buf.fill(0)
353
+ return buf
354
+
355
+ def release(self, buf: np.ndarray) -> None:
356
+ """
357
+ Return a buffer to the pool for reuse.
358
+
359
+ Args:
360
+ buf: Buffer to return
361
+ """
362
+ if buf is None:
363
+ return
364
+
365
+ key = self._key(buf.shape, buf.dtype)
366
+
367
+ with self._lock:
368
+ if key not in self._pools:
369
+ self._pools[key] = []
370
+
371
+ if len(self._pools[key]) < self._max_buffers:
372
+ self._pools[key].append(buf)
373
+
374
+ def clear(self) -> None:
375
+ """Clear all buffers from the pool."""
376
+ with self._lock:
377
+ self._pools.clear()
378
+
379
+ def stats(self) -> Dict[str, int]:
380
+ """Get statistics about pool usage."""
381
+ with self._lock:
382
+ return {
383
+ "num_shapes": len(self._pools),
384
+ "total_buffers": sum(len(p) for p in self._pools.values()),
385
+ "shapes": list(self._pools.keys())
386
+ }
387
+
388
+
389
+ # Global buffer pool instance
390
+ _global_pool: Optional[BufferPool] = None
391
+
392
+
393
+ def get_buffer_pool() -> BufferPool:
394
+ """Get the global buffer pool instance."""
395
+ global _global_pool
396
+ if _global_pool is None:
397
+ _global_pool = BufferPool(max_buffers=8)
398
+ return _global_pool
399
+
400
+
401
+ # ============================================================================
402
+ # THUMBNAIL/PREVIEW CACHE
403
+ # ============================================================================
404
+
405
+ class ThumbnailCache:
406
+ """
407
+ Disk-based cache for image thumbnails/previews.
408
+
409
+ Speeds up repeated loading of the same images by caching
410
+ downscaled preview versions.
411
+ """
412
+
413
+ def __init__(self, cache_dir: Optional[Path] = None, max_size_mb: int = 500):
414
+ """
415
+ Initialize thumbnail cache.
416
+
417
+ Args:
418
+ cache_dir: Directory for cache files (default: temp dir)
419
+ max_size_mb: Maximum cache size in MB
420
+ """
421
+ if cache_dir is None:
422
+ cache_dir = Path(tempfile.gettempdir()) / "sasp_thumb_cache"
423
+
424
+ self._cache_dir = cache_dir
425
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
426
+ self._max_size = max_size_mb * 1024 * 1024
427
+ self._lock = threading.Lock()
428
+ self._memory_cache: Dict[str, np.ndarray] = {} # LRU in-memory cache
429
+ self._max_memory_items = 50
430
+
431
+ def _get_cache_key(self, path: str, target_size: Tuple[int, int]) -> str:
432
+ """Generate a unique cache key for a file and target size."""
433
+ # Include file path, mtime, and target size in hash
434
+ try:
435
+ mtime = os.path.getmtime(path)
436
+ except Exception:
437
+ mtime = 0
438
+
439
+ key_str = f"{path}|{mtime}|{target_size[0]}x{target_size[1]}"
440
+ return hashlib.md5(key_str.encode()).hexdigest()
441
+
442
+ def _get_cache_path(self, key: str) -> Path:
443
+ """Get the cache file path for a key."""
444
+ return self._cache_dir / f"{key}.npy"
445
+
446
+ def get(self, path: str, target_size: Tuple[int, int]) -> Optional[np.ndarray]:
447
+ """
448
+ Get a cached thumbnail if available.
449
+
450
+ Args:
451
+ path: Original image path
452
+ target_size: Target thumbnail size (width, height)
453
+
454
+ Returns:
455
+ Cached thumbnail array or None
456
+ """
457
+ key = self._get_cache_key(path, target_size)
458
+
459
+ # Check in-memory cache first
460
+ with self._lock:
461
+ if key in self._memory_cache:
462
+ return self._memory_cache[key].copy()
463
+
464
+ # Check disk cache
465
+ cache_path = self._get_cache_path(key)
466
+ if cache_path.exists():
467
+ try:
468
+ thumb = np.load(str(cache_path))
469
+ # Add to memory cache
470
+ with self._lock:
471
+ self._memory_cache[key] = thumb
472
+ self._trim_memory_cache()
473
+ return thumb.copy()
474
+ except Exception:
475
+ # Corrupted cache file, remove it
476
+ try:
477
+ cache_path.unlink()
478
+ except Exception:
479
+ pass
480
+
481
+ return None
482
+
483
+ def put(self, path: str, target_size: Tuple[int, int], thumb: np.ndarray) -> None:
484
+ """
485
+ Store a thumbnail in the cache.
486
+
487
+ Args:
488
+ path: Original image path
489
+ target_size: Target thumbnail size
490
+ thumb: Thumbnail array to cache
491
+ """
492
+ key = self._get_cache_key(path, target_size)
493
+
494
+ # Store in memory cache
495
+ with self._lock:
496
+ self._memory_cache[key] = thumb.copy()
497
+ self._trim_memory_cache()
498
+
499
+ # Store on disk
500
+ cache_path = self._get_cache_path(key)
501
+ try:
502
+ np.save(str(cache_path), thumb)
503
+ except Exception:
504
+ pass
505
+
506
+ # Trim disk cache if needed
507
+ self._trim_disk_cache()
508
+
509
+ def _trim_memory_cache(self) -> None:
510
+ """Trim memory cache to max size (caller must hold lock)."""
511
+ while len(self._memory_cache) > self._max_memory_items:
512
+ # Remove oldest item (first key)
513
+ oldest = next(iter(self._memory_cache))
514
+ del self._memory_cache[oldest]
515
+
516
+ def _trim_disk_cache(self) -> None:
517
+ """Trim disk cache to max size."""
518
+ try:
519
+ files = list(self._cache_dir.glob("*.npy"))
520
+ total_size = sum(f.stat().st_size for f in files)
521
+
522
+ if total_size > self._max_size:
523
+ # Sort by access time, oldest first
524
+ files.sort(key=lambda f: f.stat().st_atime)
525
+
526
+ while total_size > self._max_size * 0.8 and files: # Trim to 80%
527
+ oldest = files.pop(0)
528
+ try:
529
+ size = oldest.stat().st_size
530
+ oldest.unlink()
531
+ total_size -= size
532
+ except Exception:
533
+ pass
534
+ except Exception:
535
+ pass
536
+
537
+ def clear(self) -> None:
538
+ """Clear all cached thumbnails."""
539
+ with self._lock:
540
+ self._memory_cache.clear()
541
+
542
+ try:
543
+ for f in self._cache_dir.glob("*.npy"):
544
+ try:
545
+ f.unlink()
546
+ except Exception:
547
+ pass
548
+ except Exception:
549
+ pass
550
+
551
+
552
+ # Global thumbnail cache instance
553
+ _thumb_cache: Optional[ThumbnailCache] = None
554
+
555
+
556
+ def get_thumbnail_cache() -> ThumbnailCache:
557
+ """Get the global thumbnail cache instance."""
558
+ global _thumb_cache
559
+ if _thumb_cache is None:
560
+ _thumb_cache = ThumbnailCache()
561
+ return _thumb_cache
562
+
563
+
564
+ # ============================================================================
565
+ # LAZY IMAGE LOADER
566
+ # ============================================================================
567
+
568
+ class LazyImage:
569
+ """
570
+ Lazy image loader that loads full resolution on demand.
571
+
572
+ Initially loads only a preview/thumbnail, deferring full
573
+ resolution loading until actually needed.
574
+ """
575
+
576
+ def __init__(
577
+ self,
578
+ path: str,
579
+ preview_size: Tuple[int, int] = (512, 512),
580
+ load_preview_fn: Optional[callable] = None,
581
+ load_full_fn: Optional[callable] = None
582
+ ):
583
+ """
584
+ Initialize lazy image.
585
+
586
+ Args:
587
+ path: Path to the image file
588
+ preview_size: Size for preview image
589
+ load_preview_fn: Function to load preview (path, size) -> array
590
+ load_full_fn: Function to load full image (path) -> array
591
+ """
592
+ self.path = path
593
+ self.preview_size = preview_size
594
+ self._preview: Optional[np.ndarray] = None
595
+ self._full: Optional[np.ndarray] = None
596
+ self._load_preview_fn = load_preview_fn
597
+ self._load_full_fn = load_full_fn
598
+ self._lock = threading.Lock()
599
+
600
+ @property
601
+ def preview(self) -> Optional[np.ndarray]:
602
+ """Get preview image, loading if necessary."""
603
+ if self._preview is None and self._load_preview_fn is not None:
604
+ with self._lock:
605
+ if self._preview is None:
606
+ # Check cache first
607
+ cache = get_thumbnail_cache()
608
+ cached = cache.get(self.path, self.preview_size)
609
+ if cached is not None:
610
+ self._preview = cached
611
+ else:
612
+ self._preview = self._load_preview_fn(self.path, self.preview_size)
613
+ if self._preview is not None:
614
+ cache.put(self.path, self.preview_size, self._preview)
615
+ return self._preview
616
+
617
+ @property
618
+ def full(self) -> Optional[np.ndarray]:
619
+ """Get full resolution image, loading if necessary."""
620
+ if self._full is None and self._load_full_fn is not None:
621
+ with self._lock:
622
+ if self._full is None:
623
+ self._full = self._load_full_fn(self.path)
624
+ return self._full
625
+
626
+ @property
627
+ def is_full_loaded(self) -> bool:
628
+ """Check if full resolution image is loaded."""
629
+ return self._full is not None
630
+
631
+ def unload_full(self) -> None:
632
+ """Unload full resolution to free memory."""
633
+ with self._lock:
634
+ self._full = None
635
+
636
+ def unload_all(self) -> None:
637
+ """Unload all images to free memory."""
638
+ with self._lock:
639
+ self._preview = None
640
+ self._full = None
641
+
642
+
643
+ # ============================================================================
644
+ # CLEANUP UTILITIES
645
+ # ============================================================================
646
+
647
+ def cleanup_temp_files() -> None:
648
+ """Cleanup all temporary memory-mapped files."""
649
+ global _TEMP_DIR
650
+ if _TEMP_DIR is not None and _TEMP_DIR.exists():
651
+ try:
652
+ import shutil
653
+ shutil.rmtree(_TEMP_DIR, ignore_errors=True)
654
+ except Exception:
655
+ pass
656
+ _TEMP_DIR = None
657
+
658
+
659
+ def get_memory_usage_mb() -> float:
660
+ """Get current process memory usage in MB."""
661
+ try:
662
+ import psutil
663
+ process = psutil.Process()
664
+ return process.memory_info().rss / (1024 * 1024)
665
+ except Exception:
666
+ return 0.0