1
0

cache_manager.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890
  1. """Image Cache Manager for pre-generating and managing image previews."""
  2. import os
  3. import json
  4. import asyncio
  5. import logging
  6. from pathlib import Path
  7. from modules.core.pattern_manager import list_theta_rho_files, THETA_RHO_DIR, parse_theta_rho_file
  8. from modules.core.process_pool import get_pool as _get_process_pool
  9. try:
  10. from PIL import Image
  11. except ImportError:
  12. Image = None
  13. logger = logging.getLogger(__name__)
  14. # Global cache progress state
  15. cache_progress = {
  16. "is_running": False,
  17. "total_files": 0,
  18. "processed_files": 0,
  19. "current_file": "",
  20. "stage": "idle", # idle, metadata, images, complete
  21. "error": None
  22. }
  23. # Constants
  24. CACHE_DIR = os.path.join(THETA_RHO_DIR, "cached_images")
  25. METADATA_CACHE_FILE = "metadata_cache.json" # Now in root directory
  26. # Cache schema version - increment when structure changes
  27. CACHE_SCHEMA_VERSION = 1
  28. # Expected cache schema structure
  29. EXPECTED_CACHE_SCHEMA = {
  30. 'version': CACHE_SCHEMA_VERSION,
  31. 'structure': {
  32. 'mtime': 'number',
  33. 'metadata': {
  34. 'first_coordinate': {'x': 'number', 'y': 'number'},
  35. 'last_coordinate': {'x': 'number', 'y': 'number'},
  36. 'total_coordinates': 'number'
  37. }
  38. }
  39. }
  40. def validate_cache_schema(cache_data):
  41. """Validate that cache data matches the expected schema structure."""
  42. try:
  43. # Check if version info exists
  44. if not isinstance(cache_data, dict):
  45. return False
  46. # Check for version field - if missing, it's old format
  47. cache_version = cache_data.get('version')
  48. if cache_version is None:
  49. logger.info("Cache file missing version info - treating as outdated schema")
  50. return False
  51. # Check if version matches current expected version
  52. if cache_version != CACHE_SCHEMA_VERSION:
  53. logger.info(f"Cache schema version mismatch: found {cache_version}, expected {CACHE_SCHEMA_VERSION}")
  54. return False
  55. # Check if data section exists
  56. if 'data' not in cache_data:
  57. logger.warning("Cache file missing 'data' section")
  58. return False
  59. # Validate structure of a few entries if they exist
  60. data_section = cache_data.get('data', {})
  61. if data_section and isinstance(data_section, dict):
  62. # Check first entry structure
  63. for pattern_file, entry in list(data_section.items())[:1]: # Just check first entry
  64. if not isinstance(entry, dict):
  65. return False
  66. if 'mtime' not in entry or 'metadata' not in entry:
  67. return False
  68. metadata = entry.get('metadata', {})
  69. required_fields = ['first_coordinate', 'last_coordinate', 'total_coordinates']
  70. if not all(field in metadata for field in required_fields):
  71. return False
  72. # Validate coordinate structure
  73. for coord_field in ['first_coordinate', 'last_coordinate']:
  74. coord = metadata.get(coord_field)
  75. if not isinstance(coord, dict) or 'x' not in coord or 'y' not in coord:
  76. return False
  77. return True
  78. except Exception as e:
  79. logger.warning(f"Error validating cache schema: {str(e)}")
  80. return False
  81. def invalidate_cache():
  82. """Delete only the metadata cache file, preserving image cache."""
  83. try:
  84. # Delete metadata cache file only
  85. if os.path.exists(METADATA_CACHE_FILE):
  86. os.remove(METADATA_CACHE_FILE)
  87. logger.info("Deleted outdated metadata cache file")
  88. # Keep image cache directory intact - images are still valid
  89. # Just ensure the cache directory structure exists
  90. ensure_cache_dir()
  91. return True
  92. except Exception as e:
  93. logger.error(f"Failed to invalidate metadata cache: {str(e)}")
  94. return False
  95. async def invalidate_cache_async():
  96. """Async version: Delete only the metadata cache file, preserving image cache."""
  97. try:
  98. # Delete metadata cache file only
  99. if await asyncio.to_thread(os.path.exists, METADATA_CACHE_FILE):
  100. await asyncio.to_thread(os.remove, METADATA_CACHE_FILE)
  101. logger.info("Deleted outdated metadata cache file")
  102. # Keep image cache directory intact - images are still valid
  103. # Just ensure the cache directory structure exists
  104. await ensure_cache_dir_async()
  105. return True
  106. except Exception as e:
  107. logger.error(f"Failed to invalidate metadata cache: {str(e)}")
  108. return False
  109. def ensure_cache_dir():
  110. """Ensure the cache directory exists with proper permissions."""
  111. try:
  112. Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
  113. # Initialize metadata cache if it doesn't exist
  114. if not os.path.exists(METADATA_CACHE_FILE):
  115. initial_cache = {
  116. 'version': CACHE_SCHEMA_VERSION,
  117. 'data': {}
  118. }
  119. with open(METADATA_CACHE_FILE, 'w') as f:
  120. json.dump(initial_cache, f)
  121. try:
  122. os.chmod(METADATA_CACHE_FILE, 0o644) # More conservative permissions
  123. except (OSError, PermissionError) as e:
  124. logger.debug(f"Could not set metadata cache file permissions: {str(e)}")
  125. for root, dirs, files in os.walk(CACHE_DIR):
  126. try:
  127. os.chmod(root, 0o755) # More conservative permissions
  128. for file in files:
  129. file_path = os.path.join(root, file)
  130. try:
  131. os.chmod(file_path, 0o644) # More conservative permissions
  132. except (OSError, PermissionError) as e:
  133. # Log as debug instead of error since this is not critical
  134. logger.debug(f"Could not set permissions for file {file_path}: {str(e)}")
  135. except (OSError, PermissionError) as e:
  136. # Log as debug instead of error since this is not critical
  137. logger.debug(f"Could not set permissions for directory {root}: {str(e)}")
  138. continue
  139. except Exception as e:
  140. logger.error(f"Failed to create cache directory: {str(e)}")
  141. async def ensure_cache_dir_async():
  142. """Async version: Ensure the cache directory exists with proper permissions."""
  143. try:
  144. await asyncio.to_thread(Path(CACHE_DIR).mkdir, parents=True, exist_ok=True)
  145. # Initialize metadata cache if it doesn't exist
  146. if not await asyncio.to_thread(os.path.exists, METADATA_CACHE_FILE):
  147. initial_cache = {
  148. 'version': CACHE_SCHEMA_VERSION,
  149. 'data': {}
  150. }
  151. def _write_initial_cache():
  152. with open(METADATA_CACHE_FILE, 'w') as f:
  153. json.dump(initial_cache, f)
  154. await asyncio.to_thread(_write_initial_cache)
  155. try:
  156. await asyncio.to_thread(os.chmod, METADATA_CACHE_FILE, 0o644)
  157. except (OSError, PermissionError) as e:
  158. logger.debug(f"Could not set metadata cache file permissions: {str(e)}")
  159. def _set_permissions():
  160. for root, dirs, files in os.walk(CACHE_DIR):
  161. try:
  162. os.chmod(root, 0o755)
  163. for file in files:
  164. file_path = os.path.join(root, file)
  165. try:
  166. os.chmod(file_path, 0o644)
  167. except (OSError, PermissionError) as e:
  168. logger.debug(f"Could not set permissions for file {file_path}: {str(e)}")
  169. except (OSError, PermissionError) as e:
  170. logger.debug(f"Could not set permissions for directory {root}: {str(e)}")
  171. continue
  172. await asyncio.to_thread(_set_permissions)
  173. except Exception as e:
  174. logger.error(f"Failed to create cache directory: {str(e)}")
  175. def get_cache_path(pattern_file):
  176. """Get the cache path for a pattern file."""
  177. # Normalize path separators to handle both forward slashes and backslashes
  178. pattern_file = pattern_file.replace('\\', '/')
  179. # Create subdirectories in cache to match the pattern file structure
  180. cache_subpath = os.path.dirname(pattern_file)
  181. if cache_subpath:
  182. # Create the same subdirectory structure in cache (including custom_patterns)
  183. # Convert forward slashes back to platform-specific separator for os.path.join
  184. cache_subpath = cache_subpath.replace('/', os.sep)
  185. cache_dir = os.path.join(CACHE_DIR, cache_subpath)
  186. else:
  187. # For files in root pattern directory
  188. cache_dir = CACHE_DIR
  189. # Ensure the subdirectory exists
  190. os.makedirs(cache_dir, exist_ok=True)
  191. try:
  192. os.chmod(cache_dir, 0o755) # More conservative permissions
  193. except (OSError, PermissionError) as e:
  194. # Log as debug instead of error since this is not critical
  195. logger.debug(f"Could not set permissions for cache subdirectory {cache_dir}: {str(e)}")
  196. # Use just the filename part for the cache file
  197. filename = os.path.basename(pattern_file)
  198. safe_name = filename.replace('\\', '_')
  199. return os.path.join(cache_dir, f"{safe_name}.webp")
  200. def delete_pattern_cache(pattern_file):
  201. """Delete cached preview image and metadata for a pattern file."""
  202. try:
  203. # Remove cached image
  204. cache_path = get_cache_path(pattern_file)
  205. if os.path.exists(cache_path):
  206. os.remove(cache_path)
  207. logger.info(f"Deleted cached image: {cache_path}")
  208. # Remove from metadata cache
  209. metadata_cache = load_metadata_cache()
  210. data_section = metadata_cache.get('data', {})
  211. if pattern_file in data_section:
  212. del data_section[pattern_file]
  213. metadata_cache['data'] = data_section
  214. save_metadata_cache(metadata_cache)
  215. logger.info(f"Removed {pattern_file} from metadata cache")
  216. return True
  217. except Exception as e:
  218. logger.error(f"Failed to delete cache for {pattern_file}: {str(e)}")
  219. return False
  220. def load_metadata_cache():
  221. """Load the metadata cache from disk with schema validation."""
  222. try:
  223. if os.path.exists(METADATA_CACHE_FILE):
  224. with open(METADATA_CACHE_FILE, 'r') as f:
  225. cache_data = json.load(f)
  226. # Validate schema
  227. if not validate_cache_schema(cache_data):
  228. logger.info("Cache schema validation failed - invalidating cache")
  229. invalidate_cache()
  230. # Return empty cache structure after invalidation
  231. return {
  232. 'version': CACHE_SCHEMA_VERSION,
  233. 'data': {}
  234. }
  235. return cache_data
  236. except Exception as e:
  237. logger.warning(f"Failed to load metadata cache: {str(e)} - invalidating cache")
  238. try:
  239. invalidate_cache()
  240. except Exception as invalidate_error:
  241. logger.error(f"Failed to invalidate corrupted cache: {str(invalidate_error)}")
  242. # Return empty cache structure
  243. return {
  244. 'version': CACHE_SCHEMA_VERSION,
  245. 'data': {}
  246. }
  247. async def load_metadata_cache_async():
  248. """Async version: Load the metadata cache from disk with schema validation."""
  249. try:
  250. if await asyncio.to_thread(os.path.exists, METADATA_CACHE_FILE):
  251. def _load_json():
  252. with open(METADATA_CACHE_FILE, 'r') as f:
  253. return json.load(f)
  254. cache_data = await asyncio.to_thread(_load_json)
  255. # Validate schema
  256. if not validate_cache_schema(cache_data):
  257. logger.info("Cache schema validation failed - invalidating cache")
  258. await invalidate_cache_async()
  259. # Return empty cache structure after invalidation
  260. return {
  261. 'version': CACHE_SCHEMA_VERSION,
  262. 'data': {}
  263. }
  264. return cache_data
  265. except Exception as e:
  266. logger.warning(f"Failed to load metadata cache: {str(e)} - invalidating cache")
  267. try:
  268. await invalidate_cache_async()
  269. except Exception as invalidate_error:
  270. logger.error(f"Failed to invalidate corrupted cache: {str(invalidate_error)}")
  271. # Return empty cache structure
  272. return {
  273. 'version': CACHE_SCHEMA_VERSION,
  274. 'data': {}
  275. }
  276. def save_metadata_cache(cache_data):
  277. """Save the metadata cache to disk with version info."""
  278. try:
  279. ensure_cache_dir()
  280. # Ensure cache data has proper structure
  281. if not isinstance(cache_data, dict) or 'version' not in cache_data:
  282. # Convert old format or create new structure
  283. if isinstance(cache_data, dict) and 'data' not in cache_data:
  284. # Old format - wrap existing data
  285. structured_cache = {
  286. 'version': CACHE_SCHEMA_VERSION,
  287. 'data': cache_data
  288. }
  289. else:
  290. structured_cache = cache_data
  291. else:
  292. structured_cache = cache_data
  293. with open(METADATA_CACHE_FILE, 'w') as f:
  294. json.dump(structured_cache, f, indent=2)
  295. except Exception as e:
  296. logger.error(f"Failed to save metadata cache: {str(e)}")
  297. def get_pattern_metadata(pattern_file):
  298. """Get cached metadata for a pattern file."""
  299. cache_data = load_metadata_cache()
  300. data_section = cache_data.get('data', {})
  301. # Check if we have cached metadata and if the file hasn't changed
  302. if pattern_file in data_section:
  303. cached_entry = data_section[pattern_file]
  304. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  305. try:
  306. file_mtime = os.path.getmtime(pattern_path)
  307. if cached_entry.get('mtime') == file_mtime:
  308. return cached_entry.get('metadata')
  309. except OSError:
  310. pass
  311. return None
  312. async def get_pattern_metadata_async(pattern_file):
  313. """Async version: Get cached metadata for a pattern file."""
  314. cache_data = await load_metadata_cache_async()
  315. data_section = cache_data.get('data', {})
  316. # Check if we have cached metadata and if the file hasn't changed
  317. if pattern_file in data_section:
  318. cached_entry = data_section[pattern_file]
  319. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  320. try:
  321. file_mtime = await asyncio.to_thread(os.path.getmtime, pattern_path)
  322. if cached_entry.get('mtime') == file_mtime:
  323. return cached_entry.get('metadata')
  324. except OSError:
  325. pass
  326. return None
  327. def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
  328. """Cache metadata for a pattern file."""
  329. try:
  330. cache_data = load_metadata_cache()
  331. data_section = cache_data.get('data', {})
  332. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  333. file_mtime = os.path.getmtime(pattern_path)
  334. data_section[pattern_file] = {
  335. 'mtime': file_mtime,
  336. 'metadata': {
  337. 'first_coordinate': first_coord,
  338. 'last_coordinate': last_coord,
  339. 'total_coordinates': total_coords
  340. }
  341. }
  342. cache_data['data'] = data_section
  343. save_metadata_cache(cache_data)
  344. logger.debug(f"Cached metadata for {pattern_file}")
  345. except Exception as e:
  346. logger.warning(f"Failed to cache metadata for {pattern_file}: {str(e)}")
  347. def needs_cache(pattern_file):
  348. """Check if a pattern file needs its cache generated."""
  349. # Check if image preview exists
  350. cache_path = get_cache_path(pattern_file)
  351. if not os.path.exists(cache_path):
  352. return True
  353. # Check if metadata cache exists and is valid
  354. metadata = get_pattern_metadata(pattern_file)
  355. if metadata is None:
  356. return True
  357. return False
  358. def needs_image_cache_only(pattern_file):
  359. """Quick check if a pattern file needs its image cache generated.
  360. Only checks for image file existence, not metadata validity.
  361. Used during startup for faster checking.
  362. """
  363. cache_path = get_cache_path(pattern_file)
  364. return not os.path.exists(cache_path)
  365. async def needs_cache_async(pattern_file):
  366. """Async version: Check if a pattern file needs its cache generated."""
  367. # Check if image preview exists
  368. cache_path = get_cache_path(pattern_file)
  369. if not await asyncio.to_thread(os.path.exists, cache_path):
  370. return True
  371. # Check if metadata cache exists and is valid
  372. metadata = await get_pattern_metadata_async(pattern_file)
  373. if metadata is None:
  374. return True
  375. return False
  376. async def generate_image_preview(pattern_file):
  377. """Generate image preview for a single pattern file."""
  378. from modules.core.preview import generate_preview_image
  379. from modules.core.pattern_manager import parse_theta_rho_file
  380. try:
  381. logger.debug(f"Starting preview generation for {pattern_file}")
  382. # Check if we need to update metadata cache
  383. metadata = get_pattern_metadata(pattern_file)
  384. if metadata is None:
  385. # Parse file to get metadata (this is the only time we need to parse)
  386. logger.debug(f"Parsing {pattern_file} for metadata cache")
  387. pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
  388. try:
  389. loop = asyncio.get_running_loop()
  390. coordinates = await loop.run_in_executor(
  391. _get_process_pool(),
  392. parse_theta_rho_file,
  393. pattern_path
  394. )
  395. if coordinates:
  396. first_coord = {"x": coordinates[0][0], "y": coordinates[0][1]}
  397. last_coord = {"x": coordinates[-1][0], "y": coordinates[-1][1]}
  398. total_coords = len(coordinates)
  399. # Cache the metadata for future use
  400. cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords)
  401. logger.debug(f"Metadata cached for {pattern_file}: {total_coords} coordinates")
  402. else:
  403. logger.warning(f"No coordinates found in {pattern_file}")
  404. except Exception as e:
  405. logger.error(f"Failed to parse {pattern_file} for metadata: {str(e)}")
  406. # Continue with image generation even if metadata fails
  407. # Check if we need to generate the image
  408. cache_path = get_cache_path(pattern_file)
  409. if os.path.exists(cache_path):
  410. logger.debug(f"Skipping image generation for {pattern_file} - already cached")
  411. return True
  412. # Generate the image
  413. logger.debug(f"Generating image preview for {pattern_file}")
  414. image_content = await generate_preview_image(pattern_file)
  415. if not image_content:
  416. logger.error(f"Generated image content is empty for {pattern_file}")
  417. return False
  418. # Ensure cache directory exists
  419. ensure_cache_dir()
  420. with open(cache_path, 'wb') as f:
  421. f.write(image_content)
  422. try:
  423. os.chmod(cache_path, 0o644) # More conservative permissions
  424. except (OSError, PermissionError) as e:
  425. # Log as debug instead of error since this is not critical
  426. logger.debug(f"Could not set cache file permissions for {pattern_file}: {str(e)}")
  427. logger.debug(f"Successfully generated preview for {pattern_file}")
  428. return True
  429. except Exception as e:
  430. logger.error(f"Failed to generate image for {pattern_file}: {str(e)}")
  431. return False
  432. async def convert_webp_to_png(pattern_file: str) -> bool:
  433. """Convert a WebP preview to PNG format for touchscreen compatibility.
  434. The touchscreen (Qt/QML) has better support for PNG than WebP,
  435. so we generate both formats when a pattern is uploaded.
  436. Args:
  437. pattern_file: The pattern file name (e.g., 'custom_patterns/my_pattern.thr')
  438. Returns:
  439. True if conversion succeeded or PNG already exists, False on error
  440. """
  441. if not Image:
  442. logger.warning("PIL (Pillow) not available - cannot convert WebP to PNG")
  443. return False
  444. try:
  445. webp_path = Path(get_cache_path(pattern_file))
  446. png_path = webp_path.with_suffix('.png')
  447. # Skip if PNG already exists
  448. if png_path.exists():
  449. logger.debug(f"PNG already exists for {pattern_file}")
  450. return True
  451. # Skip if WebP doesn't exist
  452. if not webp_path.exists():
  453. logger.warning(f"WebP not found for {pattern_file}, cannot convert to PNG")
  454. return False
  455. def _convert():
  456. with Image.open(webp_path) as img:
  457. # Keep transparency for modes that support it
  458. if img.mode in ('RGBA', 'LA', 'P'):
  459. img.save(png_path, "PNG", optimize=True)
  460. else:
  461. rgb_img = img.convert('RGB')
  462. rgb_img.save(png_path, "PNG", optimize=True)
  463. # Set file permissions to match WebP file
  464. try:
  465. webp_stat = webp_path.stat()
  466. os.chmod(png_path, webp_stat.st_mode)
  467. except (OSError, PermissionError):
  468. pass
  469. await asyncio.to_thread(_convert)
  470. logger.info(f"Converted {pattern_file} preview to PNG for touchscreen")
  471. return True
  472. except Exception as e:
  473. logger.error(f"Failed to convert {pattern_file} to PNG: {e}")
  474. return False
  475. async def generate_all_image_previews():
  476. """Generate image previews for missing patterns using set difference."""
  477. global cache_progress
  478. try:
  479. await ensure_cache_dir_async()
  480. # Step 1: Get all pattern files
  481. pattern_files = await list_theta_rho_files_async()
  482. if not pattern_files:
  483. logger.info("No .thr pattern files found. Skipping image preview generation.")
  484. return
  485. # Step 2: Find patterns with existing cache
  486. def _find_cached_patterns():
  487. cached = set()
  488. for pattern in pattern_files:
  489. cache_path = get_cache_path(pattern)
  490. if os.path.exists(cache_path):
  491. cached.add(pattern)
  492. return cached
  493. cached_patterns = await asyncio.to_thread(_find_cached_patterns)
  494. # Step 3: Calculate delta (patterns missing image cache)
  495. pattern_set = set(pattern_files)
  496. patterns_to_cache = list(pattern_set - cached_patterns)
  497. total_files = len(patterns_to_cache)
  498. skipped_files = len(pattern_files) - total_files
  499. if total_files == 0:
  500. logger.info(f"All {skipped_files} pattern files already have image previews. Skipping image generation.")
  501. return
  502. # Update progress state
  503. cache_progress.update({
  504. "stage": "images",
  505. "total_files": total_files,
  506. "processed_files": 0,
  507. "current_file": "",
  508. "error": None
  509. })
  510. logger.info(f"Generating image cache for {total_files} uncached .thr patterns ({skipped_files} already cached)...")
  511. batch_size = 5
  512. successful = 0
  513. for i in range(0, total_files, batch_size):
  514. batch = patterns_to_cache[i:i + batch_size]
  515. tasks = [generate_image_preview(file) for file in batch]
  516. results = await asyncio.gather(*tasks)
  517. successful += sum(1 for r in results if r)
  518. # Update progress
  519. cache_progress["processed_files"] = min(i + batch_size, total_files)
  520. if i < total_files:
  521. cache_progress["current_file"] = patterns_to_cache[min(i + batch_size - 1, total_files - 1)]
  522. # Log progress
  523. progress = min(i + batch_size, total_files)
  524. logger.info(f"Image cache generation progress: {progress}/{total_files} files processed")
  525. logger.info(f"Image cache generation completed: {successful}/{total_files} patterns cached successfully, {skipped_files} patterns skipped (already cached)")
  526. except Exception as e:
  527. logger.error(f"Error during image cache generation: {str(e)}")
  528. cache_progress["error"] = str(e)
  529. raise
  530. async def generate_metadata_cache():
  531. """Generate metadata cache for missing patterns using set difference."""
  532. global cache_progress
  533. try:
  534. logger.info("Starting metadata cache generation...")
  535. # Step 1: Get all pattern files
  536. pattern_files = await list_theta_rho_files_async()
  537. if not pattern_files:
  538. logger.info("No pattern files found. Skipping metadata cache generation.")
  539. return
  540. # Step 2: Get existing metadata keys
  541. metadata_cache = await load_metadata_cache_async()
  542. existing_keys = set(metadata_cache.get('data', {}).keys())
  543. # Step 3: Calculate delta (patterns missing from metadata)
  544. pattern_set = set(pattern_files)
  545. files_to_process = list(pattern_set - existing_keys)
  546. total_files = len(files_to_process)
  547. skipped_files = len(pattern_files) - total_files
  548. if total_files == 0:
  549. logger.info(f"All {skipped_files} files already have metadata cache. Skipping metadata generation.")
  550. return
  551. # Update progress state
  552. cache_progress.update({
  553. "stage": "metadata",
  554. "total_files": total_files,
  555. "processed_files": 0,
  556. "current_file": "",
  557. "error": None
  558. })
  559. logger.info(f"Generating metadata cache for {total_files} new files ({skipped_files} files already cached)...")
  560. # Process in smaller batches for Pi Zero 2 W
  561. batch_size = 3 # Reduced from 5
  562. successful = 0
  563. for i in range(0, total_files, batch_size):
  564. batch = files_to_process[i:i + batch_size]
  565. # Process files sequentially within batch (no parallel tasks)
  566. for file_name in batch:
  567. pattern_path = os.path.join(THETA_RHO_DIR, file_name)
  568. cache_progress["current_file"] = file_name
  569. try:
  570. # Parse file in separate process to avoid GIL contention with motion thread
  571. loop = asyncio.get_running_loop()
  572. coordinates = await loop.run_in_executor(
  573. _get_process_pool(),
  574. parse_theta_rho_file,
  575. pattern_path
  576. )
  577. if coordinates:
  578. first_coord = {"x": coordinates[0][0], "y": coordinates[0][1]}
  579. last_coord = {"x": coordinates[-1][0], "y": coordinates[-1][1]}
  580. total_coords = len(coordinates)
  581. # Cache the metadata
  582. cache_pattern_metadata(file_name, first_coord, last_coord, total_coords)
  583. successful += 1
  584. logger.debug(f"Generated metadata for {file_name}")
  585. # Small delay to reduce I/O pressure
  586. await asyncio.sleep(0.05)
  587. except Exception as e:
  588. logger.error(f"Failed to generate metadata for {file_name}: {str(e)}")
  589. # Update progress
  590. cache_progress["processed_files"] = min(i + batch_size, total_files)
  591. # Log progress
  592. progress = min(i + batch_size, total_files)
  593. logger.info(f"Metadata cache generation progress: {progress}/{total_files} files processed")
  594. # Delay between batches for system recovery
  595. if i + batch_size < total_files:
  596. await asyncio.sleep(0.3)
  597. logger.info(f"Metadata cache generation completed: {successful}/{total_files} patterns cached successfully, {skipped_files} patterns skipped (already cached)")
  598. except Exception as e:
  599. logger.error(f"Error during metadata cache generation: {str(e)}")
  600. cache_progress["error"] = str(e)
  601. raise
  602. async def rebuild_cache():
  603. """Rebuild the entire cache for all pattern files."""
  604. logger.info("Starting cache rebuild...")
  605. # Ensure cache directory exists
  606. ensure_cache_dir()
  607. # First generate metadata cache for all files
  608. await generate_metadata_cache()
  609. # Then generate image previews
  610. pattern_files = [f for f in list_theta_rho_files() if f.endswith('.thr')]
  611. total_files = len(pattern_files)
  612. if total_files == 0:
  613. logger.info("No pattern files found to cache")
  614. return
  615. logger.info(f"Generating image previews for {total_files} pattern files...")
  616. # Process in batches
  617. batch_size = 5
  618. successful = 0
  619. for i in range(0, total_files, batch_size):
  620. batch = pattern_files[i:i + batch_size]
  621. tasks = [generate_image_preview(file) for file in batch]
  622. results = await asyncio.gather(*tasks)
  623. successful += sum(1 for r in results if r)
  624. # Log progress
  625. progress = min(i + batch_size, total_files)
  626. logger.info(f"Image preview generation progress: {progress}/{total_files} files processed")
  627. logger.info(f"Cache rebuild completed: {successful}/{total_files} patterns cached successfully")
  628. async def generate_cache_background():
  629. """Run cache generation in the background with progress tracking."""
  630. global cache_progress
  631. try:
  632. cache_progress.update({
  633. "is_running": True,
  634. "stage": "starting",
  635. "total_files": 0,
  636. "processed_files": 0,
  637. "current_file": "",
  638. "error": None
  639. })
  640. # First generate metadata cache
  641. await generate_metadata_cache()
  642. # Then generate image previews
  643. await generate_all_image_previews()
  644. # Mark as complete
  645. cache_progress.update({
  646. "is_running": False,
  647. "stage": "complete",
  648. "current_file": "",
  649. "error": None
  650. })
  651. logger.info("Background cache generation completed successfully")
  652. except Exception as e:
  653. logger.error(f"Background cache generation failed: {str(e)}")
  654. cache_progress.update({
  655. "is_running": False,
  656. "stage": "error",
  657. "error": str(e)
  658. })
  659. raise
  660. def get_cache_progress():
  661. """Get the current cache generation progress.
  662. Returns a reference to the cache_progress dict for read-only access.
  663. The WebSocket handler should not modify this dict.
  664. """
  665. global cache_progress
  666. return cache_progress # Return reference instead of copy for better performance
  667. def is_cache_generation_needed():
  668. """Check if cache generation is needed."""
  669. pattern_files = [f for f in list_theta_rho_files() if f.endswith('.thr')]
  670. if not pattern_files:
  671. return False
  672. # Check if any files need caching
  673. patterns_to_cache = [f for f in pattern_files if needs_cache(f)]
  674. # Check metadata cache
  675. files_needing_metadata = []
  676. for file_name in pattern_files:
  677. if get_pattern_metadata(file_name) is None:
  678. files_needing_metadata.append(file_name)
  679. return len(patterns_to_cache) > 0 or len(files_needing_metadata) > 0
  680. async def is_cache_generation_needed_async():
  681. """Check if cache generation is needed using simple set difference.
  682. Returns True if any patterns are missing from either metadata or image cache.
  683. """
  684. try:
  685. # Step 1: List all patterns
  686. pattern_files = await list_theta_rho_files_async()
  687. if not pattern_files:
  688. return False
  689. pattern_set = set(pattern_files)
  690. # Step 2: Check metadata cache
  691. metadata_cache = await load_metadata_cache_async()
  692. metadata_keys = set(metadata_cache.get('data', {}).keys())
  693. if pattern_set != metadata_keys:
  694. # Metadata is missing some patterns
  695. return True
  696. # Step 3: Check image cache
  697. def _list_cached_images():
  698. """List all patterns that have cached images."""
  699. cached = set()
  700. if os.path.exists(CACHE_DIR):
  701. for pattern in pattern_files:
  702. cache_path = get_cache_path(pattern)
  703. if os.path.exists(cache_path):
  704. cached.add(pattern)
  705. return cached
  706. cached_images = await asyncio.to_thread(_list_cached_images)
  707. if pattern_set != cached_images:
  708. # Some patterns missing image cache
  709. return True
  710. return False
  711. except Exception as e:
  712. logger.warning(f"Error checking cache status: {e}")
  713. return False # Don't block startup on errors
  714. async def list_theta_rho_files_async():
  715. """Async version: List all theta-rho files."""
  716. def _walk_files():
  717. files = []
  718. for root, _, filenames in os.walk(THETA_RHO_DIR):
  719. # Only process .thr files to reduce memory usage
  720. thr_files = [f for f in filenames if f.endswith('.thr')]
  721. for file in thr_files:
  722. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  723. # Normalize path separators to always use forward slashes for consistency across platforms
  724. relative_path = relative_path.replace(os.sep, '/')
  725. files.append(relative_path)
  726. return files
  727. files = await asyncio.to_thread(_walk_files)
  728. logger.debug(f"Found {len(files)} theta-rho files")
  729. return files # Already filtered for .thr