mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +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:
Submodule LEDMatrix.wiki updated: fbd8d89a18...a01c72e156
BIN
assets/static_images/default.png
Normal file
BIN
assets/static_images/default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 675 B |
@@ -72,7 +72,8 @@
|
|||||||
"ncaam_basketball_upcoming": 30,
|
"ncaam_basketball_upcoming": 30,
|
||||||
"music": 30,
|
"music": 30,
|
||||||
"of_the_day": 40,
|
"of_the_day": 40,
|
||||||
"news_manager": 60
|
"news_manager": 60,
|
||||||
|
"static_image": 10
|
||||||
},
|
},
|
||||||
"use_short_date_format": true
|
"use_short_date_format": true
|
||||||
},
|
},
|
||||||
@@ -130,7 +131,7 @@
|
|||||||
"duration_buffer": 0.1
|
"duration_buffer": 0.1
|
||||||
},
|
},
|
||||||
"odds_ticker": {
|
"odds_ticker": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"show_favorite_teams_only": true,
|
"show_favorite_teams_only": true,
|
||||||
"games_per_favorite_team": 1,
|
"games_per_favorite_team": 1,
|
||||||
"max_games_per_league": 5,
|
"max_games_per_league": 5,
|
||||||
@@ -233,8 +234,6 @@
|
|||||||
"recent_games_to_show": 1,
|
"recent_games_to_show": 1,
|
||||||
"upcoming_games_to_show": 1,
|
"upcoming_games_to_show": 1,
|
||||||
"show_favorite_teams_only": true,
|
"show_favorite_teams_only": true,
|
||||||
"show_all_live": false,
|
|
||||||
"show_shots_on_goal": false,
|
|
||||||
"favorite_teams": [
|
"favorite_teams": [
|
||||||
"TB"
|
"TB"
|
||||||
],
|
],
|
||||||
@@ -301,7 +300,6 @@
|
|||||||
"recent_games_to_show": 1,
|
"recent_games_to_show": 1,
|
||||||
"upcoming_games_to_show": 1,
|
"upcoming_games_to_show": 1,
|
||||||
"show_favorite_teams_only": true,
|
"show_favorite_teams_only": true,
|
||||||
"show_all_live": false,
|
|
||||||
"favorite_teams": [
|
"favorite_teams": [
|
||||||
"TB",
|
"TB",
|
||||||
"DAL"
|
"DAL"
|
||||||
@@ -334,11 +332,9 @@
|
|||||||
"recent_games_to_show": 1,
|
"recent_games_to_show": 1,
|
||||||
"upcoming_games_to_show": 1,
|
"upcoming_games_to_show": 1,
|
||||||
"show_favorite_teams_only": true,
|
"show_favorite_teams_only": true,
|
||||||
"show_all_live": false,
|
|
||||||
"favorite_teams": [
|
"favorite_teams": [
|
||||||
"UGA",
|
"UGA",
|
||||||
"AUB",
|
"AUB"
|
||||||
"AP_TOP_25"
|
|
||||||
],
|
],
|
||||||
"logo_dir": "assets/sports/ncaa_logos",
|
"logo_dir": "assets/sports/ncaa_logos",
|
||||||
"show_records": true,
|
"show_records": true,
|
||||||
@@ -369,8 +365,6 @@
|
|||||||
"recent_games_to_show": 1,
|
"recent_games_to_show": 1,
|
||||||
"upcoming_games_to_show": 1,
|
"upcoming_games_to_show": 1,
|
||||||
"show_favorite_teams_only": true,
|
"show_favorite_teams_only": true,
|
||||||
"show_all_live": false,
|
|
||||||
"show_series_summary": false,
|
|
||||||
"favorite_teams": [
|
"favorite_teams": [
|
||||||
"UGA",
|
"UGA",
|
||||||
"AUB"
|
"AUB"
|
||||||
@@ -419,8 +413,6 @@
|
|||||||
"recent_games_to_show": 1,
|
"recent_games_to_show": 1,
|
||||||
"upcoming_games_to_show": 1,
|
"upcoming_games_to_show": 1,
|
||||||
"show_favorite_teams_only": true,
|
"show_favorite_teams_only": true,
|
||||||
"show_all_live": false,
|
|
||||||
"show_shots_on_goal": false,
|
|
||||||
"favorite_teams": [
|
"favorite_teams": [
|
||||||
"RIT"
|
"RIT"
|
||||||
],
|
],
|
||||||
@@ -452,8 +444,6 @@
|
|||||||
"recent_games_to_show": 1,
|
"recent_games_to_show": 1,
|
||||||
"upcoming_games_to_show": 1,
|
"upcoming_games_to_show": 1,
|
||||||
"show_favorite_teams_only": true,
|
"show_favorite_teams_only": true,
|
||||||
"show_all_live": false,
|
|
||||||
"show_series_summary": false,
|
|
||||||
"favorite_teams": [
|
"favorite_teams": [
|
||||||
"TB",
|
"TB",
|
||||||
"TEX"
|
"TEX"
|
||||||
@@ -611,5 +601,16 @@
|
|||||||
0,
|
0,
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"static_image": {
|
||||||
|
"enabled": false,
|
||||||
|
"image_path": "assets/static_images/default.png",
|
||||||
|
"fit_to_display": true,
|
||||||
|
"preserve_aspect_ratio": true,
|
||||||
|
"background_color": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentM
|
|||||||
from src.youtube_display import YouTubeDisplay
|
from src.youtube_display import YouTubeDisplay
|
||||||
from src.calendar_manager import CalendarManager
|
from src.calendar_manager import CalendarManager
|
||||||
from src.text_display import TextDisplay
|
from src.text_display import TextDisplay
|
||||||
|
from src.static_image_manager import StaticImageManager
|
||||||
from src.music_manager import MusicManager
|
from src.music_manager import MusicManager
|
||||||
from src.of_the_day_manager import OfTheDayManager
|
from src.of_the_day_manager import OfTheDayManager
|
||||||
from src.news_manager import NewsManager
|
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.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.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.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.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
|
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"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"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"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(f"News Manager initialized: {'Object' if self.news_manager else 'None'}")
|
||||||
logger.info("Display modes initialized in %.3f seconds", time.time() - init_time)
|
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.calendar: self.available_modes.append('calendar')
|
||||||
if self.youtube: self.available_modes.append('youtube')
|
if self.youtube: self.available_modes.append('youtube')
|
||||||
if self.text_display: self.available_modes.append('text_display')
|
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.of_the_day: self.available_modes.append('of_the_day')
|
||||||
if self.news_manager: self.available_modes.append('news_manager')
|
if self.news_manager: self.available_modes.append('news_manager')
|
||||||
if self.music_manager:
|
if self.music_manager:
|
||||||
@@ -600,6 +604,7 @@ class DisplayController:
|
|||||||
if self.calendar: self.calendar.update(time.time())
|
if self.calendar: self.calendar.update(time.time())
|
||||||
if self.youtube: self.youtube.update()
|
if self.youtube: self.youtube.update()
|
||||||
if self.text_display: self.text_display.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())
|
if self.of_the_day: self.of_the_day.update(time.time())
|
||||||
else:
|
else:
|
||||||
# Not scrolling, perform all updates normally
|
# Not scrolling, perform all updates normally
|
||||||
@@ -610,6 +615,7 @@ class DisplayController:
|
|||||||
if self.calendar: self.calendar.update(time.time())
|
if self.calendar: self.calendar.update(time.time())
|
||||||
if self.youtube: self.youtube.update()
|
if self.youtube: self.youtube.update()
|
||||||
if self.text_display: self.text_display.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())
|
if self.of_the_day: self.of_the_day.update(time.time())
|
||||||
|
|
||||||
# Update sports managers for leaderboard data
|
# Update sports managers for leaderboard data
|
||||||
@@ -1208,6 +1214,8 @@ class DisplayController:
|
|||||||
manager_to_display = self.youtube
|
manager_to_display = self.youtube
|
||||||
elif self.current_display_mode == 'text_display' and self.text_display:
|
elif self.current_display_mode == 'text_display' and self.text_display:
|
||||||
manager_to_display = 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:
|
elif self.current_display_mode == 'of_the_day' and self.of_the_day:
|
||||||
manager_to_display = self.of_the_day
|
manager_to_display = self.of_the_day
|
||||||
elif self.current_display_mode == 'news_manager' and self.news_manager:
|
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)
|
manager_to_display.display(force_clear=self.force_clear)
|
||||||
elif self.current_display_mode == 'text_display':
|
elif self.current_display_mode == 'text_display':
|
||||||
manager_to_display.display() # Assumes internal clearing
|
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':
|
elif self.current_display_mode == 'of_the_day':
|
||||||
manager_to_display.display(force_clear=self.force_clear)
|
manager_to_display.display(force_clear=self.force_clear)
|
||||||
elif self.current_display_mode == 'news_manager':
|
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
|
||||||
@@ -875,6 +875,9 @@
|
|||||||
<button class="tab-btn" onclick="showTab('text')">
|
<button class="tab-btn" onclick="showTab('text')">
|
||||||
<i class="fas fa-font"></i> Text
|
<i class="fas fa-font"></i> Text
|
||||||
</button>
|
</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('static_image')">
|
||||||
|
<i class="fas fa-image"></i> Static Image
|
||||||
|
</button>
|
||||||
<button class="tab-btn" onclick="showTab('features')">
|
<button class="tab-btn" onclick="showTab('features')">
|
||||||
<i class="fas fa-star"></i> Features
|
<i class="fas fa-star"></i> Features
|
||||||
</button>
|
</button>
|
||||||
@@ -1637,6 +1640,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Static Image Tab -->
|
||||||
|
<div id="static_image" class="tab-content">
|
||||||
|
<div class="config-section">
|
||||||
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
||||||
|
<h3>Static Image Display</h3>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button type="button" class="btn btn-info" onclick="startOnDemand('static_image')"><i class="fas fa-bolt"></i> On-Demand</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="static_image-form">
|
||||||
|
<div class="form-group"><label><input type="checkbox" id="static_image_enabled" {% if safe_config_get(main_config, 'static_image', 'enabled', default=False) %}checked{% endif %}> Enable</label></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="static_image_path">Image Path</label>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<input type="text" id="static_image_path" class="form-control" value="{{ safe_config_get(main_config, 'static_image', 'image_path', default='assets/static_images/default.png') }}">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('image_upload').click()"><i class="fas fa-upload"></i> Upload</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="image_upload" accept="image/*" style="display:none" onchange="handleImageUpload(this)">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label><input type="checkbox" id="static_image_fit_to_display" {% if safe_config_get(main_config, 'static_image', 'fit_to_display', default=True) %}checked{% endif %}> Fit to Display</label></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label><input type="checkbox" id="static_image_preserve_aspect_ratio" {% if safe_config_get(main_config, 'static_image', 'preserve_aspect_ratio', default=True) %}checked{% endif %}> Preserve Aspect Ratio</label></div>
|
||||||
|
<div class="form-group"><label for="static_image_background_color">Background Color</label><input type="color" id="static_image_background_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'static_image', 'background_color', default=[0, 0, 0]) | tojson }}'></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success">Save Static Image Settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Features Tab -->
|
<!-- Features Tab -->
|
||||||
<div id="features" class="tab-content">
|
<div id="features" class="tab-content">
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
@@ -3114,6 +3148,55 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Static Image form submit
|
||||||
|
(function augmentStaticImageForm(){
|
||||||
|
const form = document.getElementById('static_image-form');
|
||||||
|
const initColor = (input) => {
|
||||||
|
try { const rgb = JSON.parse(input.dataset.rgb || '[255,255,255]'); input.value = rgbToHex(rgb); } catch {}
|
||||||
|
};
|
||||||
|
initColor(document.getElementById('static_image_background_color'));
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
static_image: {
|
||||||
|
enabled: document.getElementById('static_image_enabled').checked,
|
||||||
|
image_path: document.getElementById('static_image_path').value,
|
||||||
|
fit_to_display: document.getElementById('static_image_fit_to_display').checked,
|
||||||
|
preserve_aspect_ratio: document.getElementById('static_image_preserve_aspect_ratio').checked,
|
||||||
|
background_color: hexToRgbArray(document.getElementById('static_image_background_color').value)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await saveConfigJson(payload);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Image upload handler
|
||||||
|
function handleImageUpload(input) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
fetch('/upload_image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('static_image_path').value = data.path;
|
||||||
|
showNotification('Image uploaded successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
showNotification('Failed to upload image: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showNotification('Error uploading image: ' + error, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// YouTube form submit
|
// YouTube form submit
|
||||||
(function augmentYouTubeForm(){
|
(function augmentYouTubeForm(){
|
||||||
const form = document.getElementById('youtube-form');
|
const form = document.getElementById('youtube-form');
|
||||||
|
|||||||
1
test/test_odds_fix.py
Normal file
1
test/test_odds_fix.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
test/test_odds_fix_simple.py
Normal file
1
test/test_odds_fix_simple.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
100
test_static_image.py
Normal file
100
test_static_image.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for the static image manager.
|
||||||
|
This script tests the static image manager functionality without requiring the full LED matrix hardware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Add the src directory to the path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||||
|
|
||||||
|
from src.static_image_manager import StaticImageManager
|
||||||
|
from src.display_manager import DisplayManager
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class MockDisplayManager:
|
||||||
|
"""Mock display manager for testing without hardware."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.matrix = type('Matrix', (), {'width': 64, 'height': 32})()
|
||||||
|
self.image = Image.new("RGB", (self.matrix.width, self.matrix.height))
|
||||||
|
self.draw = None
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear the display."""
|
||||||
|
self.image = Image.new("RGB", (self.matrix.width, self.matrix.height))
|
||||||
|
logger.info("Display cleared")
|
||||||
|
|
||||||
|
def update_display(self):
|
||||||
|
"""Update the display (mock)."""
|
||||||
|
logger.info("Display updated")
|
||||||
|
|
||||||
|
def test_static_image_manager():
|
||||||
|
"""Test the static image manager functionality."""
|
||||||
|
logger.info("Starting static image manager test...")
|
||||||
|
|
||||||
|
# Create mock display manager
|
||||||
|
display_manager = MockDisplayManager()
|
||||||
|
|
||||||
|
# Test configuration
|
||||||
|
config = {
|
||||||
|
'static_image': {
|
||||||
|
'enabled': True,
|
||||||
|
'image_path': 'assets/static_images/default.png',
|
||||||
|
'display_duration': 10,
|
||||||
|
'zoom_scale': 1.0,
|
||||||
|
'preserve_aspect_ratio': True,
|
||||||
|
'background_color': [0, 0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Initialize the static image manager
|
||||||
|
logger.info("Initializing static image manager...")
|
||||||
|
manager = StaticImageManager(display_manager, config)
|
||||||
|
|
||||||
|
# Test basic functionality
|
||||||
|
logger.info(f"Manager enabled: {manager.is_enabled()}")
|
||||||
|
logger.info(f"Display duration: {manager.get_display_duration()}")
|
||||||
|
|
||||||
|
# Test image loading
|
||||||
|
if manager.image_loaded:
|
||||||
|
logger.info("Image loaded successfully")
|
||||||
|
image_info = manager.get_image_info()
|
||||||
|
logger.info(f"Image info: {image_info}")
|
||||||
|
else:
|
||||||
|
logger.warning("Image not loaded")
|
||||||
|
|
||||||
|
# Test display
|
||||||
|
logger.info("Testing display...")
|
||||||
|
manager.display()
|
||||||
|
|
||||||
|
# Test configuration changes
|
||||||
|
logger.info("Testing configuration changes...")
|
||||||
|
manager.set_zoom_scale(1.5)
|
||||||
|
manager.set_display_duration(15)
|
||||||
|
manager.set_background_color((255, 0, 0))
|
||||||
|
|
||||||
|
# Test with a different image path (if it exists)
|
||||||
|
test_image_path = 'assets/static_images/test.png'
|
||||||
|
if os.path.exists(test_image_path):
|
||||||
|
logger.info(f"Testing with image: {test_image_path}")
|
||||||
|
manager.set_image_path(test_image_path)
|
||||||
|
|
||||||
|
logger.info("Static image manager test completed successfully!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Test failed with error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
success = test_static_image_manager()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
136
test_static_image_simple.py
Normal file
136
test_static_image_simple.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simple test script for the static image manager.
|
||||||
|
This script tests the image processing functionality without requiring the full LED matrix hardware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def test_image_processing():
|
||||||
|
"""Test image processing functionality."""
|
||||||
|
logger.info("Testing image processing...")
|
||||||
|
|
||||||
|
# Test image path
|
||||||
|
image_path = 'assets/static_images/default.png'
|
||||||
|
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
logger.error(f"Test image not found: {image_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load the image
|
||||||
|
img = Image.open(image_path)
|
||||||
|
logger.info(f"Original image size: {img.size}")
|
||||||
|
|
||||||
|
# Test different zoom scales
|
||||||
|
display_size = (64, 32)
|
||||||
|
|
||||||
|
for zoom_scale in [0.5, 1.0, 1.5, 2.0]:
|
||||||
|
logger.info(f"Testing zoom scale: {zoom_scale}")
|
||||||
|
|
||||||
|
# Calculate target size
|
||||||
|
if zoom_scale == 1.0:
|
||||||
|
# Fit to display while preserving aspect ratio
|
||||||
|
scale_x = display_size[0] / img.size[0]
|
||||||
|
scale_y = display_size[1] / img.size[1]
|
||||||
|
scale = min(scale_x, scale_y)
|
||||||
|
target_size = (int(img.size[0] * scale), int(img.size[1] * scale))
|
||||||
|
else:
|
||||||
|
# Apply zoom scale
|
||||||
|
target_size = (int(img.size[0] * zoom_scale), int(img.size[1] * zoom_scale))
|
||||||
|
|
||||||
|
logger.info(f"Target size: {target_size}")
|
||||||
|
|
||||||
|
# Resize image
|
||||||
|
resized_img = img.resize(target_size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Create display canvas
|
||||||
|
canvas = Image.new('RGB', display_size, (0, 0, 0))
|
||||||
|
|
||||||
|
# Center the image
|
||||||
|
paste_x = max(0, (display_size[0] - resized_img.width) // 2)
|
||||||
|
paste_y = max(0, (display_size[1] - resized_img.height) // 2)
|
||||||
|
|
||||||
|
# Handle transparency
|
||||||
|
if resized_img.mode == 'RGBA':
|
||||||
|
temp_canvas = Image.new('RGB', display_size, (0, 0, 0))
|
||||||
|
temp_canvas.paste(resized_img, (paste_x, paste_y), resized_img)
|
||||||
|
canvas = temp_canvas
|
||||||
|
else:
|
||||||
|
canvas.paste(resized_img, (paste_x, paste_y))
|
||||||
|
|
||||||
|
logger.info(f"Final canvas size: {canvas.size}")
|
||||||
|
logger.info(f"Image position: ({paste_x}, {paste_y})")
|
||||||
|
|
||||||
|
# Save test output
|
||||||
|
output_path = f'test_output_zoom_{zoom_scale}.png'
|
||||||
|
canvas.save(output_path)
|
||||||
|
logger.info(f"Test output saved: {output_path}")
|
||||||
|
|
||||||
|
logger.info("Image processing test completed successfully!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Test failed with error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_config_loading():
|
||||||
|
"""Test configuration loading."""
|
||||||
|
logger.info("Testing configuration loading...")
|
||||||
|
|
||||||
|
# Test configuration
|
||||||
|
config = {
|
||||||
|
'static_image': {
|
||||||
|
'enabled': True,
|
||||||
|
'image_path': 'assets/static_images/default.png',
|
||||||
|
'display_duration': 10,
|
||||||
|
'zoom_scale': 1.0,
|
||||||
|
'preserve_aspect_ratio': True,
|
||||||
|
'background_color': [0, 0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test configuration parsing
|
||||||
|
static_config = config.get('static_image', {})
|
||||||
|
enabled = static_config.get('enabled', False)
|
||||||
|
image_path = static_config.get('image_path', '')
|
||||||
|
display_duration = static_config.get('display_duration', 10)
|
||||||
|
zoom_scale = static_config.get('zoom_scale', 1.0)
|
||||||
|
preserve_aspect_ratio = static_config.get('preserve_aspect_ratio', True)
|
||||||
|
background_color = tuple(static_config.get('background_color', [0, 0, 0]))
|
||||||
|
|
||||||
|
logger.info(f"Configuration loaded:")
|
||||||
|
logger.info(f" Enabled: {enabled}")
|
||||||
|
logger.info(f" Image path: {image_path}")
|
||||||
|
logger.info(f" Display duration: {display_duration}")
|
||||||
|
logger.info(f" Zoom scale: {zoom_scale}")
|
||||||
|
logger.info(f" Preserve aspect ratio: {preserve_aspect_ratio}")
|
||||||
|
logger.info(f" Background color: {background_color}")
|
||||||
|
|
||||||
|
logger.info("Configuration loading test completed successfully!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Configuration test failed with error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger.info("Starting static image manager simple test...")
|
||||||
|
|
||||||
|
success1 = test_config_loading()
|
||||||
|
success2 = test_image_processing()
|
||||||
|
|
||||||
|
if success1 and success2:
|
||||||
|
logger.info("All tests completed successfully!")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
logger.error("Some tests failed!")
|
||||||
|
sys.exit(1)
|
||||||
@@ -20,7 +20,9 @@ from src.odds_ticker_manager import OddsTickerManager
|
|||||||
from src.calendar_manager import CalendarManager
|
from src.calendar_manager import CalendarManager
|
||||||
from src.youtube_display import YouTubeDisplay
|
from src.youtube_display import YouTubeDisplay
|
||||||
from src.text_display import TextDisplay
|
from src.text_display import TextDisplay
|
||||||
|
from src.static_image_manager import StaticImageManager
|
||||||
from src.news_manager import NewsManager
|
from src.news_manager import NewsManager
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager
|
from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager
|
||||||
from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager
|
from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager
|
||||||
from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager
|
from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager
|
||||||
@@ -421,6 +423,10 @@ class OnDemandRunner:
|
|||||||
mgr = TextDisplay(display_manager, cfg)
|
mgr = TextDisplay(display_manager, cfg)
|
||||||
self._force_enable(mgr)
|
self._force_enable(mgr)
|
||||||
return mgr, lambda fc=False: mgr.display(), lambda: getattr(mgr, 'update', lambda: None)(), 5.0
|
return mgr, lambda fc=False: mgr.display(), lambda: getattr(mgr, 'update', lambda: None)(), 5.0
|
||||||
|
if mode == 'static_image':
|
||||||
|
mgr = StaticImageManager(display_manager, cfg)
|
||||||
|
self._force_enable(mgr)
|
||||||
|
return mgr, lambda fc=False: mgr.display(force_clear=fc), lambda: mgr.update(), float(cfg.get('display', {}).get('display_durations', {}).get('static_image', 10))
|
||||||
if mode == 'of_the_day':
|
if mode == 'of_the_day':
|
||||||
from src.of_the_day_manager import OfTheDayManager # local import to avoid circulars
|
from src.of_the_day_manager import OfTheDayManager # local import to avoid circulars
|
||||||
mgr = OfTheDayManager(display_manager, cfg)
|
mgr = OfTheDayManager(display_manager, cfg)
|
||||||
@@ -1594,6 +1600,42 @@ def get_current_display():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e), 'image': None}), 500
|
return jsonify({'status': 'error', 'message': str(e), 'image': None}), 500
|
||||||
|
|
||||||
|
@app.route('/upload_image', methods=['POST'])
|
||||||
|
def upload_image():
|
||||||
|
"""Upload an image for static image display."""
|
||||||
|
try:
|
||||||
|
if 'image' not in request.files:
|
||||||
|
return jsonify({'success': False, 'error': 'No image file provided'})
|
||||||
|
|
||||||
|
file = request.files['image']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({'success': False, 'error': 'No image file selected'})
|
||||||
|
|
||||||
|
if file:
|
||||||
|
# Secure the filename
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
# Ensure we have a valid extension
|
||||||
|
if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp')):
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid file type. Only image files are allowed.'})
|
||||||
|
|
||||||
|
# Create the static images directory if it doesn't exist
|
||||||
|
static_images_dir = os.path.join(os.path.dirname(__file__), 'assets', 'static_images')
|
||||||
|
os.makedirs(static_images_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
file_path = os.path.join(static_images_dir, filename)
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
# Return the relative path for the config
|
||||||
|
relative_path = f"assets/static_images/{filename}"
|
||||||
|
|
||||||
|
logger.info(f"Image uploaded successfully: {relative_path}")
|
||||||
|
return jsonify({'success': True, 'path': relative_path})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error uploading image: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
@app.route('/api/editor/layouts', methods=['GET'])
|
@app.route('/api/editor/layouts', methods=['GET'])
|
||||||
def get_custom_layouts():
|
def get_custom_layouts():
|
||||||
"""Return saved custom layouts for the editor if available."""
|
"""Return saved custom layouts for the editor if available."""
|
||||||
|
|||||||
Reference in New Issue
Block a user