mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Add src/adaptive_images.py — the image counterpart to fit_text: - fit_image(img, box, mode=contain|cover|fill_height|stretch, crop_to_ink, anchor, resample, upscale) promoting the proven plugin patterns (football's crop-to-ink fill-height logos, masters' cover crop + NEAREST flags, static-image's letterbox). Upscales by default — thumbnail()'s downscale-only behavior is why imagery stays tiny on big panels. - draw_fitted_image() pastes aligned within a Region with alpha mask. - One central Pillow>=9.1 RESAMPLE shim replacing ~15 plugin copies. LayoutContext.fit_image() caches results per (identity, box size, options) with a 64-entry LRU; id()-keyed entries pin the source image. BasePlugin.draw_image() is the one-liner adoption path beside draw_fit. Composites in adaptive_layout.py: Region.offset() (user x/y-offset passthrough), scoreboard_regions() (the two-logos-plus-score card math duplicated across six sports plugins, logo_slot = min(H, W//2)), and media_row() (art-left/text-right). Fix LogoHelper's size-blind cache key (stale sizes on panel change); deprecation note on dead image_utils.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""
|
|
Logo Helper
|
|
|
|
Handles logo loading, caching, resizing, and management for LED matrix displays.
|
|
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|
"""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Union
|
|
|
|
import requests
|
|
from PIL import Image
|
|
from src.common.permission_utils import (
|
|
ensure_directory_permissions,
|
|
ensure_file_permissions,
|
|
get_assets_dir_mode,
|
|
get_assets_file_mode
|
|
)
|
|
|
|
|
|
class LogoHelper:
|
|
"""
|
|
Helper class for logo loading, caching, and resizing.
|
|
|
|
Provides functionality for:
|
|
- Loading logos from files
|
|
- Caching loaded logos in memory
|
|
- Resizing logos to fit display dimensions
|
|
- Handling logo variations and fallbacks
|
|
- Downloading missing logos from URLs
|
|
"""
|
|
|
|
def __init__(self, display_width: int, display_height: int,
|
|
cache_size: int = 100, logger: Optional[logging.Logger] = None):
|
|
"""
|
|
Initialize the LogoHelper.
|
|
|
|
Args:
|
|
display_width: Width of the LED matrix display
|
|
display_height: Height of the LED matrix display
|
|
cache_size: Maximum number of logos to cache in memory
|
|
logger: Optional logger instance
|
|
"""
|
|
self.display_width = display_width
|
|
self.display_height = display_height
|
|
self.cache_size = cache_size
|
|
self.logger = logger or logging.getLogger(__name__)
|
|
|
|
# In-memory logo cache
|
|
self._logo_cache: Dict[str, Image.Image] = {}
|
|
self._cache_order: List[str] = [] # For LRU cache management
|
|
|
|
# Session for HTTP requests
|
|
self.session = requests.Session()
|
|
self.session.headers.update({
|
|
'User-Agent': 'LEDMatrix-Common/1.0',
|
|
'Accept': 'image/*',
|
|
})
|
|
|
|
def load_logo(self, team_abbr: str, logo_path: Union[str, Path],
|
|
max_width: Optional[int] = None,
|
|
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
|
"""
|
|
Load and resize a team logo.
|
|
|
|
Args:
|
|
team_abbr: Team abbreviation for caching
|
|
logo_path: Path to the logo file
|
|
max_width: Maximum width (defaults to display_width * 1.5)
|
|
max_height: Maximum height (defaults to display_height * 1.5)
|
|
|
|
Returns:
|
|
PIL Image object or None if loading fails
|
|
|
|
Note: for new adaptive-layout code prefer ``BasePlugin.draw_image``
|
|
/ ``LayoutContext.fit_image`` (src/adaptive_images.py) for the
|
|
fitting step — LogoHelper remains useful for its download and
|
|
placeholder logic.
|
|
"""
|
|
# Resolve the effective target size BEFORE the cache lookup so the
|
|
# key is size-qualified — a panel-size change must not return a
|
|
# logo resized for the old dimensions.
|
|
if max_width is None:
|
|
max_width = int(self.display_width * 1.5)
|
|
if max_height is None:
|
|
max_height = int(self.display_height * 1.5)
|
|
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
|
|
if cache_key in self._logo_cache:
|
|
self.logger.debug(f"Using cached logo for {team_abbr}")
|
|
# Update LRU order (move to end)
|
|
if cache_key in self._cache_order:
|
|
self._cache_order.remove(cache_key)
|
|
self._cache_order.append(cache_key)
|
|
return self._logo_cache[cache_key]
|
|
|
|
try:
|
|
logo_path = Path(logo_path)
|
|
if not logo_path.exists():
|
|
self.logger.warning(f"Logo not found for {team_abbr} at {logo_path}")
|
|
return None
|
|
|
|
# Load image
|
|
logo = Image.open(logo_path)
|
|
if logo.mode != 'RGBA':
|
|
logo = logo.convert('RGBA')
|
|
|
|
# Resize if needed
|
|
logo = self._resize_logo(logo, max_width, max_height)
|
|
|
|
# Cache the logo
|
|
self._cache_logo(cache_key, logo)
|
|
|
|
self.logger.debug(f"Loaded logo for {team_abbr} from {logo_path}")
|
|
return logo
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error loading logo for {team_abbr}: {e}")
|
|
return None
|
|
|
|
def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path],
|
|
logo_url: Optional[str] = None,
|
|
max_width: Optional[int] = None,
|
|
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
|
"""
|
|
Load logo with automatic download if missing.
|
|
|
|
Args:
|
|
team_abbr: Team abbreviation
|
|
logo_path: Local path to store/load logo
|
|
logo_url: URL to download logo from if local file missing
|
|
max_width: Maximum width for resizing
|
|
max_height: Maximum height for resizing
|
|
|
|
Returns:
|
|
PIL Image object or None if loading fails
|
|
"""
|
|
logo_path = Path(logo_path)
|
|
|
|
# Try to load existing logo first
|
|
if logo_path.exists():
|
|
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
|
|
|
# Download if URL provided and file doesn't exist
|
|
if logo_url:
|
|
try:
|
|
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
|
self._download_logo(logo_url, logo_path)
|
|
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
|
|
|
# Create placeholder if all else fails
|
|
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
|
|
|
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
|
"""
|
|
Get possible filename variations for a team abbreviation.
|
|
|
|
Args:
|
|
team_abbr: Team abbreviation
|
|
|
|
Returns:
|
|
List of possible filename variations
|
|
"""
|
|
variations = [team_abbr]
|
|
|
|
# Common variations
|
|
if '&' in team_abbr:
|
|
variations.append(team_abbr.replace('&', 'AND'))
|
|
if 'AND' in team_abbr:
|
|
variations.append(team_abbr.replace('AND', '&'))
|
|
|
|
# Handle special cases
|
|
special_cases = {
|
|
'TA&M': ['TAMU', 'TEXASAM'],
|
|
'UCLA': ['UCLA'],
|
|
'USC': ['USC'],
|
|
'LSU': ['LSU'],
|
|
}
|
|
|
|
if team_abbr in special_cases:
|
|
variations.extend(special_cases[team_abbr])
|
|
|
|
return variations
|
|
|
|
def normalize_abbreviation(self, team_abbr: str) -> str:
|
|
"""
|
|
Normalize team abbreviation for consistent filename usage.
|
|
|
|
Args:
|
|
team_abbr: Raw team abbreviation
|
|
|
|
Returns:
|
|
Normalized abbreviation
|
|
"""
|
|
# Remove spaces and convert to uppercase
|
|
normalized = team_abbr.strip().upper()
|
|
|
|
# Handle special characters
|
|
normalized = normalized.replace('&', 'AND')
|
|
normalized = normalized.replace(' ', '')
|
|
|
|
return normalized
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Clear the logo cache."""
|
|
self._logo_cache.clear()
|
|
self._cache_order.clear()
|
|
self.logger.debug("Logo cache cleared")
|
|
|
|
def get_cache_stats(self) -> Dict[str, int]:
|
|
"""
|
|
Get cache statistics.
|
|
|
|
Returns:
|
|
Dictionary with cache statistics
|
|
"""
|
|
return {
|
|
'cached_logos': len(self._logo_cache),
|
|
'cache_size_limit': self.cache_size,
|
|
'cache_usage_percent': (len(self._logo_cache) / self.cache_size) * 100
|
|
}
|
|
|
|
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
|
max_height: Optional[int] = None) -> Image.Image:
|
|
"""Resize logo to fit display dimensions."""
|
|
if max_width is None:
|
|
max_width = int(self.display_width * 1.5)
|
|
if max_height is None:
|
|
max_height = int(self.display_height * 1.5)
|
|
|
|
# Only resize if necessary
|
|
if logo.width <= max_width and logo.height <= max_height:
|
|
return logo
|
|
|
|
# Maintain aspect ratio
|
|
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
|
return logo
|
|
|
|
def _cache_logo(self, cache_key: str, logo: Image.Image) -> None:
|
|
"""Cache a logo with LRU eviction."""
|
|
# Remove oldest if cache is full
|
|
if len(self._logo_cache) >= self.cache_size:
|
|
if self._cache_order:
|
|
oldest_key = self._cache_order.pop(0)
|
|
del self._logo_cache[oldest_key]
|
|
|
|
# Add to cache
|
|
self._logo_cache[cache_key] = logo
|
|
self._cache_order.append(cache_key)
|
|
|
|
def _download_logo(self, url: str, file_path: Path) -> None:
|
|
"""Download logo from URL."""
|
|
# Ensure directory exists with proper permissions
|
|
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
|
|
|
# Download with timeout
|
|
response = self.session.get(url, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
# Save to file
|
|
with open(file_path, 'wb') as f:
|
|
f.write(response.content)
|
|
|
|
# Set proper file permissions after saving
|
|
ensure_file_permissions(file_path, get_assets_file_mode())
|
|
|
|
self.logger.debug(f"Downloaded logo to {file_path}")
|
|
|
|
def _create_placeholder_logo(self, team_abbr: str,
|
|
max_width: Optional[int] = None,
|
|
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
|
"""
|
|
Create a placeholder logo with team abbreviation.
|
|
|
|
Args:
|
|
team_abbr: Team abbreviation to display
|
|
max_width: Maximum width
|
|
max_height: Maximum height
|
|
|
|
Returns:
|
|
PIL Image with placeholder logo
|
|
"""
|
|
try:
|
|
if max_width is None:
|
|
max_width = int(self.display_width * 1.5)
|
|
if max_height is None:
|
|
max_height = int(self.display_height * 1.5)
|
|
|
|
# Create placeholder image
|
|
placeholder = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0))
|
|
|
|
# This would require a font, so we'll create a simple colored rectangle
|
|
# In a real implementation, you'd want to add text rendering here
|
|
from PIL import ImageDraw
|
|
draw = ImageDraw.Draw(placeholder)
|
|
|
|
# Draw a simple rectangle with team abbreviation
|
|
draw.rectangle([0, 0, max_width-1, max_height-1],
|
|
fill=(100, 100, 100, 200), outline=(200, 200, 200, 255))
|
|
|
|
self.logger.debug(f"Created placeholder logo for {team_abbr}")
|
|
return placeholder
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error creating placeholder for {team_abbr}: {e}")
|
|
return None
|