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,
|
||||
"music": 30,
|
||||
"of_the_day": 40,
|
||||
"news_manager": 60
|
||||
"news_manager": 60,
|
||||
"static_image": 10
|
||||
},
|
||||
"use_short_date_format": true
|
||||
},
|
||||
@@ -130,7 +131,7 @@
|
||||
"duration_buffer": 0.1
|
||||
},
|
||||
"odds_ticker": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"show_favorite_teams_only": true,
|
||||
"games_per_favorite_team": 1,
|
||||
"max_games_per_league": 5,
|
||||
@@ -233,8 +234,6 @@
|
||||
"recent_games_to_show": 1,
|
||||
"upcoming_games_to_show": 1,
|
||||
"show_favorite_teams_only": true,
|
||||
"show_all_live": false,
|
||||
"show_shots_on_goal": false,
|
||||
"favorite_teams": [
|
||||
"TB"
|
||||
],
|
||||
@@ -301,7 +300,6 @@
|
||||
"recent_games_to_show": 1,
|
||||
"upcoming_games_to_show": 1,
|
||||
"show_favorite_teams_only": true,
|
||||
"show_all_live": false,
|
||||
"favorite_teams": [
|
||||
"TB",
|
||||
"DAL"
|
||||
@@ -334,11 +332,9 @@
|
||||
"recent_games_to_show": 1,
|
||||
"upcoming_games_to_show": 1,
|
||||
"show_favorite_teams_only": true,
|
||||
"show_all_live": false,
|
||||
"favorite_teams": [
|
||||
"UGA",
|
||||
"AUB",
|
||||
"AP_TOP_25"
|
||||
"AUB"
|
||||
],
|
||||
"logo_dir": "assets/sports/ncaa_logos",
|
||||
"show_records": true,
|
||||
@@ -369,8 +365,6 @@
|
||||
"recent_games_to_show": 1,
|
||||
"upcoming_games_to_show": 1,
|
||||
"show_favorite_teams_only": true,
|
||||
"show_all_live": false,
|
||||
"show_series_summary": false,
|
||||
"favorite_teams": [
|
||||
"UGA",
|
||||
"AUB"
|
||||
@@ -419,8 +413,6 @@
|
||||
"recent_games_to_show": 1,
|
||||
"upcoming_games_to_show": 1,
|
||||
"show_favorite_teams_only": true,
|
||||
"show_all_live": false,
|
||||
"show_shots_on_goal": false,
|
||||
"favorite_teams": [
|
||||
"RIT"
|
||||
],
|
||||
@@ -452,8 +444,6 @@
|
||||
"recent_games_to_show": 1,
|
||||
"upcoming_games_to_show": 1,
|
||||
"show_favorite_teams_only": true,
|
||||
"show_all_live": false,
|
||||
"show_series_summary": false,
|
||||
"favorite_teams": [
|
||||
"TB",
|
||||
"TEX"
|
||||
@@ -611,5 +601,16 @@
|
||||
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.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
|
||||
@@ -875,6 +875,9 @@
|
||||
<button class="tab-btn" onclick="showTab('text')">
|
||||
<i class="fas fa-font"></i> Text
|
||||
</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')">
|
||||
<i class="fas fa-star"></i> Features
|
||||
</button>
|
||||
@@ -1637,6 +1640,37 @@
|
||||
</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 -->
|
||||
<div id="features" class="tab-content">
|
||||
<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
|
||||
(function augmentYouTubeForm(){
|
||||
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.youtube_display import YouTubeDisplay
|
||||
from src.text_display import TextDisplay
|
||||
from src.static_image_manager import StaticImageManager
|
||||
from src.news_manager import NewsManager
|
||||
from werkzeug.utils import secure_filename
|
||||
from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager
|
||||
from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager
|
||||
from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager
|
||||
@@ -421,6 +423,10 @@ class OnDemandRunner:
|
||||
mgr = TextDisplay(display_manager, cfg)
|
||||
self._force_enable(mgr)
|
||||
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':
|
||||
from src.of_the_day_manager import OfTheDayManager # local import to avoid circulars
|
||||
mgr = OfTheDayManager(display_manager, cfg)
|
||||
@@ -1594,6 +1600,42 @@ def get_current_display():
|
||||
except Exception as e:
|
||||
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'])
|
||||
def get_custom_layouts():
|
||||
"""Return saved custom layouts for the editor if available."""
|
||||
|
||||
Reference in New Issue
Block a user