mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
Feature/static image manager (#95)
* feat(static-image): Add static image manager with web interface - Create StaticImageManager class with image scaling and transparency support - Add configuration options for display duration, zoom scale, and background color - Integrate with display controller and web interface - Add image upload functionality to web interface - Support for various image formats with proper scaling - Efficient image processing with aspect ratio preservation - Ready for future scrolling feature implementation * fix(static-image): Move display duration to main display_durations block - Remove display_duration from static_image config section - Update StaticImageManager to read duration from display.display_durations.static_image - Remove display duration field from web interface form - Update web interface JavaScript to not include display_duration in payload - Follows same pattern as all other managers in the project * feat(static-image): Add fit to display option - Add fit_to_display checkbox to automatically scale images to fit display - When enabled, images are scaled to fit display dimensions while preserving aspect ratio - When disabled, manual zoom_scale control is available - Update web interface with smart form controls (zoom scale disabled when fit to display is on) - Prevents stretching or cropping - images are always properly fitted - Default to fit_to_display=true for better user experience * refactor(static-image): Remove zoom_scale and simplify to fit_to_display only - Remove zoom_scale option entirely as it was confusing and redundant - Simplify image processing to always fit to display dimensions - Remove zoom_scale field from web interface - Clean up JavaScript to remove zoom scale logic - Images are now always properly fitted without stretching or cropping - Much simpler and more intuitive user experience
This commit is contained in:
@@ -34,6 +34,7 @@ from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentM
|
||||
from src.youtube_display import YouTubeDisplay
|
||||
from src.calendar_manager import CalendarManager
|
||||
from src.text_display import TextDisplay
|
||||
from src.static_image_manager import StaticImageManager
|
||||
from src.music_manager import MusicManager
|
||||
from src.of_the_day_manager import OfTheDayManager
|
||||
from src.news_manager import NewsManager
|
||||
@@ -66,10 +67,12 @@ class DisplayController:
|
||||
self.calendar = CalendarManager(self.display_manager, self.config) if self.config.get('calendar', {}).get('enabled', False) else None
|
||||
self.youtube = YouTubeDisplay(self.display_manager, self.config) if self.config.get('youtube', {}).get('enabled', False) else None
|
||||
self.text_display = TextDisplay(self.display_manager, self.config) if self.config.get('text_display', {}).get('enabled', False) else None
|
||||
self.static_image = StaticImageManager(self.display_manager, self.config) if self.config.get('static_image', {}).get('enabled', False) else None
|
||||
self.of_the_day = OfTheDayManager(self.display_manager, self.config) if self.config.get('of_the_day', {}).get('enabled', False) else None
|
||||
self.news_manager = NewsManager(self.config, self.display_manager, self.config_manager) if self.config.get('news_manager', {}).get('enabled', False) else None
|
||||
logger.info(f"Calendar Manager initialized: {'Object' if self.calendar else 'None'}")
|
||||
logger.info(f"Text Display initialized: {'Object' if self.text_display else 'None'}")
|
||||
logger.info(f"Static Image Manager initialized: {'Object' if self.static_image else 'None'}")
|
||||
logger.info(f"OfTheDay Manager initialized: {'Object' if self.of_the_day else 'None'}")
|
||||
logger.info(f"News Manager initialized: {'Object' if self.news_manager else 'None'}")
|
||||
logger.info("Display modes initialized in %.3f seconds", time.time() - init_time)
|
||||
@@ -282,6 +285,7 @@ class DisplayController:
|
||||
if self.calendar: self.available_modes.append('calendar')
|
||||
if self.youtube: self.available_modes.append('youtube')
|
||||
if self.text_display: self.available_modes.append('text_display')
|
||||
if self.static_image: self.available_modes.append('static_image')
|
||||
if self.of_the_day: self.available_modes.append('of_the_day')
|
||||
if self.news_manager: self.available_modes.append('news_manager')
|
||||
if self.music_manager:
|
||||
@@ -600,6 +604,7 @@ class DisplayController:
|
||||
if self.calendar: self.calendar.update(time.time())
|
||||
if self.youtube: self.youtube.update()
|
||||
if self.text_display: self.text_display.update()
|
||||
if self.static_image: self.static_image.update()
|
||||
if self.of_the_day: self.of_the_day.update(time.time())
|
||||
else:
|
||||
# Not scrolling, perform all updates normally
|
||||
@@ -610,6 +615,7 @@ class DisplayController:
|
||||
if self.calendar: self.calendar.update(time.time())
|
||||
if self.youtube: self.youtube.update()
|
||||
if self.text_display: self.text_display.update()
|
||||
if self.static_image: self.static_image.update()
|
||||
if self.of_the_day: self.of_the_day.update(time.time())
|
||||
|
||||
# Update sports managers for leaderboard data
|
||||
@@ -1208,6 +1214,8 @@ class DisplayController:
|
||||
manager_to_display = self.youtube
|
||||
elif self.current_display_mode == 'text_display' and self.text_display:
|
||||
manager_to_display = self.text_display
|
||||
elif self.current_display_mode == 'static_image' and self.static_image:
|
||||
manager_to_display = self.static_image
|
||||
elif self.current_display_mode == 'of_the_day' and self.of_the_day:
|
||||
manager_to_display = self.of_the_day
|
||||
elif self.current_display_mode == 'news_manager' and self.news_manager:
|
||||
@@ -1321,6 +1329,8 @@ class DisplayController:
|
||||
manager_to_display.display(force_clear=self.force_clear)
|
||||
elif self.current_display_mode == 'text_display':
|
||||
manager_to_display.display() # Assumes internal clearing
|
||||
elif self.current_display_mode == 'static_image':
|
||||
manager_to_display.display(force_clear=self.force_clear)
|
||||
elif self.current_display_mode == 'of_the_day':
|
||||
manager_to_display.display(force_clear=self.force_clear)
|
||||
elif self.current_display_mode == 'news_manager':
|
||||
|
||||
211
src/static_image_manager.py
Normal file
211
src/static_image_manager.py
Normal file
@@ -0,0 +1,211 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
from PIL import Image, ImageOps
|
||||
import json
|
||||
|
||||
from .display_manager import DisplayManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class StaticImageManager:
|
||||
"""
|
||||
Manager for displaying static images on the LED matrix.
|
||||
Supports image scaling, transparency, and configurable display duration.
|
||||
"""
|
||||
|
||||
def __init__(self, display_manager: DisplayManager, config: dict):
|
||||
self.display_manager = display_manager
|
||||
self.config = config.get('static_image', {})
|
||||
|
||||
# Configuration
|
||||
self.enabled = self.config.get('enabled', False)
|
||||
self.image_path = self.config.get('image_path', '')
|
||||
# Get display duration from main display_durations block
|
||||
self.display_duration = config.get('display', {}).get('display_durations', {}).get('static_image', 10)
|
||||
self.fit_to_display = self.config.get('fit_to_display', True) # Auto-fit to display dimensions
|
||||
self.preserve_aspect_ratio = self.config.get('preserve_aspect_ratio', True)
|
||||
self.background_color = tuple(self.config.get('background_color', [0, 0, 0]))
|
||||
|
||||
# State
|
||||
self.current_image = None
|
||||
self.image_loaded = False
|
||||
self.last_update_time = 0
|
||||
|
||||
# Load initial image if enabled
|
||||
if self.enabled and self.image_path:
|
||||
self._load_image()
|
||||
|
||||
def _load_image(self) -> bool:
|
||||
"""
|
||||
Load and process the image for display.
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if not self.image_path or not os.path.exists(self.image_path):
|
||||
logger.warning(f"[Static Image] Image file not found: {self.image_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Load the image
|
||||
img = Image.open(self.image_path)
|
||||
|
||||
# Convert to RGBA to handle transparency
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
|
||||
# Get display dimensions
|
||||
display_width = self.display_manager.matrix.width
|
||||
display_height = self.display_manager.matrix.height
|
||||
|
||||
# Calculate target size - always fit to display while preserving aspect ratio
|
||||
target_size = self._calculate_fit_size(img.size, (display_width, display_height))
|
||||
|
||||
# Resize image
|
||||
if self.preserve_aspect_ratio:
|
||||
img = img.resize(target_size, Image.Resampling.LANCZOS)
|
||||
else:
|
||||
img = img.resize((display_width, display_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create display-sized canvas with background color
|
||||
canvas = Image.new('RGB', (display_width, display_height), self.background_color)
|
||||
|
||||
# Calculate position to center the image
|
||||
paste_x = (display_width - img.width) // 2
|
||||
paste_y = (display_height - img.height) // 2
|
||||
|
||||
# Handle transparency by compositing
|
||||
if img.mode == 'RGBA':
|
||||
# Create a temporary image with the background color
|
||||
temp_canvas = Image.new('RGB', (display_width, display_height), self.background_color)
|
||||
temp_canvas.paste(img, (paste_x, paste_y), img)
|
||||
canvas = temp_canvas
|
||||
else:
|
||||
canvas.paste(img, (paste_x, paste_y))
|
||||
|
||||
self.current_image = canvas
|
||||
self.image_loaded = True
|
||||
self.last_update_time = time.time()
|
||||
|
||||
logger.info(f"[Static Image] Successfully loaded and processed image: {self.image_path}")
|
||||
logger.info(f"[Static Image] Original size: {Image.open(self.image_path).size}, "
|
||||
f"Display size: {target_size}, Position: ({paste_x}, {paste_y})")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Static Image] Error loading image {self.image_path}: {e}")
|
||||
self.image_loaded = False
|
||||
return False
|
||||
|
||||
def _calculate_fit_size(self, image_size: Tuple[int, int], display_size: Tuple[int, int]) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculate the size to fit an image within display bounds while preserving aspect ratio.
|
||||
"""
|
||||
img_width, img_height = image_size
|
||||
display_width, display_height = display_size
|
||||
|
||||
# Calculate scaling factor to fit within display
|
||||
scale_x = display_width / img_width
|
||||
scale_y = display_height / img_height
|
||||
scale = min(scale_x, scale_y)
|
||||
|
||||
return (int(img_width * scale), int(img_height * scale))
|
||||
|
||||
def update(self):
|
||||
"""
|
||||
Update method - no continuous updates needed for static images.
|
||||
"""
|
||||
pass
|
||||
|
||||
def display(self, force_clear: bool = False):
|
||||
"""
|
||||
Display the static image on the LED matrix.
|
||||
"""
|
||||
if not self.enabled or not self.image_loaded or not self.current_image:
|
||||
if self.enabled:
|
||||
logger.warning("[Static Image] Manager enabled but no image loaded")
|
||||
return
|
||||
|
||||
# Clear display if requested
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
# Set the image on the display manager
|
||||
self.display_manager.image = self.current_image.copy()
|
||||
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
|
||||
logger.debug(f"[Static Image] Displayed image: {self.image_path}")
|
||||
|
||||
def set_image_path(self, image_path: str) -> bool:
|
||||
"""
|
||||
Set a new image path and load it.
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
self.image_path = image_path
|
||||
return self._load_image()
|
||||
|
||||
def set_fit_to_display(self, fit_to_display: bool):
|
||||
"""
|
||||
Set the fit to display option and reload the image.
|
||||
"""
|
||||
self.fit_to_display = fit_to_display
|
||||
if self.image_path:
|
||||
self._load_image()
|
||||
logger.info(f"[Static Image] Fit to display set to: {self.fit_to_display}")
|
||||
|
||||
def set_display_duration(self, duration: int):
|
||||
"""
|
||||
Set the display duration in seconds.
|
||||
"""
|
||||
self.display_duration = max(1, duration) # Minimum 1 second
|
||||
logger.info(f"[Static Image] Display duration set to: {self.display_duration} seconds")
|
||||
|
||||
def set_background_color(self, color: Tuple[int, int, int]):
|
||||
"""
|
||||
Set the background color and reload the image.
|
||||
"""
|
||||
self.background_color = color
|
||||
if self.image_path:
|
||||
self._load_image()
|
||||
logger.info(f"[Static Image] Background color set to: {self.background_color}")
|
||||
|
||||
def get_image_info(self) -> dict:
|
||||
"""
|
||||
Get information about the currently loaded image.
|
||||
"""
|
||||
if not self.image_loaded or not self.current_image:
|
||||
return {"loaded": False}
|
||||
|
||||
return {
|
||||
"loaded": True,
|
||||
"path": self.image_path,
|
||||
"display_size": self.current_image.size,
|
||||
"fit_to_display": self.fit_to_display,
|
||||
"display_duration": self.display_duration,
|
||||
"background_color": self.background_color
|
||||
}
|
||||
|
||||
def reload_image(self) -> bool:
|
||||
"""
|
||||
Reload the current image.
|
||||
"""
|
||||
if not self.image_path:
|
||||
logger.warning("[Static Image] No image path set for reload")
|
||||
return False
|
||||
|
||||
return self._load_image()
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""
|
||||
Check if the manager is enabled.
|
||||
"""
|
||||
return self.enabled
|
||||
|
||||
def get_display_duration(self) -> int:
|
||||
"""
|
||||
Get the display duration in seconds.
|
||||
"""
|
||||
return self.display_duration
|
||||
Reference in New Issue
Block a user