From f3d02e07eac448fe2ac38ab4f0df78b4825c7463 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:46:36 -0400 Subject: [PATCH] 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 --- LEDMatrix.wiki | 2 +- assets/static_images/default.png | Bin 0 -> 675 bytes config/config.template.json | 29 +++-- src/display_controller.py | 10 ++ src/static_image_manager.py | 211 +++++++++++++++++++++++++++++++ templates/index_v2.html | 83 ++++++++++++ test/test_odds_fix.py | 1 + test/test_odds_fix_simple.py | 1 + test_static_image.py | 100 +++++++++++++++ test_static_image_simple.py | 136 ++++++++++++++++++++ web_interface_v2.py | 42 ++++++ 11 files changed, 600 insertions(+), 15 deletions(-) create mode 100644 assets/static_images/default.png create mode 100644 src/static_image_manager.py create mode 100644 test/test_odds_fix.py create mode 100644 test/test_odds_fix_simple.py create mode 100644 test_static_image.py create mode 100644 test_static_image_simple.py diff --git a/LEDMatrix.wiki b/LEDMatrix.wiki index fbd8d89a..a01c72e1 160000 --- a/LEDMatrix.wiki +++ b/LEDMatrix.wiki @@ -1 +1 @@ -Subproject commit fbd8d89a186e5757d1785737b0ee4c03ad442dbf +Subproject commit a01c72e156b46c08a5ef1c67db79acd73300a6f7 diff --git a/assets/static_images/default.png b/assets/static_images/default.png new file mode 100644 index 0000000000000000000000000000000000000000..6db472882aa994366e6195b8e7a024be378fd638 GIT binary patch literal 675 zcmV;U0$lxxP)&86bD7bO+mY~i-VA%NQx~K1qTNg9b9#G7DpF#Ye7&%6gt$UMcrCOB(^w+ zlS>e!+ChX0S_-8@Jr0RBwh0t|zEQ~G4Y}vt_qd05-@SPPK$w9LOnKp#`HQCPAA&2u zX}AKMhAY5n2lv&UT}Pj;o+YQw;dgw zKsXFRfFv2HNCe~K@cWTYLomN~KZA}0C<;g3n z>uFk*;vWKyBNih`S!S#K>G4Gb0=&KD%?$;Cnh4WjLEGkUoA3eftjs%n!W9v(m<6aoOB4{vWIMn?glT5UFI5=bQUuSx|6 z2M|SVFImR&GHNvhgXrtS(-U@fY}Q~>M(cGl8s+pf>vfV0g$QtNj_>c~GS}C2XOD*@ zS62zJS#TE@Nm7zDMwTgx%;#AsFq!1zBYS#mExWCh7RTXXD#fECrc%xLPfnQ6lUrLX zl_-iW3cliBT4JL?zhB=;?;(KzYc-OboV2$;Slof#-I*E0V%Xo;iT-{}O(By3G8qgG zVq(H{ouXiS8}T^q@4rv<_QLB0fPsO3&6w}7_?0M?&dyM+qFmOA`FVJ~*xUq=1%QPG zoS(O^i$oBOVr8YN900nyu(yX?4yuZH9H*yvezr}4c?t!Cnk7kk3Ka^> 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 diff --git a/templates/index_v2.html b/templates/index_v2.html index d3dc3c5a..c67a6be1 100644 --- a/templates/index_v2.html +++ b/templates/index_v2.html @@ -875,6 +875,9 @@ + @@ -1637,6 +1640,37 @@ + +
+
+
+

Static Image Display

+
+ +
+
+
+
+
+ +
+ + +
+ +
+
+
+
+
+
+
+
+ +
+
+
+
@@ -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'); diff --git a/test/test_odds_fix.py b/test/test_odds_fix.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/test/test_odds_fix.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/test_odds_fix_simple.py b/test/test_odds_fix_simple.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/test/test_odds_fix_simple.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test_static_image.py b/test_static_image.py new file mode 100644 index 00000000..f1a77784 --- /dev/null +++ b/test_static_image.py @@ -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) diff --git a/test_static_image_simple.py b/test_static_image_simple.py new file mode 100644 index 00000000..9e4d5878 --- /dev/null +++ b/test_static_image_simple.py @@ -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) diff --git a/web_interface_v2.py b/web_interface_v2.py index edc1304b..9e8cfebf 100644 --- a/web_interface_v2.py +++ b/web_interface_v2.py @@ -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."""