diff --git a/plugins/masters-tournament/README.md b/plugins/masters-tournament/README.md new file mode 100644 index 0000000..209eeb1 --- /dev/null +++ b/plugins/masters-tournament/README.md @@ -0,0 +1,401 @@ +# Masters Tournament LED Display Plugin + +A high-polish Augusta National Masters Tournament display plugin for LEDMatrix with course imagery, live leaderboards, player stats, hole maps, and maximum Masters branding. Works year-round with engaging off-season content. + +## Features + +### 14 Display Modes + +1. **Leaderboard** - Live tournament standings with scores, positions, and thru indicators (paginated) +2. **Player Cards** - Individual player spotlights with ESPN headshots, country flags, round scores, green jacket count +3. **Hole-by-Hole** - Rotating hole cards with real Augusta National overhead maps +4. **Live Action** - Real-time scoring alerts and leader updates +5. **Course Tour** - Rotating hole maps showcasing all 18 holes at Augusta National +6. **Amen Corner** - Dedicated display for the famous holes 11-13 +7. **Featured Holes** - Highlight signature holes (12, 13, 15, 16) +8. **Schedule** - Daily tee times and pairings (paginated) +9. **Past Champions** - Historical Masters winners through 2025 (paginated) +10. **Tournament Stats** - Tournament records and statistics (paginated) +11. **Fun Facts** - 35 real Masters and Augusta National trivia facts with scrolling text +12. **Countdown** - Days until the next Masters Tournament +13. **Field Overview** - Under/over/even par breakdown with leader highlight +14. **Course Overview** - Augusta National front nine / back nine stats and signature holes + +### Dynamic Scaling + +Automatically adapts to any LED matrix size: +- **32x16**: Minimal layout with 1-2 players, abbreviated names +- **64x32**: Standard layout with 3-4 players, basic stats (recommended) +- **128x64+**: Maximum detail with 5-8 players, full statistics, photos + +### Masters Branding + +Authentic Augusta National visual identity: +- **Masters green** (#00784A) as primary brand color +- **Gold accents** for leaders +- **Azalea pink** decorative elements +- Masters logo placement +- Green jacket icons +- Course-specific imagery + +### Year-Round Operation + +- **Tournament Week**: Live leaderboards, player tracking, real-time updates +- **Practice Rounds**: Schedule displays, course tours, player preparation +- **Off-Season**: Past champions, course beauty, tournament countdown + +## Installation + +### Via Plugin Store (Recommended) + +1. Open LEDMatrix Web UI +2. Navigate to **Plugins** → **Plugin Store** +3. Find "Masters Tournament" +4. Click **Install** +5. Configure and enable + +### Manual Installation + +```bash +cd ~/Github/ledmatrix-plugins/plugins +git clone masters-tournament +cd masters-tournament +pip install -r requirements.txt +``` + +## Configuration + +### Basic Setup + +```json +{ + "enabled": true, + "display_duration": 20, + "update_interval": 30, + "mock_data": false, + "favorite_players": [ + "Scottie Scheffler", + "Rory McIlroy" + ] +} +``` + +### Display Modes Configuration + +Enable/disable specific modes and configure their settings: + +```json +{ + "display_modes": { + "leaderboard": { + "enabled": true, + "top_n": 10, + "show_favorites_always": true, + "duration": 25 + }, + "player_cards": { + "enabled": true, + "show_headshots": true, + "duration_per_player": 15 + }, + "course_tour": { + "enabled": true, + "show_animations": true, + "duration_per_hole": 15, + "featured_holes": [12, 13, 16] + } + } +} +``` + +### Notifications + +Configure alerts and interruptions: + +```json +{ + "notifications": { + "practice_round_alerts": { + "enabled": true, + "interrupt_display": true, + "duration": 15 + }, + "favorite_player_alerts": { + "enabled": true, + "interrupt_display": true, + "duration": 10 + } + } +} +``` + +### Branding Options + +Customize Masters visual elements: + +```json +{ + "branding": { + "show_masters_logo": true, + "show_green_jacket": true, + "show_azaleas": true, + "color_scheme": "classic" + } +} +``` + +## Data Source + +This plugin uses the **ESPN Golf API** (free, no API key required): + +- **Live Leaderboard**: Updates every 30 seconds during tournament play +- **Player Statistics**: Detailed round-by-round scores +- **Schedule**: Tee times and pairings +- **Player Photos**: Downloaded and cached locally + +### Caching Strategy + +- **Live tournament**: 30-second cache for leaderboard +- **Practice rounds**: 5-minute cache +- **Off-season**: 1-hour cache for historical data +- **Player photos**: Permanent local cache (download once) + +### Mock Data Mode + +For testing when the Masters isn't live: + +```json +{ + "mock_data": true +} +``` + +This generates realistic mock leaderboard data with: +- 10 players with authentic names +- Scores ranging from -12 to -3 +- Round scores and thru indicators +- Simulated tournament conditions + +## Usage Examples + +### Tournament Week Setup + +Monitor your favorite players during Masters week: + +```json +{ + "enabled": true, + "favorite_players": ["Scottie Scheffler", "Jon Rahm"], + "display_modes": { + "leaderboard": {"enabled": true, "duration": 30}, + "player_cards": {"enabled": true, "duration_per_player": 20}, + "live_action": {"enabled": true} + }, + "update_interval": 30, + "notifications": { + "favorite_player_alerts": { + "enabled": true, + "interrupt_display": true + } + } +} +``` + +### Off-Season Display + +Celebrate Masters history year-round: + +```json +{ + "enabled": true, + "display_modes": { + "past_champions": {"enabled": true, "duration": 25}, + "course_tour": {"enabled": true, "duration_per_hole": 20}, + "tournament_stats": {"enabled": true} + }, + "update_interval": 3600 +} +``` + +### Course Showcase + +Focus on Augusta National's beauty: + +```json +{ + "enabled": true, + "display_modes": { + "course_tour": { + "enabled": true, + "featured_holes": [11, 12, 13, 16], + "duration_per_hole": 25 + }, + "amen_corner": {"enabled": true} + } +} +``` + +## Vegas Scroll Mode + +When Vegas scroll mode is active, the plugin provides: + +- Individual player cards for each leaderboard entry +- Hole map cards for all 18 holes +- Past champion cards +- Smooth scrolling integration with other plugins + +## Display Size Optimization + +### 32x16 (Minimal) +- 1-2 players maximum +- Position, abbreviated name, score +- No country flags or round scores +- 8x8px logos + +### 64x32 (Standard) +- 3-4 players +- Position, name, country, score, thru +- 16x16px logos +- Full Masters branding + +### 128x64 (Maximum Detail) +- 5-8 players +- Position, name, country, scores, rounds, photos +- 24x24px player headshots +- Detailed statistics +- Enhanced visual elements + +## Assets + +### Bundled Assets +- Masters logo (simplified, tournament-inspired design) +- Green jacket icon +- Azalea flower icons +- 18 hole map placeholders (auto-generated) + +### Downloaded Assets +- Player headshots from ESPN (cached in `assets/masters/players/`) + +### Creating Custom Hole Maps + +To add custom hole map images: + +1. Create PNG images sized 512x512px +2. Name them `hole_01.png` through `hole_18.png` +3. Place in `assets/masters/courses/` +4. Plugin will automatically load and scale them + +## Troubleshooting + +### No Data Displayed + +Check these common issues: + +1. **Masters not currently active**: Enable `mock_data: true` for testing +2. **API timeout**: Check network connectivity +3. **Cache issues**: Clear cache via web UI or restart LEDMatrix + +### Text Too Small + +Adjust display size settings: + +```json +{ + "display_duration": 30 +} +``` + +Longer duration allows easier reading of small text. + +### Favorite Players Not Showing + +Ensure exact name match: + +```json +{ + "favorite_players": ["Scottie Scheffler"] +} +``` + +Check ESPN leaderboard for correct spelling. + +## Tournament Schedule + +The Masters is typically held: + +- **Practice Rounds**: Monday-Wednesday (April 6-8) +- **Tournament**: Thursday-Sunday (April 9-12) + +Plugin automatically detects tournament phase and adjusts: +- Update intervals (30s live, 5m practice, 1h off-season) +- Cache duration +- Mode prioritization + +## Development + +### Testing with Mock Data + +```bash +# Enable mock mode in config +cd ~/Github/ledmatrix-plugins/plugins/masters-tournament +# Edit config to set "mock_data": true + +# Restart LEDMatrix +sudo systemctl restart ledmatrix + +# Monitor logs +tail -f /var/log/ledmatrix/ledmatrix.log +``` + +### Adding New Display Modes + +1. Add mode to `manifest.json` `display_modes` array +2. Add config schema entry in `config_schema.json` +3. Implement rendering in `masters_renderer.py` +4. Add display method in `manager.py` +5. Update `_build_enabled_modes()` mapping + +## API Reference + +### ESPN Golf API Endpoints + +- **Leaderboard**: `https://site.api.espn.com/apis/site/v2/sports/golf/pga/leaderboard` +- **Schedule**: `https://site.api.espn.com/apis/site/v2/sports/golf/pga/schedule` +- **News**: `https://site.api.espn.com/apis/site/v2/sports/golf/pga/news` + +No API key required. Rate limits apply (plugin respects with caching). + +## Credits + +- **Plugin Development**: Claude (Anthropic) +- **Masters Tournament**: Augusta National Golf Club +- **Data Provider**: ESPN Golf API +- **LED Matrix Framework**: LEDMatrix by ChuckBuilds + +## License + +This plugin is for personal, non-commercial use only. Masters Tournament, Augusta National, and related branding are trademarks of Augusta National, Inc. + +## Version History + +### 2.0.0 +- 14 display modes (added fun facts, countdown, field overview, course overview) +- Real Masters logo from masters.com +- Real Augusta National overhead hole maps for all 18 holes +- 23 real ESPN player headshots +- 16 country flags for player cards +- Phase-aware mode rotation (off-season, pre-tournament, practice, live, evening) +- Paginated displays with page indicator dots +- Broadcast-quality pixel-perfect rendering +- 35 fun facts, 40 past champions through 2025, tournament records database +- Player cards with green jacket count and round-by-round scores + +### 1.0.0 (Initial Release) +- 10 display modes +- ESPN API integration +- Dynamic scaling (32x16 to 128x64+) +- Mock data support +- Vegas scroll mode +- Year-round operation +- Configurable notifications +- Masters branding diff --git a/plugins/masters-tournament/__init__.py b/plugins/masters-tournament/__init__.py new file mode 100644 index 0000000..897e235 --- /dev/null +++ b/plugins/masters-tournament/__init__.py @@ -0,0 +1,11 @@ +""" +Masters Tournament Plugin + +A comprehensive LED display plugin for the Masters Tournament +at Augusta National Golf Club. +""" + +__version__ = "2.0.0" +__author__ = "Claude" + +__all__ = ["MastersTournamentPlugin"] diff --git a/plugins/masters-tournament/assets/masters/backgrounds/augusta_green_texture.png b/plugins/masters-tournament/assets/masters/backgrounds/augusta_green_texture.png new file mode 100644 index 0000000..e6cc9da Binary files /dev/null and b/plugins/masters-tournament/assets/masters/backgrounds/augusta_green_texture.png differ diff --git a/plugins/masters-tournament/assets/masters/backgrounds/masters_green_gradient.png b/plugins/masters-tournament/assets/masters/backgrounds/masters_green_gradient.png new file mode 100644 index 0000000..5801ce0 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/backgrounds/masters_green_gradient.png differ diff --git a/plugins/masters-tournament/assets/masters/backgrounds/masters_green_gradient_128x64.png b/plugins/masters-tournament/assets/masters/backgrounds/masters_green_gradient_128x64.png new file mode 100644 index 0000000..5801ce0 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/backgrounds/masters_green_gradient_128x64.png differ diff --git a/plugins/masters-tournament/assets/masters/backgrounds/masters_green_gradient_64x32.png b/plugins/masters-tournament/assets/masters/backgrounds/masters_green_gradient_64x32.png new file mode 100644 index 0000000..e450a3d Binary files /dev/null and b/plugins/masters-tournament/assets/masters/backgrounds/masters_green_gradient_64x32.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_01.png b/plugins/masters-tournament/assets/masters/courses/hole_01.png new file mode 100644 index 0000000..3419eb7 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_01.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_02.png b/plugins/masters-tournament/assets/masters/courses/hole_02.png new file mode 100644 index 0000000..bddf830 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_02.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_03.png b/plugins/masters-tournament/assets/masters/courses/hole_03.png new file mode 100644 index 0000000..fbdf1ee Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_03.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_04.png b/plugins/masters-tournament/assets/masters/courses/hole_04.png new file mode 100644 index 0000000..d62722a Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_04.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_05.png b/plugins/masters-tournament/assets/masters/courses/hole_05.png new file mode 100644 index 0000000..612b7f2 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_05.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_06.png b/plugins/masters-tournament/assets/masters/courses/hole_06.png new file mode 100644 index 0000000..35617ae Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_06.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_07.png b/plugins/masters-tournament/assets/masters/courses/hole_07.png new file mode 100644 index 0000000..b206d2f Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_07.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_08.png b/plugins/masters-tournament/assets/masters/courses/hole_08.png new file mode 100644 index 0000000..b54ef77 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_08.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_09.png b/plugins/masters-tournament/assets/masters/courses/hole_09.png new file mode 100644 index 0000000..257cd2c Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_09.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_10.png b/plugins/masters-tournament/assets/masters/courses/hole_10.png new file mode 100644 index 0000000..1da99ad Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_10.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_11.png b/plugins/masters-tournament/assets/masters/courses/hole_11.png new file mode 100644 index 0000000..d82f07e Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_11.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_12.png b/plugins/masters-tournament/assets/masters/courses/hole_12.png new file mode 100644 index 0000000..a5bf9b1 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_12.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_13.png b/plugins/masters-tournament/assets/masters/courses/hole_13.png new file mode 100644 index 0000000..8b515ff Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_13.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_14.png b/plugins/masters-tournament/assets/masters/courses/hole_14.png new file mode 100644 index 0000000..89ce0b6 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_14.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_15.png b/plugins/masters-tournament/assets/masters/courses/hole_15.png new file mode 100644 index 0000000..6b2f503 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_15.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_16.png b/plugins/masters-tournament/assets/masters/courses/hole_16.png new file mode 100644 index 0000000..3e582f1 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_16.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_17.png b/plugins/masters-tournament/assets/masters/courses/hole_17.png new file mode 100644 index 0000000..d7b8b9b Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_17.png differ diff --git a/plugins/masters-tournament/assets/masters/courses/hole_18.png b/plugins/masters-tournament/assets/masters/courses/hole_18.png new file mode 100644 index 0000000..0fa7a4f Binary files /dev/null and b/plugins/masters-tournament/assets/masters/courses/hole_18.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/ARG.png b/plugins/masters-tournament/assets/masters/flags/ARG.png new file mode 100644 index 0000000..8168b07 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/ARG.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/AUS.png b/plugins/masters-tournament/assets/masters/flags/AUS.png new file mode 100644 index 0000000..3c85eae Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/AUS.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/CAN.png b/plugins/masters-tournament/assets/masters/flags/CAN.png new file mode 100644 index 0000000..2f3ff65 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/CAN.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/ENG.png b/plugins/masters-tournament/assets/masters/flags/ENG.png new file mode 100644 index 0000000..acf6e64 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/ENG.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/ESP.png b/plugins/masters-tournament/assets/masters/flags/ESP.png new file mode 100644 index 0000000..a804ead Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/ESP.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/FIJ.png b/plugins/masters-tournament/assets/masters/flags/FIJ.png new file mode 100644 index 0000000..6472ecd Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/FIJ.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/GER.png b/plugins/masters-tournament/assets/masters/flags/GER.png new file mode 100644 index 0000000..0452236 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/GER.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/IRL.png b/plugins/masters-tournament/assets/masters/flags/IRL.png new file mode 100644 index 0000000..1e3cadc Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/IRL.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/JPN.png b/plugins/masters-tournament/assets/masters/flags/JPN.png new file mode 100644 index 0000000..ebfd15a Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/JPN.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/NIR.png b/plugins/masters-tournament/assets/masters/flags/NIR.png new file mode 100644 index 0000000..39bc0e9 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/NIR.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/NOR.png b/plugins/masters-tournament/assets/masters/flags/NOR.png new file mode 100644 index 0000000..b37eac3 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/NOR.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/RSA.png b/plugins/masters-tournament/assets/masters/flags/RSA.png new file mode 100644 index 0000000..64c6182 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/RSA.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/SCO.png b/plugins/masters-tournament/assets/masters/flags/SCO.png new file mode 100644 index 0000000..865d174 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/SCO.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/SWE.png b/plugins/masters-tournament/assets/masters/flags/SWE.png new file mode 100644 index 0000000..1aa6729 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/SWE.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/USA.png b/plugins/masters-tournament/assets/masters/flags/USA.png new file mode 100644 index 0000000..8b8ce83 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/USA.png differ diff --git a/plugins/masters-tournament/assets/masters/flags/WAL.png b/plugins/masters-tournament/assets/masters/flags/WAL.png new file mode 100644 index 0000000..3bbea2d Binary files /dev/null and b/plugins/masters-tournament/assets/masters/flags/WAL.png differ diff --git a/plugins/masters-tournament/assets/masters/icons/golf_ball.png b/plugins/masters-tournament/assets/masters/icons/golf_ball.png new file mode 100644 index 0000000..492efdf Binary files /dev/null and b/plugins/masters-tournament/assets/masters/icons/golf_ball.png differ diff --git a/plugins/masters-tournament/assets/masters/icons/golf_flag.png b/plugins/masters-tournament/assets/masters/icons/golf_flag.png new file mode 100644 index 0000000..5738ee3 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/icons/golf_flag.png differ diff --git a/plugins/masters-tournament/assets/masters/icons/golf_tee.png b/plugins/masters-tournament/assets/masters/icons/golf_tee.png new file mode 100644 index 0000000..1cc4933 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/icons/golf_tee.png differ diff --git a/plugins/masters-tournament/assets/masters/logos/azalea.png b/plugins/masters-tournament/assets/masters/logos/azalea.png new file mode 100644 index 0000000..2b657fd Binary files /dev/null and b/plugins/masters-tournament/assets/masters/logos/azalea.png differ diff --git a/plugins/masters-tournament/assets/masters/logos/green_jacket.png b/plugins/masters-tournament/assets/masters/logos/green_jacket.png new file mode 100644 index 0000000..1034289 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/logos/green_jacket.png differ diff --git a/plugins/masters-tournament/assets/masters/logos/masters_logo.png b/plugins/masters-tournament/assets/masters/logos/masters_logo.png new file mode 100644 index 0000000..c49a6be Binary files /dev/null and b/plugins/masters-tournament/assets/masters/logos/masters_logo.png differ diff --git a/plugins/masters-tournament/assets/masters/logos/masters_logo_full.png b/plugins/masters-tournament/assets/masters/logos/masters_logo_full.png new file mode 100644 index 0000000..bd10cea Binary files /dev/null and b/plugins/masters-tournament/assets/masters/logos/masters_logo_full.png differ diff --git a/plugins/masters-tournament/assets/masters/logos/masters_logo_lg.png b/plugins/masters-tournament/assets/masters/logos/masters_logo_lg.png new file mode 100644 index 0000000..3eabe66 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/logos/masters_logo_lg.png differ diff --git a/plugins/masters-tournament/assets/masters/logos/masters_logo_md.png b/plugins/masters-tournament/assets/masters/logos/masters_logo_md.png new file mode 100644 index 0000000..27198b7 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/logos/masters_logo_md.png differ diff --git a/plugins/masters-tournament/assets/masters/logos/masters_logo_sm.png b/plugins/masters-tournament/assets/masters/logos/masters_logo_sm.png new file mode 100644 index 0000000..fab4537 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/logos/masters_logo_sm.png differ diff --git a/plugins/masters-tournament/assets/masters/players/10134.png b/plugins/masters-tournament/assets/masters/players/10134.png new file mode 100644 index 0000000..e37ef01 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/10134.png differ diff --git a/plugins/masters-tournament/assets/masters/players/10138.png b/plugins/masters-tournament/assets/masters/players/10138.png new file mode 100644 index 0000000..ec11211 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/10138.png differ diff --git a/plugins/masters-tournament/assets/masters/players/10140.png b/plugins/masters-tournament/assets/masters/players/10140.png new file mode 100644 index 0000000..52a1f70 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/10140.png differ diff --git a/plugins/masters-tournament/assets/masters/players/10591.png b/plugins/masters-tournament/assets/masters/players/10591.png new file mode 100644 index 0000000..849bdba Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/10591.png differ diff --git a/plugins/masters-tournament/assets/masters/players/10592.png b/plugins/masters-tournament/assets/masters/players/10592.png new file mode 100644 index 0000000..81b188a Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/10592.png differ diff --git a/plugins/masters-tournament/assets/masters/players/308.png b/plugins/masters-tournament/assets/masters/players/308.png new file mode 100644 index 0000000..980f3c2 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/308.png differ diff --git a/plugins/masters-tournament/assets/masters/players/3448.png b/plugins/masters-tournament/assets/masters/players/3448.png new file mode 100644 index 0000000..4796cb2 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/3448.png differ diff --git a/plugins/masters-tournament/assets/masters/players/3470.png b/plugins/masters-tournament/assets/masters/players/3470.png new file mode 100644 index 0000000..15c0e64 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/3470.png differ diff --git a/plugins/masters-tournament/assets/masters/players/367.png b/plugins/masters-tournament/assets/masters/players/367.png new file mode 100644 index 0000000..028794b Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/367.png differ diff --git a/plugins/masters-tournament/assets/masters/players/3702.png b/plugins/masters-tournament/assets/masters/players/3702.png new file mode 100644 index 0000000..99ef673 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/3702.png differ diff --git a/plugins/masters-tournament/assets/masters/players/462.png b/plugins/masters-tournament/assets/masters/players/462.png new file mode 100644 index 0000000..ea01594 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/462.png differ diff --git a/plugins/masters-tournament/assets/masters/players/4686082.png b/plugins/masters-tournament/assets/masters/players/4686082.png new file mode 100644 index 0000000..bed3478 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/4686082.png differ diff --git a/plugins/masters-tournament/assets/masters/players/4686084.png b/plugins/masters-tournament/assets/masters/players/4686084.png new file mode 100644 index 0000000..8cb5502 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/4686084.png differ diff --git a/plugins/masters-tournament/assets/masters/players/5548.png b/plugins/masters-tournament/assets/masters/players/5548.png new file mode 100644 index 0000000..ad99338 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/5548.png differ diff --git a/plugins/masters-tournament/assets/masters/players/5765.png b/plugins/masters-tournament/assets/masters/players/5765.png new file mode 100644 index 0000000..65750a7 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/5765.png differ diff --git a/plugins/masters-tournament/assets/masters/players/5860.png b/plugins/masters-tournament/assets/masters/players/5860.png new file mode 100644 index 0000000..5bd529f Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/5860.png differ diff --git a/plugins/masters-tournament/assets/masters/players/6798.png b/plugins/masters-tournament/assets/masters/players/6798.png new file mode 100644 index 0000000..e904b0e Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/6798.png differ diff --git a/plugins/masters-tournament/assets/masters/players/780.png b/plugins/masters-tournament/assets/masters/players/780.png new file mode 100644 index 0000000..656298c Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/780.png differ diff --git a/plugins/masters-tournament/assets/masters/players/9037.png b/plugins/masters-tournament/assets/masters/players/9037.png new file mode 100644 index 0000000..67e0bab Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/9037.png differ diff --git a/plugins/masters-tournament/assets/masters/players/9131.png b/plugins/masters-tournament/assets/masters/players/9131.png new file mode 100644 index 0000000..83ec4a7 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/9131.png differ diff --git a/plugins/masters-tournament/assets/masters/players/9478.png b/plugins/masters-tournament/assets/masters/players/9478.png new file mode 100644 index 0000000..4ce0731 Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/9478.png differ diff --git a/plugins/masters-tournament/assets/masters/players/9780.png b/plugins/masters-tournament/assets/masters/players/9780.png new file mode 100644 index 0000000..117fc1d Binary files /dev/null and b/plugins/masters-tournament/assets/masters/players/9780.png differ diff --git a/plugins/masters-tournament/config_schema.json b/plugins/masters-tournament/config_schema.json new file mode 100644 index 0000000..03cb78b --- /dev/null +++ b/plugins/masters-tournament/config_schema.json @@ -0,0 +1,402 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Masters Tournament Plugin Configuration", + "description": "Configuration schema for the Masters Tournament plugin - displays live tournament data, course imagery, and Masters branding year-round", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable the Masters Tournament plugin" + }, + "display_duration": { + "type": "number", + "default": 20, + "minimum": 5, + "maximum": 300, + "description": "Duration in seconds to display each mode before rotating" + }, + "update_interval": { + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 3600, + "description": "How often to fetch new data in seconds (30s during tournament, 3600s off-season)" + }, + "mock_data": { + "type": "boolean", + "default": false, + "description": "Use mock tournament data for testing (useful when Masters isn't live)" + }, + "favorite_players": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of favorite player names (e.g., ['Scottie Scheffler', 'Rory McIlroy'])" + }, + "display_modes": { + "type": "object", + "title": "Display Modes Configuration", + "description": "Control which display modes are enabled and their settings", + "properties": { + "leaderboard": { + "type": "object", + "title": "Leaderboard Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show live leaderboard" + }, + "top_n": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 50, + "description": "Number of players to show on leaderboard" + }, + "show_favorites_always": { + "type": "boolean", + "default": true, + "description": "Always include favorite players even if outside top N" + }, + "duration": { + "type": "number", + "default": 25, + "minimum": 5, + "maximum": 120, + "description": "Display duration for leaderboard (seconds)" + } + } + }, + "player_cards": { + "type": "object", + "title": "Player Card Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show individual player spotlight cards" + }, + "show_headshots": { + "type": "boolean", + "default": true, + "description": "Display player headshot photos" + }, + "duration_per_player": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Time to show each player card (seconds)" + } + } + }, + "course_tour": { + "type": "object", + "title": "Course Tour Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show rotating hole maps with course imagery" + }, + "show_animations": { + "type": "boolean", + "default": true, + "description": "Enable transitions and animations" + }, + "duration_per_hole": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Time to show each hole (seconds)" + }, + "featured_holes": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 18 + }, + "default": [12, 13, 16], + "description": "Featured holes to highlight (Amen Corner, par 3s)" + } + } + }, + "hole_by_hole": { + "type": "object", + "title": "Hole-by-Hole Scoring", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show hole-by-hole scores for favorite players" + }, + "duration": { + "type": "number", + "default": 20, + "minimum": 5, + "maximum": 120, + "description": "Display duration (seconds)" + } + } + }, + "live_action": { + "type": "object", + "title": "Live Action Notifications", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show real-time birdie/eagle notifications" + }, + "duration": { + "type": "number", + "default": 10, + "minimum": 3, + "maximum": 30, + "description": "Notification display duration (seconds)" + } + } + }, + "amen_corner": { + "type": "object", + "title": "Amen Corner Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Dedicated display for holes 11-13 (Amen Corner)" + }, + "duration": { + "type": "number", + "default": 20, + "minimum": 5, + "maximum": 120, + "description": "Display duration (seconds)" + } + } + }, + "featured_holes": { + "type": "object", + "title": "Featured Holes Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show scoring on signature holes (12, 16)" + }, + "duration": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 120, + "description": "Display duration (seconds)" + } + } + }, + "schedule": { + "type": "object", + "title": "Schedule Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show tee times and pairings" + }, + "duration": { + "type": "number", + "default": 20, + "minimum": 5, + "maximum": 120, + "description": "Display duration (seconds)" + } + } + }, + "past_champions": { + "type": "object", + "title": "Past Champions Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show historical Masters winners" + }, + "duration": { + "type": "number", + "default": 20, + "minimum": 5, + "maximum": 120, + "description": "Display duration (seconds)" + } + } + }, + "tournament_stats": { + "type": "object", + "title": "Tournament Statistics", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show tournament records and statistics" + } + } + }, + "fun_facts": { + "type": "object", + "title": "Fun Facts Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show Masters and Augusta National fun facts" + } + } + }, + "countdown": { + "type": "object", + "title": "Countdown Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show countdown to next Masters Tournament" + } + } + }, + "field_overview": { + "type": "object", + "title": "Field Overview Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show field breakdown (under/over/even par counts)" + } + } + }, + "course_overview": { + "type": "object", + "title": "Course Overview Display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show Augusta National front nine / back nine overview" + } + } + } + } + }, + "notifications": { + "type": "object", + "title": "Notification Settings", + "description": "Configure when to interrupt normal display rotation", + "properties": { + "practice_round_alerts": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Alert when practice rounds start" + }, + "interrupt_display": { + "type": "boolean", + "default": true, + "description": "Interrupt current mode to show alert" + }, + "duration": { + "type": "number", + "default": 15, + "minimum": 3, + "maximum": 60, + "description": "Alert display duration (seconds)" + } + } + }, + "favorite_player_alerts": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Alert on favorite player eagles/birdies" + }, + "interrupt_display": { + "type": "boolean", + "default": true, + "description": "Interrupt current mode to show alert" + }, + "duration": { + "type": "number", + "default": 10, + "minimum": 3, + "maximum": 60, + "description": "Alert display duration (seconds)" + } + } + }, + "tournament_start_alert": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Alert when tournament begins" + }, + "interrupt_display": { + "type": "boolean", + "default": false, + "description": "Interrupt current mode to show alert" + } + } + } + } + }, + "branding": { + "type": "object", + "title": "Masters Branding Settings", + "description": "Control Masters visual branding elements", + "properties": { + "show_masters_logo": { + "type": "boolean", + "default": true, + "description": "Display Masters logo on screens" + }, + "show_green_jacket": { + "type": "boolean", + "default": true, + "description": "Show green jacket icon for leaders" + }, + "show_azaleas": { + "type": "boolean", + "default": true, + "description": "Display azalea flower accents" + }, + "color_scheme": { + "type": "string", + "enum": ["classic", "bright", "subdued"], + "default": "classic", + "description": "Masters color palette variant" + } + } + } + }, + "additionalProperties": false, + "required": ["enabled"], + "x-propertyOrder": [ + "enabled", + "display_duration", + "update_interval", + "mock_data", + "favorite_players", + "display_modes", + "notifications", + "branding" + ] +} diff --git a/plugins/masters-tournament/download_assets.py b/plugins/masters-tournament/download_assets.py new file mode 100644 index 0000000..31938e0 --- /dev/null +++ b/plugins/masters-tournament/download_assets.py @@ -0,0 +1,907 @@ +#!/usr/bin/env python3 +""" +Masters Tournament Asset Downloader + +Downloads REAL assets: +- Player headshots from ESPN CDN +- Creates accurate Augusta National hole layouts based on real course topology +- Creates pixel-perfect Masters branding for LED matrix displays +- Creates high-quality icons and backgrounds +""" + +import math +import os +import random +import sys +from io import BytesIO +from pathlib import Path + +import requests +from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont + +# Asset directories +PLUGIN_DIR = Path(__file__).parent +ASSETS_DIR = PLUGIN_DIR / "assets" / "masters" +LOGOS_DIR = ASSETS_DIR / "logos" +COURSES_DIR = ASSETS_DIR / "courses" +ICONS_DIR = ASSETS_DIR / "icons" +BACKGROUNDS_DIR = ASSETS_DIR / "backgrounds" +PLAYERS_DIR = ASSETS_DIR / "players" +FLAGS_DIR = ASSETS_DIR / "flags" + +for directory in [LOGOS_DIR, COURSES_DIR, ICONS_DIR, BACKGROUNDS_DIR, PLAYERS_DIR, FLAGS_DIR]: + directory.mkdir(parents=True, exist_ok=True) + + +def get_font(size=16, bold=True): + """Get best available font.""" + paths = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/google-droid-sans-fonts/DroidSans-Bold.ttf", + ] + if not bold: + paths = [p.replace("-Bold", "") for p in paths] + paths + for p in paths: + if os.path.exists(p): + return ImageFont.truetype(p, size) + return ImageFont.load_default() + + +# ═══════════════════════════════════════════════════════════════ +# REAL PLAYER HEADSHOTS FROM ESPN +# ═══════════════════════════════════════════════════════════════ + +ESPN_PLAYERS = { + "Scottie Scheffler": "9478", + "Rory McIlroy": "3470", + "Jon Rahm": "9780", + "Brooks Koepka": "6798", + "Xander Schauffele": "10138", + "Jordan Spieth": "5765", + "Patrick Cantlay": "10134", + "Tiger Woods": "462", + "Phil Mickelson": "308", + "Dustin Johnson": "3702", + "Hideki Matsuyama": "5860", + "Collin Morikawa": "10592", + "Viktor Hovland": "10591", + "Tony Finau": "5548", + "Shane Lowry": "3448", + "Tommy Fleetwood": "9035", + "Adam Scott": "367", + "Bubba Watson": "780", + "Matt Fitzpatrick": "9037", + "Wyndham Clark": "4686082", + "Max Homa": "10140", + "Cameron Smith": "9131", + "Justin Thomas": "4686084", + "Ludvig Aberg": "4686087", + "Sahith Theegala": "4375306", +} + + +def download_player_headshots(): + """Download real player headshots from ESPN CDN.""" + print("\nDownloading real player headshots from ESPN...") + count = 0 + for name, pid in ESPN_PLAYERS.items(): + save_path = PLAYERS_DIR / f"{pid}.png" + if save_path.exists(): + print(f" [cached] {name}") + count += 1 + continue + + url = f"https://a.espncdn.com/combiner/i?img=/i/headshots/golf/players/full/{pid}.png&w=350&h=254" + try: + resp = requests.get(url, timeout=10, headers={ + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + }) + resp.raise_for_status() + img = Image.open(BytesIO(resp.content)).convert("RGBA") + img.save(save_path, "PNG") + print(f" [downloaded] {name} ({img.size[0]}x{img.size[1]})") + count += 1 + except Exception as e: + print(f" [FAILED] {name}: {e}") + + print(f" Total: {count}/{len(ESPN_PLAYERS)} headshots") + + +# ═══════════════════════════════════════════════════════════════ +# MASTERS LOGO - Authentic pixel-art recreation +# ═══════════════════════════════════════════════════════════════ + +def create_masters_logo(): + """Create an authentic-looking Masters Tournament logo. + + The real Masters logo features the text 'MASTERS' in a serif font + with a map of the United States underneath showing Augusta's location. + We recreate this iconic look. + """ + width, height = 256, 128 + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Masters iconic yellow/gold on dark green + masters_green = (0, 104, 56) + masters_yellow = (253, 218, 36) + + # Background + draw.rounded_rectangle([(2, 2), (width - 3, height - 3)], radius=8, fill=masters_green) + + # Gold border + draw.rounded_rectangle([(2, 2), (width - 3, height - 3)], radius=8, outline=masters_yellow, width=3) + + # "THE MASTERS" text - the real logo uses a distinctive serif font + try: + # Try serif fonts first for authenticity + serif_paths = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf", + "/usr/share/fonts/dejavu/DejaVuSerif-Bold.ttf", + "/usr/share/fonts/google-noto-serif-fonts/NotoSerif-Bold.ttf", + ] + title_font = None + for p in serif_paths: + if os.path.exists(p): + title_font = ImageFont.truetype(p, 38) + break + if not title_font: + title_font = get_font(38) + except Exception: + title_font = get_font(38) + + # Shadow + draw.text((width // 2 + 2, 18), "THE MASTERS", font=title_font, + fill=(0, 0, 0, 120), anchor="mt") + # Main text in Masters yellow + draw.text((width // 2, 16), "THE MASTERS", font=title_font, + fill=masters_yellow, anchor="mt") + + # "TOURNAMENT" subtitle + sub_font = get_font(14) + draw.text((width // 2, 60), "T O U R N A M E N T", font=sub_font, + fill=masters_yellow, anchor="mt") + + # Simplified US map outline with Augusta marked + # Draw a simplified outline of the continental US + us_points = [ + (60, 82), (65, 78), (80, 76), (95, 78), (110, 76), + (125, 78), (135, 80), (145, 82), (155, 78), (165, 80), + (175, 84), (180, 88), (185, 92), (188, 98), (182, 102), + (175, 106), (165, 108), (155, 106), (145, 108), (135, 110), + (120, 108), (105, 110), (90, 108), (80, 110), (70, 108), + (60, 105), (55, 98), (58, 90), + ] + draw.polygon(us_points, fill=(0, 85, 45), outline=masters_yellow, width=1) + + # Mark Augusta, GA with a flag pin + augusta_x, augusta_y = 172, 100 + draw.ellipse([augusta_x - 3, augusta_y - 3, augusta_x + 3, augusta_y + 3], + fill=(255, 0, 0)) + draw.line([(augusta_x, augusta_y - 3), (augusta_x, augusta_y - 12)], + fill=(255, 255, 255), width=1) + draw.polygon([(augusta_x, augusta_y - 12), (augusta_x + 6, augusta_y - 10), + (augusta_x, augusta_y - 8)], fill=(255, 0, 0)) + + # "AUGUSTA NATIONAL GOLF CLUB" text at bottom + small_font = get_font(9) + draw.text((width // 2, height - 12), "AUGUSTA NATIONAL GOLF CLUB", font=small_font, + fill=masters_yellow, anchor="mb") + + save_path = LOGOS_DIR / "masters_logo.png" + img.save(save_path) + print(f" [created] masters_logo.png ({width}x{height})") + + +def create_masters_logo_small(): + """Create a small pixel-perfect Masters logo for LED displays.""" + # Multiple sizes for different display resolutions + for size_name, (w, h) in [("sm", (32, 16)), ("md", (48, 24)), ("lg", (64, 32))]: + img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + masters_green = (0, 104, 56) + masters_yellow = (253, 218, 36) + + # Fill background + draw.rectangle([(0, 0), (w - 1, h - 1)], fill=masters_green, outline=masters_yellow) + + # "M" logo for tiny sizes, "MASTERS" for larger + if w <= 32: + # Just draw a stylized "M" in gold + mx = w // 2 + my = h // 2 + draw.text((mx, my), "M", fill=masters_yellow, anchor="mm", + font=get_font(min(h - 4, 14))) + else: + draw.text((w // 2, h // 2), "MASTERS", fill=masters_yellow, anchor="mm", + font=get_font(min(h - 6, 10))) + + save_path = LOGOS_DIR / f"masters_logo_{size_name}.png" + img.save(save_path) + print(f" [created] masters_logo_{size_name}.png ({w}x{h})") + + +# ═══════════════════════════════════════════════════════════════ +# GREEN JACKET ICON +# ═══════════════════════════════════════════════════════════════ + +def create_green_jacket_icon(): + """Create a detailed green jacket icon.""" + size = 64 + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + masters_green = (0, 120, 74) + dark_green = (0, 90, 55) + light_green = (0, 140, 90) + gold = (255, 215, 0) + + # Jacket body with lapels + # Left side + draw.polygon([ + (size * 0.15, size * 0.25), # Left shoulder + (size * 0.20, size * 0.95), # Left bottom + (size * 0.48, size * 0.95), # Center bottom left + (size * 0.42, size * 0.30), # Left lapel inner + ], fill=masters_green, outline=dark_green, width=1) + + # Right side + draw.polygon([ + (size * 0.85, size * 0.25), # Right shoulder + (size * 0.80, size * 0.95), # Right bottom + (size * 0.52, size * 0.95), # Center bottom right + (size * 0.58, size * 0.30), # Right lapel inner + ], fill=masters_green, outline=dark_green, width=1) + + # Collar / lapels (V-shape) + draw.polygon([ + (size * 0.35, size * 0.15), # Left collar top + (size * 0.50, size * 0.40), # V bottom + (size * 0.42, size * 0.30), # Left lapel + ], fill=light_green, outline=dark_green, width=1) + + draw.polygon([ + (size * 0.65, size * 0.15), # Right collar top + (size * 0.50, size * 0.40), # V bottom + (size * 0.58, size * 0.30), # Right lapel + ], fill=light_green, outline=dark_green, width=1) + + # Sleeves + draw.polygon([ + (size * 0.15, size * 0.25), + (size * 0.05, size * 0.55), + (size * 0.15, size * 0.55), + (size * 0.22, size * 0.35), + ], fill=masters_green, outline=dark_green, width=1) + + draw.polygon([ + (size * 0.85, size * 0.25), + (size * 0.95, size * 0.55), + (size * 0.85, size * 0.55), + (size * 0.78, size * 0.35), + ], fill=masters_green, outline=dark_green, width=1) + + # Gold buttons + for y_ratio in [0.45, 0.58, 0.72]: + bx, by = int(size * 0.50), int(size * y_ratio) + r = max(2, int(size * 0.04)) + draw.ellipse([bx - r, by - r, bx + r, by + r], fill=gold, outline=(200, 170, 0)) + + # Augusta National crest on breast pocket (tiny gold circle) + crest_x, crest_y = int(size * 0.62), int(size * 0.42) + cr = max(2, int(size * 0.06)) + draw.ellipse([crest_x - cr, crest_y - cr, crest_x + cr, crest_y + cr], + outline=gold, width=1) + + save_path = LOGOS_DIR / "green_jacket.png" + img.save(save_path) + print(f" [created] green_jacket.png ({size}x{size})") + + +# ═══════════════════════════════════════════════════════════════ +# AZALEA FLOWER +# ═══════════════════════════════════════════════════════════════ + +def create_azalea_flower(): + """Create a beautiful azalea flower icon.""" + size = 64 + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + pink = (255, 105, 180) + light_pink = (255, 182, 213) + dark_pink = (200, 60, 120) + center_yellow = (255, 255, 100) + + center_x, center_y = size // 2, size // 2 + + # 5 petals with realistic petal shape + for i in range(5): + angle = math.radians((360 / 5) * i - 90) + px = center_x + int(12 * math.cos(angle)) + py = center_y + int(12 * math.sin(angle)) + + # Each petal is an elongated ellipse + petal_len = 14 + petal_wid = 10 + + # Draw petal as overlapping circles for organic shape + for step in range(8): + t = step / 7 + sx = int(center_x + (px - center_x) * (0.3 + t * 0.7) + petal_len * t * math.cos(angle)) + sy = int(center_y + (py - center_y) * (0.3 + t * 0.7) + petal_len * t * math.sin(angle)) + r = int(petal_wid * (1 - abs(t - 0.5) * 1.2)) + if r > 0: + color = light_pink if t > 0.5 else pink + draw.ellipse([sx - r, sy - r, sx + r, sy + r], fill=color) + + # Petal outlines + for i in range(5): + angle = math.radians((360 / 5) * i - 90) + px = center_x + int(22 * math.cos(angle)) + py = center_y + int(22 * math.sin(angle)) + draw.ellipse([px - 8, py - 8, px + 8, py + 8], outline=dark_pink, width=1) + + # Flower center + draw.ellipse([center_x - 6, center_y - 6, center_x + 6, center_y + 6], + fill=center_yellow, outline=(220, 180, 50)) + + # Stamens + for i in range(6): + angle = math.radians(60 * i) + sx = center_x + int(4 * math.cos(angle)) + sy = center_y + int(4 * math.sin(angle)) + draw.ellipse([sx - 1, sy - 1, sx + 1, sy + 1], fill=(180, 120, 0)) + + save_path = LOGOS_DIR / "azalea.png" + img.save(save_path) + print(f" [created] azalea.png ({size}x{size})") + + +# ═══════════════════════════════════════════════════════════════ +# GOLF ICONS +# ═══════════════════════════════════════════════════════════════ + +def create_golf_icons(): + """Create clean golf icons for LED display.""" + size = 48 + + # Golf Ball with realistic dimples + ball_img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(ball_img) + center = size // 2 + r = int(size * 0.4) + + # Shadow + draw.ellipse([center - r + 3, center - r + 3, center + r + 3, center + r + 3], + fill=(0, 0, 0, 60)) + # Ball body + draw.ellipse([center - r, center - r, center + r, center + r], + fill=(255, 255, 255), outline=(200, 200, 200), width=2) + # Highlight + draw.ellipse([center - r + 4, center - r + 3, center - r + 10, center - r + 8], + fill=(255, 255, 255, 200)) + # Dimples + random.seed(42) + for _ in range(20): + dx = random.randint(center - r + 5, center + r - 5) + dy = random.randint(center - r + 5, center + r - 5) + if (dx - center) ** 2 + (dy - center) ** 2 < (r - 4) ** 2: + draw.ellipse([dx - 1, dy - 1, dx + 1, dy + 1], fill=(230, 230, 230)) + + ball_img.save(ICONS_DIR / "golf_ball.png") + print(f" [created] golf_ball.png") + + # Masters Flag (yellow flag, not red - Masters uses yellow) + flag_img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(flag_img) + pole_x = size // 3 + draw.line([(pole_x, 4), (pole_x, size - 4)], fill=(180, 180, 180), width=2) + # Yellow flag (Masters signature) + draw.polygon([(pole_x, 4), (pole_x + 20, 10), (pole_x, 16)], + fill=(253, 218, 36), outline=(200, 170, 0), width=1) + # Hole + draw.ellipse([pole_x - 5, size - 6, pole_x + 5, size - 2], fill=(40, 40, 40)) + + flag_img.save(ICONS_DIR / "golf_flag.png") + print(f" [created] golf_flag.png") + + # Golf Tee + tee_img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(tee_img) + cx = size // 2 + draw.polygon([(cx - 3, 14), (cx - 7, size - 6), (cx + 7, size - 6), (cx + 3, 14)], + fill=(210, 180, 140), outline=(150, 120, 80), width=1) + draw.ellipse([cx - 7, 10, cx + 7, 18], fill=(210, 180, 140), outline=(150, 120, 80)) + # Ball on tee + draw.ellipse([cx - 5, 2, cx + 5, 12], fill=(255, 255, 255), outline=(200, 200, 200)) + + tee_img.save(ICONS_DIR / "golf_tee.png") + print(f" [created] golf_tee.png") + + +# ═══════════════════════════════════════════════════════════════ +# ACCURATE AUGUSTA NATIONAL HOLE LAYOUTS +# Based on real Augusta National course topology +# ═══════════════════════════════════════════════════════════════ + +# Each hole defined with waypoints, hazards, and green shape +# Coordinates are relative (0-1 range), mapped to image dimensions +# Fairway waypoints go from tee to green + +AUGUSTA_HOLE_LAYOUTS = { + 1: { # Tea Olive - Par 4, 445y - Slight dogleg right, uphill + "tee": (0.50, 0.90), + "fairway": [(0.50, 0.90), (0.52, 0.70), (0.55, 0.50), (0.58, 0.30)], + "green": (0.58, 0.18), "green_shape": "oval", + "bunkers": [(0.50, 0.22), (0.66, 0.20)], + "trees_left": True, "trees_right": True, + "elevation": "uphill", + }, + 2: { # Pink Dogwood - Par 5, 575y - Downhill, then uphill to green + "tee": (0.25, 0.90), + "fairway": [(0.25, 0.90), (0.30, 0.72), (0.40, 0.55), (0.55, 0.40), (0.65, 0.25)], + "green": (0.65, 0.15), "green_shape": "kidney", + "bunkers": [(0.55, 0.18), (0.72, 0.22)], + "trees_left": True, "trees_right": True, + }, + 3: { # Flowering Peach - Par 4, 350y - Short, uphill, tricky green + "tee": (0.40, 0.88), + "fairway": [(0.40, 0.88), (0.42, 0.65), (0.45, 0.42)], + "green": (0.45, 0.25), "green_shape": "round", + "bunkers": [(0.35, 0.28), (0.55, 0.22), (0.38, 0.20), (0.52, 0.30)], + "trees_left": True, + }, + 4: { # Flowering Crab Apple - Par 3, 240y - Long par 3, downhill + "tee": (0.25, 0.82), + "fairway": [], + "green": (0.65, 0.22), "green_shape": "oval_wide", + "bunkers": [(0.55, 0.28), (0.72, 0.18), (0.60, 0.15)], + "trees_left": True, "trees_right": True, + }, + 5: { # Magnolia - Par 4, 495y - Uphill dogleg left + "tee": (0.75, 0.88), + "fairway": [(0.75, 0.88), (0.68, 0.70), (0.55, 0.52), (0.40, 0.35)], + "green": (0.35, 0.18), "green_shape": "oval", + "bunkers": [(0.28, 0.22), (0.42, 0.15)], + "trees_left": True, "trees_right": True, + "elevation": "uphill", + }, + 6: { # Juniper - Par 3, 180y - Dramatically downhill + "tee": (0.35, 0.80), + "fairway": [], + "green": (0.60, 0.25), "green_shape": "round", + "bunkers": [(0.52, 0.30), (0.68, 0.22), (0.55, 0.18)], + "elevation": "steep_downhill", + }, + 7: { # Pampas - Par 4, 450y - Uphill, tree-lined + "tee": (0.45, 0.90), + "fairway": [(0.45, 0.90), (0.48, 0.72), (0.50, 0.52), (0.50, 0.32)], + "green": (0.50, 0.18), "green_shape": "oval", + "bunkers": [(0.40, 0.22), (0.58, 0.16), (0.44, 0.14)], + "trees_left": True, "trees_right": True, + }, + 8: { # Yellow Jasmine - Par 5, 570y - Uphill all the way + "tee": (0.50, 0.92), + "fairway": [(0.50, 0.92), (0.48, 0.75), (0.42, 0.58), (0.38, 0.42), (0.35, 0.28)], + "green": (0.35, 0.15), "green_shape": "kidney", + "bunkers": [(0.28, 0.18), (0.42, 0.12)], + "mounds": True, + "elevation": "uphill", + }, + 9: { # Carolina Cherry - Par 4, 460y - Downhill dogleg left + "tee": (0.70, 0.88), + "fairway": [(0.70, 0.88), (0.62, 0.70), (0.50, 0.52), (0.42, 0.35)], + "green": (0.38, 0.18), "green_shape": "oval", + "bunkers": [(0.30, 0.22), (0.45, 0.15)], + "trees_left": True, + "elevation": "downhill", + }, + 10: { # Camellia - Par 4, 495y - Dramatic downhill dogleg left + "tee": (0.70, 0.88), + "fairway": [(0.70, 0.88), (0.58, 0.68), (0.42, 0.48), (0.30, 0.32)], + "green": (0.25, 0.18), "green_shape": "oval", + "bunkers": [(0.18, 0.22), (0.32, 0.15)], + "trees_left": True, "trees_right": True, + "elevation": "steep_downhill", + }, + 11: { # White Dogwood - Par 4, 520y - Dogleg left, pond left of green + "tee": (0.72, 0.88), + "fairway": [(0.72, 0.88), (0.62, 0.68), (0.48, 0.50), (0.38, 0.35)], + "green": (0.32, 0.18), "green_shape": "oval", + "bunkers": [(0.38, 0.15)], + "water": [(0.18, 0.15, 0.28, 0.28)], # Pond left of green + "trees_left": True, "trees_right": True, + "amen_corner": True, + }, + 12: { # Golden Bell - Par 3, 155y - THE iconic hole. Rae's Creek fronting green + "tee": (0.20, 0.75), + "fairway": [], + "green": (0.60, 0.30), "green_shape": "wide_shallow", + "bunkers": [(0.50, 0.22), (0.70, 0.22), (0.72, 0.35)], + "water": [(0.25, 0.42, 0.80, 0.52)], # Rae's Creek + "trees_right": True, + "amen_corner": True, + "hogan_bridge": True, + }, + 13: { # Azalea - Par 5, 510y - Sharp dogleg left, creek along left/front of green + "tee": (0.82, 0.85), + "fairway": [(0.82, 0.85), (0.72, 0.68), (0.55, 0.52), (0.35, 0.40), (0.25, 0.30)], + "green": (0.20, 0.18), "green_shape": "oval", + "bunkers": [(0.14, 0.22), (0.26, 0.12)], + "water": [(0.08, 0.12, 0.22, 0.32)], # Rae's Creek + "trees_left": True, "trees_right": True, + "amen_corner": True, + "azaleas": True, + }, + 14: { # Chinese Fir - Par 4, 440y - No bunkers! Only hole without them + "tee": (0.50, 0.90), + "fairway": [(0.50, 0.90), (0.48, 0.70), (0.45, 0.50), (0.42, 0.32)], + "green": (0.40, 0.18), "green_shape": "oval", + "bunkers": [], # No bunkers - unique at Augusta + "trees_left": True, "trees_right": True, + }, + 15: { # Firethorn - Par 5, 550y - Pond in front of green + "tee": (0.30, 0.90), + "fairway": [(0.30, 0.90), (0.35, 0.72), (0.45, 0.55), (0.55, 0.40), (0.62, 0.30)], + "green": (0.65, 0.18), "green_shape": "oval", + "bunkers": [(0.58, 0.15), (0.72, 0.20)], + "water": [(0.52, 0.24, 0.72, 0.34)], # Pond + "trees_left": True, + }, + 16: { # Redbud - Par 3, 170y - Over water to green + "tee": (0.20, 0.78), + "fairway": [], + "green": (0.65, 0.28), "green_shape": "kidney", + "bunkers": [(0.72, 0.25), (0.58, 0.35), (0.72, 0.35)], + "water": [(0.30, 0.35, 0.65, 0.55)], # Large pond + "trees_right": True, + }, + 17: { # Nandina - Par 4, 440y - Slight uphill, Eisenhower Tree was here + "tee": (0.50, 0.90), + "fairway": [(0.50, 0.90), (0.48, 0.70), (0.45, 0.50), (0.42, 0.32)], + "green": (0.40, 0.18), "green_shape": "round", + "bunkers": [(0.32, 0.22), (0.48, 0.15)], + "trees_left": True, "trees_right": True, + "elevation": "uphill", + }, + 18: { # Holly - Par 4, 465y - Dramatic uphill finish + "tee": (0.30, 0.88), + "fairway": [(0.30, 0.88), (0.38, 0.70), (0.48, 0.52), (0.55, 0.35)], + "green": (0.58, 0.18), "green_shape": "oval", + "bunkers": [(0.50, 0.22), (0.65, 0.18)], + "trees_left": True, "trees_right": True, + "elevation": "steep_uphill", + }, +} + + +def draw_water(draw, coords, w, h): + """Draw a water hazard.""" + x1, y1, x2, y2 = [int(c * (w if i % 2 == 0 else h)) for i, c in enumerate(coords)] + draw.ellipse([x1, y1, x2, y2], fill=(64, 140, 200, 180), outline=(40, 100, 160)) + + +def draw_bunker(draw, pos, w, h, size=8): + """Draw a sand bunker.""" + x, y = int(pos[0] * w), int(pos[1] * h) + draw.ellipse([x - size, y - size // 2, x + size, y + size // 2], + fill=(238, 214, 175), outline=(200, 180, 140)) + + +def draw_green(draw, pos, w, h, shape="oval"): + """Draw putting green.""" + gx, gy = int(pos[0] * w), int(pos[1] * h) + if shape == "wide_shallow": + rx, ry = 22, 10 + elif shape == "kidney": + rx, ry = 18, 14 + elif shape == "round": + rx, ry = 14, 14 + elif shape == "oval_wide": + rx, ry = 20, 12 + else: + rx, ry = 16, 12 + + # Green with slightly different shade + draw.ellipse([gx - rx, gy - ry, gx + rx, gy + ry], + fill=(80, 200, 80), outline=(60, 160, 60)) + # Fringe + draw.ellipse([gx - rx - 2, gy - ry - 2, gx + rx + 2, gy + ry + 2], + outline=(50, 150, 50)) + + # Flag pin + draw.line([(gx, gy), (gx, gy - 14)], fill=(255, 255, 255), width=1) + draw.polygon([(gx, gy - 14), (gx + 7, gy - 11), (gx, gy - 8)], fill=(255, 0, 0)) + + +def draw_tee_box(draw, pos, w, h): + """Draw tee box.""" + tx, ty = int(pos[0] * w), int(pos[1] * h) + draw.rectangle([tx - 6, ty - 4, tx + 6, ty + 4], fill=(45, 130, 45), outline=(30, 100, 30)) + # Tee markers + draw.ellipse([tx - 4, ty - 1, tx - 2, ty + 1], fill=(255, 255, 255)) + draw.ellipse([tx + 2, ty - 1, tx + 4, ty + 1], fill=(255, 255, 255)) + + +def create_hole_layout(hole_num, layout): + """Create an accurate hole layout image.""" + w, h = 200, 150 + img = Image.new("RGBA", (w, h), (34, 120, 34, 255)) + draw = ImageDraw.Draw(img) + + # Background rough texture + random.seed(hole_num * 17) + for _ in range(200): + rx, ry = random.randint(0, w), random.randint(0, h) + shade = random.randint(-8, 8) + draw.point((rx, ry), fill=(34 + shade, 120 + shade, 34 + shade, 255)) + + # Draw trees on sides + if layout.get("trees_left"): + for i in range(8): + ty = random.randint(10, h - 10) + tx = random.randint(2, 18) + tr = random.randint(4, 8) + draw.ellipse([tx - tr, ty - tr, tx + tr, ty + tr], fill=(20, 80, 20)) + + if layout.get("trees_right"): + for i in range(8): + ty = random.randint(10, h - 10) + tx = random.randint(w - 18, w - 2) + tr = random.randint(4, 8) + draw.ellipse([tx - tr, ty - tr, tx + tr, ty + tr], fill=(20, 80, 20)) + + # Draw azaleas for hole 13 + if layout.get("azaleas"): + for i in range(6): + ax = random.randint(5, 30) + ay = random.randint(int(h * 0.3), int(h * 0.6)) + draw.ellipse([ax - 4, ay - 4, ax + 4, ay + 4], fill=(255, 105, 180)) + + # Draw fairway + fairway_pts = layout.get("fairway", []) + if fairway_pts and len(fairway_pts) >= 2: + fw = 24 # fairway pixel width + for i in range(len(fairway_pts) - 1): + x1, y1 = int(fairway_pts[i][0] * w), int(fairway_pts[i][1] * h) + x2, y2 = int(fairway_pts[i + 1][0] * w), int(fairway_pts[i + 1][1] * h) + draw.line([(x1, y1), (x2, y2)], fill=(60, 170, 60), width=fw) + + # Draw water hazards + for water_coords in layout.get("water", []): + draw_water(draw, water_coords, w, h) + + # Draw Hogan Bridge (hole 12) + if layout.get("hogan_bridge"): + bx = int(0.45 * w) + by = int(0.47 * h) + draw.rectangle([bx - 8, by - 2, bx + 8, by + 2], fill=(139, 119, 101)) + draw.rectangle([bx - 8, by - 3, bx + 8, by - 2], fill=(160, 140, 120)) + + # Draw bunkers + for bpos in layout.get("bunkers", []): + draw_bunker(draw, bpos, w, h) + + # Draw green + draw_green(draw, layout["green"], w, h, layout.get("green_shape", "oval")) + + # Draw tee box + draw_tee_box(draw, layout["tee"], w, h) + + # Amen Corner badge + if layout.get("amen_corner"): + badge_w = 50 + draw.rounded_rectangle([(w - badge_w - 4, h - 16), (w - 4, h - 4)], + radius=3, fill=(0, 0, 0, 160)) + font = get_font(7) + draw.text((w - badge_w // 2 - 4, h - 14), "AMEN CORNER", fill=(253, 218, 36), + font=font, anchor="mt") + + return img + + +def create_course_hole_images(): + """Create all 18 hole layout images.""" + print("\nCreating accurate Augusta National hole layouts...") + + from masters_helpers import AUGUSTA_HOLES + + for hole_num in range(1, 19): + layout = AUGUSTA_HOLE_LAYOUTS[hole_num] + hole_info = AUGUSTA_HOLES[hole_num] + img = create_hole_layout(hole_num, layout) + save_path = COURSES_DIR / f"hole_{hole_num:02d}.png" + img.save(save_path) + print(f" [created] hole_{hole_num:02d}.png - {hole_info['name']} " + f"(Par {hole_info['par']}, {hole_info['yardage']}y)") + + +# ═══════════════════════════════════════════════════════════════ +# COUNTRY FLAGS (small pixel art for LED display) +# ═══════════════════════════════════════════════════════════════ + +FLAG_COLORS = { + "USA": [((0, 0, 100), 0.4), ((200, 0, 0), 0.3), ((255, 255, 255), 0.3)], + "ESP": [((200, 0, 0), 0.25), ((255, 200, 0), 0.50), ((200, 0, 0), 0.25)], + "ENG": [((255, 255, 255), 1.0)], # White with red cross + "AUS": [((0, 0, 128), 1.0)], + "JPN": [((255, 255, 255), 1.0)], # White with red circle + "NIR": [((255, 255, 255), 1.0)], # Simplified + "IRL": [((0, 155, 72), 0.33), ((255, 255, 255), 0.34), ((255, 130, 0), 0.33)], + "NOR": [((200, 16, 32), 1.0)], + "SWE": [((0, 106, 167), 1.0)], + "RSA": [((0, 120, 60), 0.34), ((255, 255, 255), 0.08), ((200, 0, 0), 0.08), + ((255, 255, 255), 0.08), ((0, 0, 128), 0.42)], + "CAN": [((255, 0, 0), 0.25), ((255, 255, 255), 0.50), ((255, 0, 0), 0.25)], + "GER": [((0, 0, 0), 0.33), ((220, 0, 0), 0.34), ((255, 200, 0), 0.33)], + "ARG": [((108, 180, 230), 0.33), ((255, 255, 255), 0.34), ((108, 180, 230), 0.33)], + "SCO": [((0, 0, 128), 1.0)], + "WAL": [((255, 255, 255), 0.5), ((0, 128, 0), 0.5)], + "FIJ": [((0, 0, 128), 1.0)], +} + + +def create_country_flags(): + """Create tiny country flag images for player cards.""" + print("\nCreating country flag icons...") + fw, fh = 16, 10 + + for country, stripes in FLAG_COLORS.items(): + img = Image.new("RGBA", (fw, fh), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Draw horizontal stripes + y = 0 + for color, ratio in stripes: + stripe_h = max(1, int(fh * ratio)) + draw.rectangle([(0, y), (fw - 1, y + stripe_h - 1)], fill=color) + y += stripe_h + + # Special overlays + if country == "ENG": + # Red cross on white + draw.line([(fw // 2, 0), (fw // 2, fh)], fill=(200, 0, 0), width=2) + draw.line([(0, fh // 2), (fw, fh // 2)], fill=(200, 0, 0), width=2) + elif country == "JPN": + # Red circle on white + cx, cy = fw // 2, fh // 2 + r = min(fw, fh) // 3 + draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(200, 0, 0)) + elif country == "NOR": + # Blue cross with white border on red + draw.line([(fw // 3, 0), (fw // 3, fh)], fill=(255, 255, 255), width=3) + draw.line([(0, fh // 2), (fw, fh // 2)], fill=(255, 255, 255), width=3) + draw.line([(fw // 3, 0), (fw // 3, fh)], fill=(0, 32, 91), width=1) + draw.line([(0, fh // 2), (fw, fh // 2)], fill=(0, 32, 91), width=1) + elif country == "SWE": + # Yellow cross on blue + draw.line([(fw // 3, 0), (fw // 3, fh)], fill=(254, 204, 2), width=2) + draw.line([(0, fh // 2), (fw, fh // 2)], fill=(254, 204, 2), width=2) + elif country == "SCO": + # White X on blue + draw.line([(0, 0), (fw, fh)], fill=(255, 255, 255), width=1) + draw.line([(fw, 0), (0, fh)], fill=(255, 255, 255), width=1) + elif country == "AUS": + # Union Jack canton + stars (simplified) + draw.rectangle([(0, 0), (fw // 2, fh // 2)], fill=(0, 0, 128)) + draw.line([(0, 0), (fw // 2, fh // 2)], fill=(255, 0, 0), width=1) + draw.line([(fw // 2, 0), (0, fh // 2)], fill=(255, 0, 0), width=1) + # Southern cross (simplified) + for sx, sy in [(fw * 3 // 4, fh // 4), (fw * 3 // 4, fh * 3 // 4)]: + draw.point((sx, sy), fill=(255, 255, 255)) + + # Border + draw.rectangle([(0, 0), (fw - 1, fh - 1)], outline=(80, 80, 80)) + + img.save(FLAGS_DIR / f"{country}.png") + + print(f" [created] {len(FLAG_COLORS)} country flag icons") + + +# ═══════════════════════════════════════════════════════════════ +# BACKGROUND TEXTURES +# ═══════════════════════════════════════════════════════════════ + +def create_background_textures(): + """Create background textures for displays.""" + print("\nCreating background textures...") + + # Masters green gradient + for res_name, (w, h) in [("64x32", (64, 32)), ("128x64", (128, 64))]: + img = Image.new("RGB", (w, h)) + draw = ImageDraw.Draw(img) + c1 = (0, 70, 40) + c2 = (0, 110, 65) + for y in range(h): + ratio = y / h + r = int(c1[0] + (c2[0] - c1[0]) * ratio) + g = int(c1[1] + (c2[1] - c1[1]) * ratio) + b = int(c1[2] + (c2[2] - c1[2]) * ratio) + draw.line([(0, y), (w, y)], fill=(r, g, b)) + img.save(BACKGROUNDS_DIR / f"masters_green_gradient_{res_name}.png") + print(f" [created] masters_green_gradient_{res_name}.png") + + # Also save a default one + img = Image.new("RGB", (128, 64)) + draw = ImageDraw.Draw(img) + for y in range(64): + ratio = y / 64 + g_val = int(70 + 40 * ratio) + draw.line([(0, y), (127, y)], fill=(0, g_val, int(40 + 25 * ratio))) + img.save(BACKGROUNDS_DIR / "masters_green_gradient.png") + + # Augusta texture + img2 = Image.new("RGB", (128, 64), (34, 120, 34)) + draw2 = ImageDraw.Draw(img2) + random.seed(42) + for _ in range(200): + x, y = random.randint(0, 127), random.randint(0, 63) + s = random.randint(-8, 8) + draw2.point((x, y), fill=(max(0, 34 + s), max(0, 120 + s), max(0, 34 + s))) + img2.save(BACKGROUNDS_DIR / "augusta_green_texture.png") + print(f" [created] augusta_green_texture.png") + + +# ═══════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════ + +def main(): + """Download and create all assets.""" + print("=" * 60) + print(" Masters Tournament Asset Downloader v2.0") + print(" Real headshots, accurate course layouts, authentic branding") + print("=" * 60) + + print("\n[1/7] Creating Masters logo...") + create_masters_logo() + create_masters_logo_small() + + print("\n[2/7] Creating green jacket icon...") + create_green_jacket_icon() + + print("\n[3/7] Creating azalea flower icon...") + create_azalea_flower() + + print("\n[4/7] Creating golf icons...") + create_golf_icons() + + print("\n[5/7] Creating accurate course hole layouts...") + create_course_hole_images() + + print("\n[6/7] Creating country flags...") + create_country_flags() + + print("\n[7/7] Creating background textures...") + create_background_textures() + + # Optional: download real headshots + try: + print("\n[BONUS] Downloading real ESPN player headshots...") + download_player_headshots() + except Exception as e: + print(f" [skip] Headshot download failed (network?): {e}") + + # Summary + print("\n" + "=" * 60) + counts = { + "Logos": len(list(LOGOS_DIR.glob("*.png"))), + "Course Holes": len(list(COURSES_DIR.glob("*.png"))), + "Icons": len(list(ICONS_DIR.glob("*.png"))), + "Backgrounds": len(list(BACKGROUNDS_DIR.glob("*.png"))), + "Country Flags": len(list(FLAGS_DIR.glob("*.png"))), + "Player Headshots": len(list(PLAYERS_DIR.glob("*.png"))), + } + total = sum(counts.values()) + print(" Asset Summary:") + for label, count in counts.items(): + print(f" {label}: {count} files") + print(f" TOTAL: {total} files") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/plugins/masters-tournament/logo_loader.py b/plugins/masters-tournament/logo_loader.py new file mode 100644 index 0000000..3fd66c2 --- /dev/null +++ b/plugins/masters-tournament/logo_loader.py @@ -0,0 +1,314 @@ +""" +Masters Logo & Asset Loader + +Handles loading, caching, and resizing of all Masters Tournament assets: +- Masters logo (multiple sizes for different displays) +- Green jacket icon +- Azalea flower accents +- Hole maps for all 18 Augusta National holes +- Player headshots (downloaded from ESPN CDN) +- Country flags for player cards +""" + +import logging +import os +from io import BytesIO +from pathlib import Path +from typing import Dict, Optional, Tuple + +import requests +from PIL import Image, ImageDraw, ImageFont + +from masters_helpers import ESPN_HEADSHOT_URL, ESPN_PLAYER_IDS, get_espn_headshot_url + +logger = logging.getLogger(__name__) + + +class MastersLogoLoader: + """Loads, caches, and resizes Masters Tournament assets.""" + + def __init__(self, plugin_dir: str = None): + if plugin_dir is None: + plugin_dir = os.path.dirname(os.path.abspath(__file__)) + + self.plugin_dir = Path(plugin_dir) + self.masters_dir = self.plugin_dir / "assets" / "masters" + self.logos_dir = self.masters_dir / "logos" + self.courses_dir = self.masters_dir / "courses" + self.players_dir = self.masters_dir / "players" + self.flags_dir = self.masters_dir / "flags" + self.icons_dir = self.masters_dir / "icons" + self.backgrounds_dir = self.masters_dir / "backgrounds" + + for directory in [self.logos_dir, self.courses_dir, self.players_dir, + self.flags_dir, self.icons_dir, self.backgrounds_dir]: + directory.mkdir(parents=True, exist_ok=True) + + self._cache: Dict[str, Image.Image] = {} + + def get_masters_logo(self, max_width: int = 20, max_height: int = 12) -> Optional[Image.Image]: + """Get the Masters logo, choosing the best size variant.""" + cache_key = f"masters_logo_{max_width}x{max_height}" + if cache_key in self._cache: + return self._cache[cache_key] + + # Try size-specific logos first + if max_width <= 32: + candidates = ["masters_logo_sm.png", "masters_logo.png"] + elif max_width <= 48: + candidates = ["masters_logo_md.png", "masters_logo_sm.png", "masters_logo.png"] + else: + candidates = ["masters_logo_lg.png", "masters_logo.png"] + + for filename in candidates: + logo_path = self.logos_dir / filename + if logo_path.exists(): + try: + img = Image.open(logo_path).convert("RGBA") + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._cache[cache_key] = img + return img + except Exception as e: + logger.warning(f"Failed to load {filename}: {e}") + + # Text placeholder fallback + placeholder = self._create_text_placeholder("M", max_width, max_height, (0, 104, 56)) + self._cache[cache_key] = placeholder + return placeholder + + def get_green_jacket_icon(self, size: int = 16) -> Optional[Image.Image]: + """Get the green jacket icon.""" + cache_key = f"green_jacket_{size}" + if cache_key in self._cache: + return self._cache[cache_key] + + icon_path = self.logos_dir / "green_jacket.png" + if icon_path.exists(): + try: + img = Image.open(icon_path).convert("RGBA") + img.thumbnail((size, size), Image.Resampling.LANCZOS) + self._cache[cache_key] = img + return img + except Exception as e: + logger.warning(f"Failed to load green jacket: {e}") + + # Minimal placeholder + placeholder = Image.new("RGBA", (size, size), (0, 120, 74, 255)) + self._cache[cache_key] = placeholder + return placeholder + + def get_azalea_icon(self, size: int = 16) -> Optional[Image.Image]: + """Get the azalea flower accent icon.""" + cache_key = f"azalea_{size}" + if cache_key in self._cache: + return self._cache[cache_key] + + icon_path = self.logos_dir / "azalea.png" + if icon_path.exists(): + try: + img = Image.open(icon_path).convert("RGBA") + img.thumbnail((size, size), Image.Resampling.LANCZOS) + self._cache[cache_key] = img + return img + except Exception as e: + logger.warning(f"Failed to load azalea: {e}") + + # Pink circle fallback + placeholder = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(placeholder) + r = size // 3 + c = size // 2 + draw.ellipse([c - r, c - r, c + r, c + r], fill=(255, 105, 180, 255)) + self._cache[cache_key] = placeholder + return placeholder + + def get_hole_image(self, hole_number: int, max_width: int = 40, max_height: int = 28) -> Optional[Image.Image]: + """Get a hole map image for Augusta National.""" + if not 1 <= hole_number <= 18: + return None + + cache_key = f"hole_{hole_number}_{max_width}x{max_height}" + if cache_key in self._cache: + return self._cache[cache_key] + + hole_path = self.courses_dir / f"hole_{hole_number:02d}.png" + if hole_path.exists(): + try: + img = Image.open(hole_path).convert("RGBA") + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._cache[cache_key] = img + return img + except Exception as e: + logger.warning(f"Failed to load hole {hole_number}: {e}") + + placeholder = self._create_hole_placeholder(hole_number, max_width, max_height) + self._cache[cache_key] = placeholder + return placeholder + + def _crop_to_fill(self, img: Image.Image, size: int) -> Image.Image: + """Crop and resize image to exactly fill a square, centering on the face area.""" + w, h = img.size + # Crop to square from the top-center (faces are usually top-center in headshots) + if w > h: + left = (w - h) // 2 + img = img.crop((left, 0, left + h, h)) + elif h > w: + # Keep top portion (face is at top in ESPN headshots) + img = img.crop((0, 0, w, w)) + return img.resize((size, size), Image.Resampling.LANCZOS) + + def get_player_headshot(self, player_id: str, url: Optional[str], max_size: int = 24) -> Optional[Image.Image]: + """Get player headshot, crop-to-fill so it fills the display box.""" + if not player_id: + return None + + cache_key = f"player_{player_id}_{max_size}" + if cache_key in self._cache: + return self._cache[cache_key] + + # Check disk cache + player_path = self.players_dir / f"{player_id}.png" + if player_path.exists(): + try: + img = Image.open(player_path).convert("RGBA") + img = self._crop_to_fill(img, max_size) + self._cache[cache_key] = img + return img + except Exception as e: + logger.warning(f"Failed to load cached headshot {player_id}: {e}") + + # Download from URL + if url: + try: + response = requests.get(url, timeout=5, headers={ + "User-Agent": "LEDMatrix Masters Plugin/2.0" + }) + response.raise_for_status() + + img = Image.open(BytesIO(response.content)).convert("RGBA") + img.save(player_path, "PNG") + + img = self._crop_to_fill(img, max_size) + self._cache[cache_key] = img + return img + except Exception as e: + logger.debug(f"Failed to download headshot for {player_id}: {e}") + + return None + + def get_country_flag(self, country_code: str, width: int = 16, height: int = 10) -> Optional[Image.Image]: + """Get a country flag image.""" + if not country_code: + return None + + cache_key = f"flag_{country_code}_{width}x{height}" + if cache_key in self._cache: + return self._cache[cache_key] + + flag_path = self.flags_dir / f"{country_code}.png" + if flag_path.exists(): + try: + img = Image.open(flag_path).convert("RGBA") + img = img.resize((width, height), Image.Resampling.NEAREST) + self._cache[cache_key] = img + return img + except Exception as e: + logger.warning(f"Failed to load flag {country_code}: {e}") + + return None + + def get_icon(self, icon_name: str, size: int = 16) -> Optional[Image.Image]: + """Load an icon from the icons directory.""" + cache_key = f"icon_{icon_name}_{size}" + if cache_key in self._cache: + return self._cache[cache_key] + + icon_path = self.icons_dir / icon_name + if icon_path.exists(): + try: + img = Image.open(icon_path).convert("RGBA") + img.thumbnail((size, size), Image.Resampling.LANCZOS) + self._cache[cache_key] = img + return img + except Exception as e: + logger.warning(f"Failed to load icon {icon_name}: {e}") + + return None + + def _create_text_placeholder(self, text: str, width: int, height: int, + color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image: + """Create a simple text-based placeholder.""" + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + font = self._get_small_font() + + if len(text) > width // 4: + text = text[:width // 4] + + bbox = draw.textbbox((0, 0), text, font=font) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + + x = (width - tw) // 2 + y = (height - th) // 2 + + # Outline for visibility + for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=(0, 0, 0, 255)) + draw.text((x, y), text, font=font, fill=color + (255,)) + + return img + + def _create_hole_placeholder(self, hole_number: int, width: int, height: int) -> Image.Image: + """Create a placeholder hole map.""" + img = Image.new("RGBA", (width, height), (34, 120, 34, 255)) + draw = ImageDraw.Draw(img) + + # Simple fairway line + draw.line( + [(width // 3, height - 5), (width * 2 // 3, 5)], + fill=(60, 170, 60, 255), + width=max(3, width // 8), + ) + + # Green circle + gx, gy = width * 2 // 3, 8 + draw.ellipse([gx - 6, gy - 4, gx + 6, gy + 4], fill=(80, 200, 80, 255)) + + # Flag + draw.line([(gx, gy), (gx, gy - 8)], fill=(255, 255, 255, 255), width=1) + draw.polygon([(gx, gy - 8), (gx + 4, gy - 6), (gx, gy - 4)], fill=(255, 0, 0, 255)) + + # Hole number + font = self._get_small_font() + text = f"#{hole_number}" + draw.text((2, height - 8), text, font=font, fill=(255, 255, 255, 255)) + + return img + + def _get_small_font(self) -> ImageFont.ImageFont: + """Get a small font for placeholders.""" + font_paths = [ + "assets/fonts/4x6-font.ttf", + str(Path.home() / "Github" / "LEDMatrix" / "assets" / "fonts" / "4x6-font.ttf"), + ] + for p in font_paths: + if os.path.exists(p): + try: + return ImageFont.truetype(p, 6) + except Exception: + pass + return ImageFont.load_default() + + def preload_all_holes(self, max_width: int = 40, max_height: int = 28): + """Preload all 18 hole images into cache.""" + count = 0 + for hole_num in range(1, 19): + if self.get_hole_image(hole_num, max_width, max_height): + count += 1 + logger.info(f"Preloaded {count} hole images") + + def clear_cache(self): + """Clear the in-memory image cache.""" + self._cache.clear() diff --git a/plugins/masters-tournament/manager.py b/plugins/masters-tournament/manager.py new file mode 100644 index 0000000..f4b8f72 --- /dev/null +++ b/plugins/masters-tournament/manager.py @@ -0,0 +1,535 @@ +""" +Masters Tournament Plugin + +Main plugin class for the Masters Tournament LED display. +Displays live leaderboards, player cards, course imagery, hole maps, +fun facts, past champions, and Augusta National branding year-round. +""" + +import logging +import time +from datetime import datetime +from typing import Any, Dict, List, Optional + +from PIL import Image + +from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode + +from masters_data import MastersDataSource +from masters_renderer import MastersRenderer +from masters_renderer_enhanced import MastersRendererEnhanced +from logo_loader import MastersLogoLoader +from masters_helpers import ( + calculate_tournament_countdown, + filter_favorite_players, + get_detailed_phase, + get_tournament_phase, + sort_leaderboard, +) + +logger = logging.getLogger(__name__) + + +class MastersTournamentPlugin(BasePlugin): + """ + Masters Tournament Plugin. + + Displays Masters Tournament leaderboards, player cards, course imagery, + hole maps, fun facts, and historical data year-round with authentic + Augusta National branding. + """ + + def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): + super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) + + # Display dimensions + if hasattr(display_manager, "matrix") and display_manager.matrix: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 64) + self.display_height = getattr(display_manager, "height", 32) + + # Display duration + self.display_duration = config.get("display_duration", 20) + + # Initialize components + self.logo_loader = MastersLogoLoader(self.plugin_dir) + self.data_source = MastersDataSource(cache_manager, config) + + # Use enhanced renderer for 64x32+, base for tiny displays + if self.display_width >= 64: + self.renderer = MastersRendererEnhanced( + self.display_width, + self.display_height, + config, + self.logo_loader, + self.logger, + ) + else: + self.renderer = MastersRenderer( + self.display_width, + self.display_height, + config, + self.logo_loader, + self.logger, + ) + + # Data state + self._leaderboard_data: List[Dict] = [] + self._player_data: Dict[str, Dict] = {} + self._schedule_data: List[Dict] = [] + self._last_update = 0 + self._update_interval = config.get("update_interval", 30) + + # Tournament phase + self._tournament_phase = get_tournament_phase() + self._detailed_phase = get_detailed_phase() + + # Build enabled modes (phase-aware) + self.modes = self._build_enabled_modes() + + # Current mode tracking + self.current_mode_index = 0 + self._current_display_mode: Optional[str] = None + + # Course tour state (separate cursors so modes don't interfere) + self._current_hole = 1 + self._featured_hole_index = 0 + + # Pagination state for each mode (auto-advances each display cycle) + self._page = { + "leaderboard": 0, + "champions": 0, + "stats": 0, + "schedule": 0, + "course_overview": 0, + } + + # Fun fact rotation + scroll + self._fact_index = 0 + self._fact_scroll = 0 + + # Player card rotation + self._player_card_index = 0 + + self.logger.info( + f"Masters Tournament plugin initialized: {self.display_width}x{self.display_height}, " + f"{len(self.modes)} modes, phase: {self._tournament_phase}" + ) + + # ── Phase-aware mode definitions ── + # Each phase lists modes in priority order (shown most → least) + # The framework rotates through these, so order = screen time priority + + PHASE_MODES = { + "off-season": [ + "masters_fun_facts", + "masters_past_champions", + "masters_course_tour", + "masters_hole_by_hole", + "masters_amen_corner", + "masters_course_overview", + "masters_tournament_stats", + "masters_countdown", + ], + "pre-tournament": [ + "masters_countdown", + "masters_fun_facts", + "masters_course_tour", + "masters_hole_by_hole", + "masters_course_overview", + "masters_amen_corner", + "masters_featured_holes", + "masters_past_champions", + "masters_tournament_stats", + ], + "practice": [ + "masters_schedule", + "masters_course_tour", + "masters_hole_by_hole", + "masters_fun_facts", + "masters_course_overview", + "masters_amen_corner", + "masters_featured_holes", + "masters_past_champions", + "masters_countdown", + ], + "tournament-morning": [ + "masters_schedule", + "masters_leaderboard", + "masters_field_overview", + "masters_hole_by_hole", + "masters_fun_facts", + "masters_course_overview", + "masters_amen_corner", + ], + "tournament-live": [ + "masters_leaderboard", + "masters_player_card", + "masters_leaderboard", # Show leaderboard twice per cycle + "masters_field_overview", + "masters_live_action", + "masters_leaderboard", # And a third time - it's the star + "masters_featured_holes", + "masters_amen_corner", + "masters_schedule", + "masters_tournament_stats", + ], + "tournament-evening": [ + "masters_leaderboard", + "masters_player_card", + "masters_past_champions", + "masters_tournament_stats", + "masters_hole_by_hole", + "masters_fun_facts", + "masters_field_overview", + "masters_course_overview", + ], + "tournament-overnight": [ + "masters_leaderboard", + "masters_fun_facts", + "masters_past_champions", + "masters_course_tour", + "masters_countdown", + ], + "post-tournament": [ + "masters_leaderboard", + "masters_player_card", + "masters_past_champions", + "masters_tournament_stats", + "masters_fun_facts", + ], + } + + def _build_enabled_modes(self) -> List[str]: + """Build mode list based on current tournament phase and time of day. + + The framework rotates through self.modes, so this controls what + the user sees and in what order. Modes listed multiple times get + proportionally more screen time. + """ + phase = get_detailed_phase() + phase_modes = self.PHASE_MODES.get(phase, self.PHASE_MODES["off-season"]) + + # Filter by user config (respect per-mode enabled/disabled) + display_modes_config = self.config.get("display_modes", {}) + config_key_map = { + "masters_leaderboard": "leaderboard", + "masters_player_card": "player_cards", + "masters_hole_by_hole": "hole_by_hole", + "masters_live_action": "live_action", + "masters_course_tour": "course_tour", + "masters_amen_corner": "amen_corner", + "masters_featured_holes": "featured_holes", + "masters_schedule": "schedule", + "masters_past_champions": "past_champions", + "masters_tournament_stats": "tournament_stats", + "masters_fun_facts": "fun_facts", + "masters_countdown": "countdown", + "masters_field_overview": "field_overview", + "masters_course_overview": "course_overview", + } + + enabled = [] + for mode in phase_modes: + config_key = config_key_map.get(mode) + if config_key: + mode_config = display_modes_config.get(config_key, {}) + if mode_config.get("enabled", True): + enabled.append(mode) + + self.logger.debug(f"Phase '{phase}' -> {len(enabled)} modes: {enabled}") + return enabled + + def update(self): + """Fetch and update all Masters Tournament data.""" + now = time.time() + if now - self._last_update < self._update_interval: + return + + self.logger.info("Updating Masters Tournament data...") + self._last_update = now + self._tournament_phase = get_tournament_phase() + + # Refresh modes based on current phase/time of day + # This lets modes shift automatically (e.g., morning → live → evening) + new_modes = self._build_enabled_modes() + if new_modes != self.modes: + old_phase = self._detailed_phase + self._detailed_phase = get_detailed_phase() + self.modes = new_modes + self.logger.info( + f"Phase changed: {old_phase} -> {self._detailed_phase}, " + f"now showing {len(self.modes)} modes" + ) + + try: + self._update_leaderboard() + except Exception as e: + self.logger.error(f"Error updating leaderboard: {e}", exc_info=True) + + try: + self._update_schedule() + except Exception as e: + self.logger.error(f"Error updating schedule: {e}", exc_info=True) + + try: + self._update_favorite_players() + except Exception as e: + self.logger.error(f"Error updating favorite players: {e}", exc_info=True) + + def _update_leaderboard(self): + """Update leaderboard data from API.""" + raw_leaderboard = self.data_source.fetch_leaderboard() + if not raw_leaderboard: + return + + sorted_board = sort_leaderboard(raw_leaderboard) + + favorites = self.config.get("favorite_players", []) + top_n = self.config.get("display_modes", {}).get("leaderboard", {}).get("top_n", 10) + always_show = self.config.get("display_modes", {}).get("leaderboard", {}).get( + "show_favorites_always", True + ) + + self._leaderboard_data = filter_favorite_players( + sorted_board, favorites, top_n=top_n, always_show_favorites=always_show + ) + self.logger.debug(f"Updated leaderboard with {len(self._leaderboard_data)} players") + + def _update_schedule(self): + """Update schedule data from API.""" + self._schedule_data = self.data_source.fetch_schedule() + + def _update_favorite_players(self): + """Fetch detailed data for favorite players.""" + favorites = self.config.get("favorite_players", []) + if not favorites: + return + + for player in self._leaderboard_data: + player_name = player.get("player", "") + if any(fav.lower() in player_name.lower() for fav in favorites): + player_id = player.get("player_id", "") + if player_id: + details = self.data_source.fetch_player_details(player_id) + if details: + self._player_data[player_id] = details + + def display(self, force_clear: bool = False, display_mode: Optional[str] = None) -> bool: + """Render the current display mode.""" + if not self.enabled: + return False + + if display_mode is None: + display_mode = self.modes[0] if self.modes else None + + if display_mode is None: + return False + + self._current_display_mode = display_mode + + if force_clear: + self.display_manager.clear() + + dispatch = { + "masters_leaderboard": self._display_leaderboard, + "masters_player_card": self._display_player_cards, + "masters_course_tour": self._display_course_tour, + "masters_amen_corner": self._display_amen_corner, + "masters_past_champions": self._display_past_champions, + "masters_hole_by_hole": self._display_hole_by_hole, + "masters_featured_holes": self._display_featured_holes, + "masters_schedule": self._display_schedule, + "masters_live_action": self._display_live_action, + "masters_tournament_stats": self._display_tournament_stats, + "masters_fun_facts": self._display_fun_facts, + "masters_countdown": self._display_countdown, + "masters_field_overview": self._display_field_overview, + "masters_course_overview": self._display_course_overview, + } + + handler = dispatch.get(display_mode) + if handler: + return handler(force_clear) + + self.logger.warning(f"Unknown display mode: {display_mode}") + return False + + def _show_image(self, image: Optional[Image.Image]) -> bool: + """Helper to display an image if it exists.""" + if image: + self.display_manager.draw_image(image, 0, 0) + self.display_manager.update_display() + return True + return False + + def _display_leaderboard(self, force_clear: bool) -> bool: + if not self._leaderboard_data: + return False + page = self._page["leaderboard"] + result = self._show_image( + self.renderer.render_leaderboard(self._leaderboard_data, show_favorites=True, page=page) + ) + self._page["leaderboard"] = page + 1 + return result + + def _display_player_cards(self, force_clear: bool) -> bool: + if not self._leaderboard_data: + return False + # Rotate through top players + idx = self._player_card_index % min(5, len(self._leaderboard_data)) + player = self._leaderboard_data[idx] + self._player_card_index += 1 + return self._show_image(self.renderer.render_player_card(player)) + + def _display_course_tour(self, force_clear: bool) -> bool: + result = self._show_image(self.renderer.render_hole_card(self._current_hole)) + self._current_hole = (self._current_hole % 18) + 1 + return result + + def _display_amen_corner(self, force_clear: bool) -> bool: + return self._show_image(self.renderer.render_amen_corner()) + + def _display_past_champions(self, force_clear: bool) -> bool: + page = self._page["champions"] + result = self._show_image(self.renderer.render_past_champions(page=page)) + self._page["champions"] = page + 1 + return result + + def _display_hole_by_hole(self, force_clear: bool) -> bool: + """Display hole-by-hole course tour (same as course_tour).""" + return self._display_course_tour(force_clear) + + def _display_featured_holes(self, force_clear: bool) -> bool: + featured = [12, 13, 15, 16] + hole = featured[self._featured_hole_index % len(featured)] + self._featured_hole_index += 1 + return self._show_image(self.renderer.render_hole_card(hole)) + + def _display_schedule(self, force_clear: bool) -> bool: + page = self._page["schedule"] + result = self._show_image( + self.renderer.render_schedule(self._schedule_data, page=page) + ) + self._page["schedule"] = page + 1 + return result + + def _display_live_action(self, force_clear: bool) -> bool: + """Show live alert if enhanced renderer available, else leaderboard.""" + if hasattr(self.renderer, "render_live_alert") and self._leaderboard_data: + # Show the leader's current status as a live alert + leader = self._leaderboard_data[0] + return self._show_image( + self.renderer.render_live_alert( + leader.get("player", ""), + leader.get("current_hole", 18) or 18, + "Leader", + ) + ) + return self._display_leaderboard(force_clear) + + def _display_tournament_stats(self, force_clear: bool) -> bool: + page = self._page["stats"] + result = self._show_image(self.renderer.render_tournament_stats(page=page)) + self._page["stats"] = page + 1 + return result + + def _display_fun_facts(self, force_clear: bool) -> bool: + result = self._show_image( + self.renderer.render_fun_fact(self._fact_index, scroll_offset=self._fact_scroll) + ) + self._fact_scroll += 1 + # Move to next fact after scrolling through + if self._fact_scroll > 5: + self._fact_index += 1 + self._fact_scroll = 0 + return result + + def _display_countdown(self, force_clear: bool) -> bool: + # Compute next Masters Thursday dynamically (approx second Thursday of April) + now = datetime.utcnow() + year = now.year + target = datetime(year, 4, 10, 12, 0, 0) + if now > target: + target = datetime(year + 1, 4, 10, 12, 0, 0) + countdown = calculate_tournament_countdown(target) + return self._show_image( + self.renderer.render_countdown( + countdown["days"], countdown["hours"], countdown["minutes"] + ) + ) + + def _display_field_overview(self, force_clear: bool) -> bool: + if not self._leaderboard_data: + return False + return self._show_image(self.renderer.render_field_overview(self._leaderboard_data)) + + def _display_course_overview(self, force_clear: bool) -> bool: + if hasattr(self.renderer, "render_course_overview"): + page = self._page["course_overview"] + result = self._show_image(self.renderer.render_course_overview(page=page)) + self._page["course_overview"] = page + 1 + return result + return self._display_amen_corner(force_clear) + + def get_vegas_content(self) -> Optional[List[Image.Image]]: + """Return cards for Vegas scroll mode.""" + cards = [] + + for player in self._leaderboard_data[:10]: + card = self.renderer.render_player_card(player) + if card: + cards.append(card) + + for hole in range(1, 19): + card = self.renderer.render_hole_card(hole) + if card: + cards.append(card) + + # Fun facts + for i in range(5): + card = self.renderer.render_fun_fact(i) + if card: + cards.append(card) + + return cards if cards else None + + def get_vegas_content_type(self) -> str: + return "multi" + + def get_vegas_display_mode(self) -> VegasDisplayMode: + return VegasDisplayMode.SCROLL + + def get_info(self) -> Dict[str, Any]: + """Return plugin info.""" + info = super().get_info() + info.update({ + "name": "Masters Tournament", + "enabled_modes": self.modes, + "mode_count": len(self.modes), + "last_update": self._last_update, + "tournament_phase": self._tournament_phase, + "has_leaderboard": bool(self._leaderboard_data), + "player_count": len(self._leaderboard_data), + "mock_mode": self.config.get("mock_data", False), + }) + return info + + def on_config_change(self, new_config): + """Handle config changes.""" + super().on_config_change(new_config) + self._update_interval = new_config.get("update_interval", 30) + self.display_duration = new_config.get("display_duration", 20) + self.modes = self._build_enabled_modes() + self._last_update = 0 + + def cleanup(self): + """Clean up resources.""" + try: + self.logo_loader.clear_cache() + self.logger.info("Masters Tournament cleanup completed") + except Exception: + self.logger.exception("Error during Masters Tournament cleanup") + super().cleanup() diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json new file mode 100644 index 0000000..beb2421 --- /dev/null +++ b/plugins/masters-tournament/manifest.json @@ -0,0 +1,38 @@ +{ + "id": "masters-tournament", + "name": "Masters Tournament", + "version": "2.0.0", + "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", + "author": "Claude", + "class_name": "MastersTournamentPlugin", + "entry_point": "manager.py", + "display_modes": [ + "masters_leaderboard", + "masters_player_card", + "masters_hole_by_hole", + "masters_live_action", + "masters_course_tour", + "masters_amen_corner", + "masters_featured_holes", + "masters_schedule", + "masters_past_champions", + "masters_tournament_stats", + "masters_fun_facts", + "masters_countdown", + "masters_field_overview", + "masters_course_overview" + ], + "update_interval": 30, + "display_duration": 20, + "requires_api_key": false, + "config_schema": "config_schema.json", + "tags": ["sports", "golf", "tournament", "leaderboard", "masters", "augusta"], + "min_display_size": { + "width": 32, + "height": 16 + }, + "recommended_display_size": { + "width": 128, + "height": 64 + } +} diff --git a/plugins/masters-tournament/masters_data.py b/plugins/masters-tournament/masters_data.py new file mode 100644 index 0000000..42afd9d --- /dev/null +++ b/plugins/masters-tournament/masters_data.py @@ -0,0 +1,323 @@ +""" +Masters Tournament Data Source + +Handles all data fetching from ESPN Golf API with proper caching. +Supports mock data mode for testing when Masters isn't live. +Enriches player data with real ESPN headshot URLs and country codes. +""" + +import logging +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +import requests + +from masters_helpers import ESPN_HEADSHOT_URL, ESPN_PLAYER_IDS, get_espn_headshot_url, get_player_country + +logger = logging.getLogger(__name__) + + +class MastersDataSource: + """Fetches and caches Masters Tournament data from ESPN Golf API.""" + + LEADERBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/golf/pga/leaderboard" + SCHEDULE_URL = "https://site.api.espn.com/apis/site/v2/sports/golf/pga/schedule" + NEWS_URL = "https://site.api.espn.com/apis/site/v2/sports/golf/pga/news" + + def __init__(self, cache_manager, config: Dict[str, Any]): + self.cache_manager = cache_manager + self.config = config + self.mock_mode = config.get("mock_data", False) + self.logger = logging.getLogger(__name__) + + def fetch_leaderboard(self) -> List[Dict]: + """Fetch current Masters leaderboard with caching.""" + if self.mock_mode: + return self._generate_mock_leaderboard() + + cache_key = "masters_leaderboard" + ttl = self._get_cache_ttl() + + cached = self.cache_manager.get(cache_key, max_age=ttl) + if cached: + self.logger.debug("Using cached leaderboard data") + return cached + + try: + response = requests.get( + self.LEADERBOARD_URL, + timeout=10, + headers={"User-Agent": "LEDMatrix Masters Plugin/2.0"}, + ) + response.raise_for_status() + data = response.json() + + is_masters = self._is_masters_tournament(data) + if not is_masters: + self.logger.info("Masters not currently in ESPN API, using mock data") + mock = self._generate_mock_leaderboard() + self.cache_manager.set(cache_key, mock, ttl=3600) + return mock + + parsed = self._parse_leaderboard(data) + self.cache_manager.set(cache_key, parsed, ttl=ttl) + return parsed + + except Exception as e: + self.logger.error(f"Failed to fetch leaderboard: {e}") + return self._get_fallback_data(cache_key) + + def fetch_schedule(self) -> List[Dict]: + """Fetch Masters schedule with tee times and pairings.""" + if self.mock_mode: + return self._generate_mock_schedule() + + cache_key = "masters_schedule" + ttl = 300 + + cached = self.cache_manager.get(cache_key, max_age=ttl) + if cached: + return cached + + try: + response = requests.get( + self.SCHEDULE_URL, + timeout=10, + headers={"User-Agent": "LEDMatrix Masters Plugin/2.0"}, + ) + response.raise_for_status() + data = response.json() + + parsed = self._parse_schedule(data) + self.cache_manager.set(cache_key, parsed, ttl=ttl) + return parsed + + except Exception as e: + self.logger.error(f"Failed to fetch schedule: {e}") + return self._get_fallback_data(cache_key) + + def fetch_player_details(self, player_id: str) -> Optional[Dict]: + """Fetch detailed player statistics.""" + cache_key = f"masters_player_{player_id}" + ttl = self._get_cache_ttl() + + cached = self.cache_manager.get(cache_key, max_age=ttl) + if cached: + return cached + + return None + + def _is_masters_tournament(self, data: Dict) -> bool: + """Check if the current tournament in ESPN data is the Masters.""" + try: + events = data.get("events", []) + if not events: + return False + name = events[0].get("name", "").lower() + return any(kw in name for kw in ["masters", "augusta national", "augusta"]) + except Exception: + return False + + def _parse_leaderboard(self, data: Dict) -> List[Dict]: + """Extract and enrich fields from ESPN leaderboard API response.""" + players = [] + + try: + events = data.get("events", []) + if not events: + return players + + competitions = events[0].get("competitions", []) + if not competitions: + return players + + competitors = competitions[0].get("competitors", []) + + for entry in competitors: + athlete = entry.get("athlete", {}) + status = entry.get("status", {}) + score_data = entry.get("score", {}) + + player_name = athlete.get("displayName", "Unknown") + player_id = athlete.get("id", "") + + # Get headshot - prefer ESPN API data, fall back to our DB + headshot_url = athlete.get("headshot", {}).get("href") + if not headshot_url: + headshot_url = get_espn_headshot_url(player_name) + + # Get country from our DB if not in API + country = "" + flag_data = athlete.get("flag", {}) + if flag_data: + country = flag_data.get("alt", "") + # Normalize to 3-letter code + if len(country) > 3: + country = get_player_country(player_name) or "" + if not country: + country = get_player_country(player_name) or "" + + players.append({ + "position": entry.get("position", 0), + "player": player_name, + "player_id": player_id, + "country": country, + "score": self._calculate_score_to_par(entry), + "today": self._get_today_score(score_data), + "thru": status.get("thru", "F"), + "rounds": self._extract_round_scores(entry), + "headshot_url": headshot_url, + "current_hole": status.get("hole"), + "status": status.get("displayValue", ""), + }) + + except Exception as e: + self.logger.error(f"Error parsing leaderboard: {e}") + + return players + + def _calculate_score_to_par(self, entry: Dict) -> int: + """Calculate player's score relative to par.""" + try: + display_value = entry.get("score", {}).get("displayValue", "E") + if display_value == "E": + return 0 + elif display_value.startswith("+"): + return int(display_value[1:]) + elif display_value.startswith("-"): + return int(display_value) + return 0 + except Exception: + return 0 + + def _get_today_score(self, score_data: Dict) -> Optional[int]: + """Get today's round score relative to par.""" + try: + value = score_data.get("value") + if value is not None: + return int(value) + except Exception: + pass + return None + + def _extract_round_scores(self, entry: Dict) -> List[Optional[int]]: + """Extract scores for each round.""" + rounds = [None, None, None, None] + try: + linescores = entry.get("linescores", []) + for i, linescore in enumerate(linescores[:4]): + value = linescore.get("value") + if value is not None: + rounds[i] = int(value) + except Exception: + pass + return rounds + + def _parse_schedule(self, data: Dict) -> List[Dict]: + """Parse schedule data from ESPN API.""" + schedule = [] + try: + events = data.get("events", []) + if events: + competitions = events[0].get("competitions", []) + for comp in competitions: + tee_times = comp.get("teeTimes", []) + for tt in tee_times: + players_list = [] + for competitor in tt.get("competitors", []): + athlete = competitor.get("athlete", {}) + players_list.append(athlete.get("displayName", "Unknown")) + schedule.append({ + "time": tt.get("startTime", "TBD"), + "players": players_list, + }) + except Exception as e: + self.logger.error(f"Error parsing schedule: {e}") + return schedule + + def _get_cache_ttl(self) -> int: + """Get appropriate cache TTL based on tournament phase.""" + phase = self._detect_tournament_phase() + if phase == "tournament": + return 30 + elif phase == "practice": + return 300 + return 3600 + + def _detect_tournament_phase(self) -> str: + """Detect if it's practice rounds, tournament, or off-season.""" + now = datetime.now() + if now.month == 4: + if 7 <= now.day <= 9: + return "practice" + elif 10 <= now.day <= 13: + return "tournament" + return "off-season" + + def _get_fallback_data(self, cache_key: str) -> List[Dict]: + """Get stale cached data or mock data as fallback.""" + cached = self.cache_manager.get(cache_key, max_age=None) + if cached: + self.logger.warning("Using stale cached data for %s", cache_key) + return cached + + self.logger.warning("No fallback data for %s, using mock", cache_key) + if "leaderboard" in cache_key: + return self._generate_mock_leaderboard() + return [] + + def _generate_mock_leaderboard(self) -> List[Dict]: + """Generate realistic mock leaderboard with real player data.""" + players = [ + {"pos": 1, "name": "Scottie Scheffler", "score": -12, "today": -4, "thru": 15, "rounds": [68, 67, 69, None]}, + {"pos": 2, "name": "Rory McIlroy", "score": -10, "today": -3, "thru": 16, "rounds": [70, 68, 68, None]}, + {"pos": 3, "name": "Jon Rahm", "score": -9, "today": -2, "thru": 14, "rounds": [69, 69, 69, None]}, + {"pos": "T4", "name": "Brooks Koepka", "score": -7, "today": -1, "thru": 15, "rounds": [71, 68, 70, None]}, + {"pos": "T4", "name": "Viktor Hovland", "score": -7, "today": -2, "thru": 13, "rounds": [70, 69, 70, None]}, + {"pos": 6, "name": "Xander Schauffele", "score": -6, "today": 0, "thru": 16, "rounds": [68, 71, 69, None]}, + {"pos": 7, "name": "Collin Morikawa", "score": -5, "today": -1, "thru": 14, "rounds": [72, 68, 69, None]}, + {"pos": 8, "name": "Jordan Spieth", "score": -4, "today": 0, "thru": 15, "rounds": [70, 70, 70, None]}, + {"pos": "T9", "name": "Patrick Cantlay", "score": -3, "today": -1, "thru": 12, "rounds": [71, 70, 70, None]}, + {"pos": "T9", "name": "Ludvig Aberg", "score": -3, "today": +1, "thru": 14, "rounds": [69, 71, 71, None]}, + {"pos": 11, "name": "Tiger Woods", "score": -2, "today": 0, "thru": 13, "rounds": [72, 70, 70, None]}, + {"pos": 12, "name": "Hideki Matsuyama", "score": -1, "today": +1, "thru": 15, "rounds": [70, 72, 69, None]}, + {"pos": "T13","name": "Tommy Fleetwood", "score": 0, "today": 0, "thru": 14, "rounds": [71, 71, 70, None]}, + {"pos": "T13","name": "Shane Lowry", "score": 0, "today": -1, "thru": 12, "rounds": [73, 70, 69, None]}, + {"pos": 15, "name": "Adam Scott", "score": +1, "today": +2, "thru": 16, "rounds": [72, 70, 73, None]}, + ] + + result = [] + for p in players: + name = p["name"] + pid_info = ESPN_PLAYER_IDS.get(name, {}) + player_id = pid_info.get("id", f"mock_{name.replace(' ', '_')}") + country = pid_info.get("country", "USA") + headshot_url = get_espn_headshot_url(name) + + result.append({ + "position": p["pos"], + "player": name, + "player_id": player_id, + "country": country, + "score": p["score"], + "today": p["today"], + "thru": p["thru"], + "rounds": p["rounds"], + "headshot_url": headshot_url, + "current_hole": p["thru"] + 1 if isinstance(p["thru"], int) and p["thru"] < 18 else None, + "status": f"Thru {p['thru']}", + }) + + return result + + def _generate_mock_schedule(self) -> List[Dict]: + """Generate mock schedule data.""" + return [ + {"time": "8:00 AM", "players": ["Tiger Woods", "Phil Mickelson", "Adam Scott"]}, + {"time": "8:15 AM", "players": ["Scottie Scheffler", "Rory McIlroy", "Jon Rahm"]}, + {"time": "8:30 AM", "players": ["Brooks Koepka", "Viktor Hovland", "Xander Schauffele"]}, + {"time": "8:45 AM", "players": ["Jordan Spieth", "Collin Morikawa", "Patrick Cantlay"]}, + {"time": "9:00 AM", "players": ["Ludvig Aberg", "Hideki Matsuyama", "Tommy Fleetwood"]}, + {"time": "9:15 AM", "players": ["Shane Lowry", "Tony Finau", "Max Homa"]}, + ] diff --git a/plugins/masters-tournament/masters_helpers.py b/plugins/masters-tournament/masters_helpers.py new file mode 100644 index 0000000..d40b1c0 --- /dev/null +++ b/plugins/masters-tournament/masters_helpers.py @@ -0,0 +1,504 @@ +""" +Masters Tournament Helper Functions + +Comprehensive utility functions, real tournament data, fun facts, +accurate hole information, and complete historical records. +""" + +import random +from datetime import datetime, timezone, timedelta +from typing import Dict, List, Optional, Any + +# Augusta is in Eastern Time (UTC-5 / UTC-4 DST) +# Use a fixed offset for April (EDT = UTC-4) to avoid requiring pytz/zoneinfo +# at import time. Masters always falls during DST. +_EDT = timezone(timedelta(hours=-4)) + + +# ═══════════════════════════════════════════════════════════════ +# COMPLETE AUGUSTA NATIONAL HOLE DATA (2024 yardages) +# ═══════════════════════════════════════════════════════════════ + +AUGUSTA_HOLES = { + 1: {"name": "Tea Olive", "par": 4, "yardage": 445, "record": 2, "record_holder": "Justin Rose (2017)"}, + 2: {"name": "Pink Dogwood", "par": 5, "yardage": 575, "record": 3, "record_holder": "Multiple"}, + 3: {"name": "Flowering Peach", "par": 4, "yardage": 350, "record": 2, "record_holder": "Multiple"}, + 4: {"name": "Flowering Crab Apple", "par": 3, "yardage": 240, "record": 1, "record_holder": "Jeff Sluman (1992)"}, + 5: {"name": "Magnolia", "par": 4, "yardage": 495, "record": 2, "record_holder": "Multiple"}, + 6: {"name": "Juniper", "par": 3, "yardage": 180, "record": 1, "record_holder": "Jamie Donaldson (2014)"}, + 7: {"name": "Pampas", "par": 4, "yardage": 450, "record": 2, "record_holder": "Multiple"}, + 8: {"name": "Yellow Jasmine", "par": 5, "yardage": 570, "record": 2, "record_holder": "Bruce Devlin (1967)"}, + 9: {"name": "Carolina Cherry", "par": 4, "yardage": 460, "record": 2, "record_holder": "Multiple"}, + 10: {"name": "Camellia", "par": 4, "yardage": 495, "record": 2, "record_holder": "Multiple"}, + 11: {"name": "White Dogwood", "par": 4, "yardage": 520, "record": 2, "record_holder": "Multiple", "zone": "Amen Corner"}, + 12: {"name": "Golden Bell", "par": 3, "yardage": 155, "record": 1, "record_holder": "Multiple", "zone": "Amen Corner"}, + 13: {"name": "Azalea", "par": 5, "yardage": 510, "record": 2, "record_holder": "Jeff Maggert (1994)", "zone": "Amen Corner"}, + 14: {"name": "Chinese Fir", "par": 4, "yardage": 440, "record": 2, "record_holder": "Multiple"}, + 15: {"name": "Firethorn", "par": 5, "yardage": 550, "record": 2, "record_holder": "Gene Sarazen (1935)"}, + 16: {"name": "Redbud", "par": 3, "yardage": 170, "record": 1, "record_holder": "Multiple", "zone": "Featured"}, + 17: {"name": "Nandina", "par": 4, "yardage": 440, "record": 2, "record_holder": "Multiple"}, + 18: {"name": "Holly", "par": 4, "yardage": 465, "record": 2, "record_holder": "Multiple"}, +} + +# Course totals +AUGUSTA_PAR = 72 +AUGUSTA_TOTAL_YARDAGE = 7545 + + +# ═══════════════════════════════════════════════════════════════ +# PAST CHAMPIONS - Complete and accurate through 2025 +# ═══════════════════════════════════════════════════════════════ + +PAST_CHAMPIONS = [ + (2025, "Rory McIlroy", "NIR", -11), + (2024, "Scottie Scheffler", "USA", -11), + (2023, "Jon Rahm", "ESP", -12), + (2022, "Scottie Scheffler", "USA", -10), + (2021, "Hideki Matsuyama", "JPN", -10), + (2020, "Dustin Johnson", "USA", -20), + (2019, "Tiger Woods", "USA", -13), + (2018, "Patrick Reed", "USA", -15), + (2017, "Sergio Garcia", "ESP", -9), + (2016, "Danny Willett", "ENG", -5), + (2015, "Jordan Spieth", "USA", -18), + (2014, "Bubba Watson", "USA", -8), + (2013, "Adam Scott", "AUS", -9), + (2012, "Bubba Watson", "USA", -10), + (2011, "Charl Schwartzel", "RSA", -14), + (2010, "Phil Mickelson", "USA", -16), + (2009, "Angel Cabrera", "ARG", -12), + (2008, "Trevor Immelman", "RSA", -8), + (2007, "Zach Johnson", "USA", +1), + (2006, "Phil Mickelson", "USA", -7), + (2005, "Tiger Woods", "USA", -12), + (2004, "Phil Mickelson", "USA", -9), + (2003, "Mike Weir", "CAN", -7), + (2002, "Tiger Woods", "USA", -12), + (2001, "Tiger Woods", "USA", -16), + (2000, "Vijay Singh", "FIJ", -10), + (1999, "Jose Maria Olazabal","ESP", -8), + (1998, "Mark O'Meara", "USA", -9), + (1997, "Tiger Woods", "USA", -18), + (1996, "Nick Faldo", "ENG", -12), + (1995, "Ben Crenshaw", "USA", -14), + (1994, "Jose Maria Olazabal","ESP", -9), + (1993, "Bernhard Langer", "GER", -11), + (1992, "Fred Couples", "USA", -13), + (1991, "Ian Woosnam", "WAL", -11), + (1990, "Nick Faldo", "ENG", -10), + (1989, "Nick Faldo", "ENG", -5), + (1988, "Sandy Lyle", "SCO", -7), + (1987, "Larry Mize", "USA", -3), + (1986, "Jack Nicklaus", "USA", -9), +] + +# Multiple green jacket winners +MULTIPLE_WINNERS = { + "Jack Nicklaus": 6, + "Tiger Woods": 5, + "Arnold Palmer": 4, + "Phil Mickelson": 3, + "Jimmy Demaret": 3, + "Sam Snead": 3, + "Gary Player": 3, + "Nick Faldo": 3, + "Scottie Scheffler": 2, + "Bubba Watson": 2, + "Jose Maria Olazabal": 2, + "Bernhard Langer": 2, + "Ben Crenshaw": 2, + "Seve Ballesteros": 2, + "Tom Watson": 2, + "Ben Hogan": 2, + "Byron Nelson": 2, + "Horton Smith": 2, +} + + +# ═══════════════════════════════════════════════════════════════ +# TOURNAMENT RECORDS +# ═══════════════════════════════════════════════════════════════ + +TOURNAMENT_RECORDS = { + "lowest_72": {"score": -20, "player": "Dustin Johnson", "year": 2020, "total": 268}, + "lowest_round": {"score": 63, "player": "Nick Price", "year": 1986, "note": "Also shot by Greg Norman (1996)"}, + "largest_comeback": {"strokes": 8, "player": "Jack Burke Jr.", "year": 1956}, + "youngest_winner": {"age": 21, "player": "Tiger Woods", "year": 1997}, + "oldest_winner": {"age": 46, "player": "Jack Nicklaus", "year": 1986}, + "largest_margin": {"strokes": 12, "player": "Tiger Woods", "year": 1997}, + "most_wins": {"wins": 6, "player": "Jack Nicklaus", "years": "1963-86"}, + "most_cuts": {"cuts": 37, "player": "Fred Couples", "note": "37 consecutive"}, + "most_top5": {"count": 22, "player": "Jack Nicklaus"}, + "first_tournament": {"year": 1934, "winner": "Horton Smith"}, +} + + +# ═══════════════════════════════════════════════════════════════ +# FUN FACTS DATABASE +# ═══════════════════════════════════════════════════════════════ + +MASTERS_FUN_FACTS = [ + # Course & History + "Augusta National was built on the site of a former plant nursery called Fruitland Nurseries - that's why every hole is named after a tree or shrub.", + "The famous Magnolia Lane entrance is lined with 61 magnolia trees planted in the 1850s.", + "Augusta National has only about 300 members. The initiation fee is estimated at $40,000.", + "Bobby Jones and Clifford Roberts co-founded Augusta National Golf Club in 1933.", + "The Masters was originally called the 'Augusta National Invitation Tournament' until 1939.", + "Augusta National did not admit its first Black member until 1990 (Ron Townsend) and its first female members until 2012.", + "The Par 3 Contest has been held on the Wednesday before the Masters since 1960. No winner has ever gone on to win the Masters that same year.", + "Pimento cheese sandwiches at the Masters cost just $1.50 - the most iconic cheap eats in all of sports.", + "Fans are called 'patrons' at the Masters, never 'fans' or 'spectators'.", + "Cell phones are strictly banned on the grounds at Augusta National.", + + # Iconic Moments + "Gene Sarazen's 'Shot Heard Round the World' - a 235-yard double eagle on #15 in 1935 - is one of golf's most famous shots.", + "In 1986, 46-year-old Jack Nicklaus shot a back nine 30 to win his 6th green jacket, the oldest winner ever.", + "Tiger Woods won his first Masters in 1997 by a record 12 strokes at age 21.", + "In 2019, Tiger Woods completed one of sport's greatest comebacks, winning his 5th green jacket 14 years after his 4th.", + "Bubba Watson has never had a golf lesson. He won the Masters twice (2012, 2014).", + "Jordan Spieth's -18 in 2015 tied Tiger Woods' 1997 record for lowest score to par.", + "Dustin Johnson's -20 in 2020 (played in November due to COVID) broke the all-time scoring record.", + + # Amen Corner + "Amen Corner (holes 11-13) was named by Sports Illustrated's Herbert Warren Wind in 1958.", + "Hole 12 (Golden Bell) is the shortest hole at Augusta at just 155 yards, but is considered one of the hardest par 3s in golf.", + "The swirling winds at the 12th hole have caused more drama than any other hole in Masters history.", + "Rae's Creek runs in front of the 12th green and along the 13th hole. It's named after John Rae, an 18th-century settler.", + + # Green Jacket + "The green jacket tradition started in 1949. Sam Snead was the first winner to receive one.", + "Winners can only take the green jacket off club property for one year. After that, it stays in their locker at Augusta.", + "The green jacket is made by Hamilton of Cincinnati and costs approximately $300.", + "If a member or past champion's jacket is damaged, it's repaired - never replaced. Some jackets are decades old.", + + # The Course + "Augusta National plays backwards from its original Alister MacKenzie design - the current front nine was originally the back nine.", + "The course has been significantly lengthened over the years. It played at 6,925 yards in 1997 vs. 7,545 yards today.", + "There are no rough at Augusta National - instead there are 'second cut' areas with pine straw.", + "The greens at Augusta are Sub-Air heated/cooled and use bentgrass. They typically run 13+ on the stimpmeter.", + "Eisenhower Tree, a large loblolly pine on hole 17, was named after President Eisenhower who hit it so often he wanted it removed. It was finally lost to an ice storm in 2014.", + + # Traditions + "The Champions Dinner on Tuesday night is hosted by the defending champion who picks the menu. Tiger Woods famously served cheeseburgers, fries, and milkshakes in 1998.", + "The honorary starters tradition began in 1963. Jack Nicklaus, Gary Player, and Tom Watson have served as honorary starters.", + "Caddies at Augusta wear white jumpsuits and are identified by the player name on the back.", + "The Butler Cabin, where the green jacket ceremony takes place on TV, seats only about 30 people.", + "The famous crow's nest atop the clubhouse houses amateur competitors during the tournament. It has 5 beds.", +] + + +# ═══════════════════════════════════════════════════════════════ +# REAL ESPN PLAYER IDS (for headshot downloads) +# ═══════════════════════════════════════════════════════════════ + +ESPN_PLAYER_IDS = { + "Scottie Scheffler": {"id": "9478", "country": "USA"}, + "Rory McIlroy": {"id": "3470", "country": "NIR"}, + "Jon Rahm": {"id": "9780", "country": "ESP"}, + "Brooks Koepka": {"id": "6798", "country": "USA"}, + "Viktor Hovland": {"id": "10591", "country": "NOR"}, + "Xander Schauffele": {"id": "10138", "country": "USA"}, + "Collin Morikawa": {"id": "10592", "country": "USA"}, + "Jordan Spieth": {"id": "5765", "country": "USA"}, + "Patrick Cantlay": {"id": "10134", "country": "USA"}, + "Ludvig Aberg": {"id": "4686087", "country": "SWE"}, + "Tiger Woods": {"id": "462", "country": "USA"}, + "Phil Mickelson": {"id": "308", "country": "USA"}, + "Dustin Johnson": {"id": "3702", "country": "USA"}, + "Justin Thomas": {"id": "4686084", "country": "USA"}, + "Hideki Matsuyama": {"id": "5860", "country": "JPN"}, + "Cameron Smith": {"id": "9131", "country": "AUS"}, + "Bryson DeChambeau": {"id": "9261", "country": "USA"}, + "Shane Lowry": {"id": "3448", "country": "IRL"}, + "Tommy Fleetwood": {"id": "9035", "country": "ENG"}, + "Wyndham Clark": {"id": "4686082", "country": "USA"}, + "Max Homa": {"id": "10140", "country": "USA"}, + "Sahith Theegala": {"id": "4375306", "country": "USA"}, + "Tony Finau": {"id": "5548", "country": "USA"}, + "Matt Fitzpatrick": {"id": "9037", "country": "ENG"}, + "Adam Scott": {"id": "367", "country": "AUS"}, + "Sergio Garcia": {"id": "421", "country": "ESP"}, + "Bubba Watson": {"id": "780", "country": "USA"}, + "Patrick Reed": {"id": "5596", "country": "USA"}, + "Danny Willett": {"id": "3008", "country": "ENG"}, + "Charl Schwartzel": {"id": "3367", "country": "RSA"}, +} + +# ESPN headshot URL template +ESPN_HEADSHOT_URL = "https://a.espncdn.com/combiner/i?img=/i/headshots/golf/players/full/{player_id}.png&w=350&h=254" + +# Country flag emoji mapping for display +COUNTRY_FLAGS = { + "USA": "🇺🇸", "ENG": "🏴", "SCO": "🏴", "WAL": "🏴", + "NIR": "🇬🇧", "IRL": "🇮🇪", "ESP": "🇪🇸", "GER": "🇩🇪", + "AUS": "🇦🇺", "RSA": "🇿🇦", "JPN": "🇯🇵", "KOR": "🇰🇷", + "NOR": "🇳🇴", "SWE": "🇸🇪", "CAN": "🇨🇦", "ARG": "🇦🇷", + "FIJ": "🇫🇯", "MEX": "🇲🇽", "COL": "🇨🇴", "CHI": "🇨🇱", + "ITA": "🇮🇹", "FRA": "🇫🇷", "DEN": "🇩🇰", "IND": "🇮🇳", + "CHN": "🇨🇳", "THA": "🇹🇭", "TWN": "🇹🇼", +} + +# 3-letter country code to full name +COUNTRY_NAMES = { + "USA": "United States", "ENG": "England", "SCO": "Scotland", + "NIR": "N. Ireland", "IRL": "Ireland", "ESP": "Spain", + "GER": "Germany", "AUS": "Australia", "RSA": "South Africa", + "JPN": "Japan", "KOR": "South Korea", "NOR": "Norway", + "SWE": "Sweden", "CAN": "Canada", "ARG": "Argentina", + "FIJ": "Fiji", "WAL": "Wales", "FRA": "France", +} + + +# ═══════════════════════════════════════════════════════════════ +# HELPER FUNCTIONS +# ═══════════════════════════════════════════════════════════════ + +def format_player_name(name: str, max_length: int = 15) -> str: + """Format player name to fit within character limit.""" + if len(name) <= max_length: + return name + + parts = name.split() + if len(parts) >= 2: + last_name = parts[-1] + first_initial = parts[0][0] if parts[0] else "" + formatted = f"{first_initial}. {last_name}" + if len(formatted) <= max_length: + return formatted + return last_name[:max_length] + + return name[:max_length - 2] + ".." + + +def format_score_to_par(score: int) -> str: + """Format score relative to par for display.""" + if score == 0: + return "E" + elif score < 0: + return str(score) + else: + return f"+{score}" + + +def calculate_scoring_average(rounds: List[Optional[int]]) -> Optional[float]: + """Calculate average score from round scores.""" + valid_rounds = [r for r in rounds if r is not None] + if not valid_rounds: + return None + return sum(valid_rounds) / len(valid_rounds) + + +def _to_eastern(date: Optional[datetime]) -> datetime: + """Normalize a datetime to Eastern (Augusta) time.""" + if date is None: + return datetime.now(_EDT) + if date.tzinfo is None: + # Assume naive datetimes are already local/Eastern + return date + return date.astimezone(_EDT) + + +def get_tournament_phase(date: Optional[datetime] = None) -> str: + """Determine current Masters tournament phase (basic).""" + date = _to_eastern(date) + + if date.month == 4: + if 7 <= date.day <= 9: + return "practice" + elif 10 <= date.day <= 13: + return "tournament" + + return "off-season" + + +def get_detailed_phase(date: Optional[datetime] = None) -> str: + """ + Determine detailed tournament phase including time-of-day awareness. + + Returns one of: + "off-season" - No Masters activity (most of the year) + "pre-tournament" - Masters week is approaching (week before) + "practice" - Practice rounds (Mon-Wed of Masters week) + "tournament-morning" - Tournament day, before play (~6-8am ET) + "tournament-live" - Tournament day, play in progress (~8am-7pm ET) + "tournament-evening" - Tournament day, play finished (~7pm-midnight ET) + "tournament-overnight"- Tournament day, overnight (midnight-6am ET) + "post-tournament" - Sunday evening / Monday after Masters + """ + date = _to_eastern(date) + + month = date.month + day = date.day + hour = date.hour + + # Masters is typically the second full week of April + # Practice: Mon-Wed, Tournament: Thu-Sun + # Adjust these dates each year as needed + if month == 4: + # Week before Masters (build anticipation) + if 1 <= day <= 6: + return "pre-tournament" + + # Practice rounds + if 7 <= day <= 9: + return "practice" + + # Tournament days (Thu=10 through Sun=13) + if 10 <= day <= 13: + if hour < 6: + return "tournament-overnight" + elif hour < 8: + return "tournament-morning" + elif hour < 19: + return "tournament-live" + else: + return "tournament-evening" + + # Monday after + if day == 14: + return "post-tournament" + + # March - countdown month + if month == 3 and day >= 20: + return "pre-tournament" + + return "off-season" + + +def is_amen_corner_hole(hole_number: int) -> bool: + """Check if a hole is part of Amen Corner (11, 12, 13).""" + return hole_number in [11, 12, 13] + + +def is_featured_hole(hole_number: int) -> bool: + """Check if a hole is a featured/signature hole at Augusta.""" + return hole_number in [4, 6, 11, 12, 13, 15, 16] + + +def get_hole_nickname(hole_number: int) -> Optional[str]: + """Get the traditional nickname for an Augusta National hole.""" + hole = AUGUSTA_HOLES.get(hole_number) + return hole["name"] if hole else None + + +def get_hole_info(hole_number: int) -> Dict[str, Any]: + """Get complete hole information.""" + default = {"name": "Unknown", "par": 4, "yardage": 400} + hole = AUGUSTA_HOLES.get(hole_number, default) + result = dict(hole) + result["hole"] = hole_number + result["is_amen_corner"] = is_amen_corner_hole(hole_number) + result["is_featured"] = is_featured_hole(hole_number) + return result + + +def get_random_fun_fact() -> str: + """Get a random Masters fun fact.""" + return random.choice(MASTERS_FUN_FACTS) + + +def get_fun_fact_by_index(index: int) -> str: + """Get a fun fact by index (wraps around).""" + return MASTERS_FUN_FACTS[index % len(MASTERS_FUN_FACTS)] + + +def get_recent_champions(count: int = 5) -> List[tuple]: + """Get most recent champions.""" + return PAST_CHAMPIONS[:count] + + +def get_espn_headshot_url(player_name: str) -> Optional[str]: + """Get ESPN headshot URL for a player.""" + player_info = ESPN_PLAYER_IDS.get(player_name) + if player_info: + return ESPN_HEADSHOT_URL.format(player_id=player_info["id"]) + return None + + +def get_player_country(player_name: str) -> Optional[str]: + """Get country code for a player.""" + player_info = ESPN_PLAYER_IDS.get(player_name) + if player_info: + return player_info["country"] + return None + + +def get_green_jacket_count(player_name: str) -> int: + """Get number of green jackets for a player.""" + return MULTIPLE_WINNERS.get(player_name, 0) + + +def filter_favorite_players( + players: List[Dict], + favorites: List[str], + top_n: int = 10, + always_show_favorites: bool = True +) -> List[Dict]: + """Filter player list to show top N plus favorites.""" + if not players: + return [] + + favorites_lower = [f.lower() for f in favorites] + result = players[:top_n] + + if always_show_favorites and favorites_lower: + result_names = {p.get("player", "").lower() for p in result} + for player in players[top_n:]: + player_name = player.get("player", "").lower() + if any(fav in player_name for fav in favorites_lower): + if player_name not in result_names: + result.append(player) + + return result + + +def calculate_tournament_countdown(target_date: datetime) -> Dict[str, int]: + """Calculate countdown to Masters tournament.""" + now = datetime.now(timezone.utc) + if target_date.tzinfo is None: + target_date = target_date.replace(tzinfo=timezone.utc) + + delta = target_date - now + + if delta.total_seconds() <= 0: + return {"days": 0, "hours": 0, "minutes": 0} + + return { + "days": delta.days, + "hours": delta.seconds // 3600, + "minutes": (delta.seconds % 3600) // 60 + } + + +def get_score_description(score_to_par: int, hole_par: int = 4) -> str: + """Get textual description of score (eagle, birdie, etc.).""" + if score_to_par <= -3: + return "Albatross" + elif score_to_par == -2: + return "Eagle" + elif score_to_par == -1: + return "Birdie" + elif score_to_par == 0: + return "Par" + elif score_to_par == 1: + return "Bogey" + elif score_to_par == 2: + return "Double Bogey" + else: + return f"+{score_to_par}" + + +def sort_leaderboard(players: List[Dict]) -> List[Dict]: + """Sort leaderboard by position and score.""" + def sort_key(player): + pos = player.get("position", 999) + if isinstance(pos, str): + pos_str = pos.replace("T", "").strip() + try: + pos = int(pos_str) + except ValueError: + pos = 999 + score = player.get("score", 999) + return (pos, score) + + return sorted(players, key=sort_key) diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py new file mode 100644 index 0000000..2db07ae --- /dev/null +++ b/plugins/masters-tournament/masters_renderer.py @@ -0,0 +1,913 @@ +""" +Masters Tournament Renderer - Broadcast Quality + +Pixel-perfect rendering for LED matrix displays with: +- BDF bitmap fonts for crisp text at all sizes +- Broadcast-style leaderboard with pagination +- Player cards with real ESPN headshots and country flags +- Accurate Augusta National hole cards +- Scrolling fun facts ticker +- Past champions with pagination +- Amen Corner spotlight +- Tournament countdown +- Schedule display with pagination +- Generous spacing for LED readability +""" + +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from PIL import Image, ImageDraw, ImageFont + +from masters_helpers import ( + AUGUSTA_HOLES, + AUGUSTA_PAR, + MULTIPLE_WINNERS, + PAST_CHAMPIONS, + TOURNAMENT_RECORDS, + format_player_name, + format_score_to_par, + get_fun_fact_by_index, + get_hole_info, + get_random_fun_fact, + get_recent_champions, + get_score_description, +) + +logger = logging.getLogger(__name__) + +# ═══════════════════════════════════════════════════════════════ +# MASTERS COLOR PALETTE - Authentic colors +# ═══════════════════════════════════════════════════════════════ + +COLORS = { + "masters_green": (0, 104, 56), + "masters_dark": (0, 75, 40), + "masters_yellow": (253, 218, 36), + "augusta_green": (34, 120, 34), + "azalea_pink": (255, 105, 180), + "gold": (255, 215, 0), + "gold_dark": (200, 170, 0), + "white": (255, 255, 255), + "off_white": (240, 240, 235), + "yellow_bright": (255, 255, 102), + "red": (220, 40, 40), + "birdie_red": (200, 0, 0), + "bogey_blue": (80, 120, 200), + "under_par": (100, 255, 100), + "over_par": (255, 130, 130), + "even_par": (200, 200, 200), + "bg": (0, 0, 0), + "bg_dark_green": (5, 20, 10), + "row_alt": (10, 35, 18), + "header_bg": (0, 80, 45), + "shadow": (0, 0, 0), + "gray": (120, 120, 120), + "light_gray": (180, 180, 180), + "page_dot_on": (253, 218, 36), + "page_dot_off": (60, 60, 60), +} + + +# ═══════════════════════════════════════════════════════════════ +# FONT SYSTEM +# ═══════════════════════════════════════════════════════════════ + +FONT_SEARCH_DIRS = [ + "assets/fonts", + "../../../assets/fonts", + "../../assets/fonts", + str(Path.home() / "Github" / "LEDMatrix" / "assets" / "fonts"), +] + +FONT_SPECS = { + "tiny": ("4x6-font.ttf", 6), + "small": ("4x6-font.ttf", 6), + "medium": ("PressStart2P-Regular.ttf", 8), + "large": ("PressStart2P-Regular.ttf", 8), + "xl": ("PressStart2P-Regular.ttf", 10), + "5x7": ("5by7.regular.ttf", 7), +} + + +def _find_font_path(filename: str) -> Optional[str]: + for search_dir in FONT_SEARCH_DIRS: + path = os.path.join(search_dir, filename) + if os.path.exists(path): + return path + return None + + +def _load_font(name: str) -> ImageFont.ImageFont: + if name not in FONT_SPECS: + name = "small" + filename, size = FONT_SPECS[name] + path = _find_font_path(filename) + if path: + try: + return ImageFont.truetype(path, size) + except Exception as e: + logger.warning(f"Failed to load font {path}: {e}") + return ImageFont.load_default() + + +class MastersRenderer: + """Broadcast-quality Masters Tournament renderer with pagination & scrolling.""" + + def __init__( + self, + display_width: int, + display_height: int, + config: Dict[str, Any], + logo_loader, + logger_inst=None, + ): + self.width = display_width + self.height = display_height + self.config = config + self.logo_loader = logo_loader + self.logger = logger_inst or logger + + self.plugin_dir = Path(__file__).parent + self.flags_dir = self.plugin_dir / "assets" / "masters" / "flags" + + if self.width <= 32: + self.tier = "tiny" + elif self.width <= 64: + self.tier = "small" + else: + self.tier = "large" + + self._configure_tier() + self._load_fonts() + + self._flag_cache: Dict[str, Image.Image] = {} + + def _configure_tier(self): + """Configure display parameters by size tier with generous spacing.""" + if self.tier == "tiny": # 32x16 + self.max_players = 2 + self.name_len = 8 + self.row_height = 7 + self.header_height = 7 + self.logo_size = 0 + self.show_pos_badge = False + self.show_thru = False + self.show_country = False + self.show_headshot = False + self.headshot_size = 0 + self.row_gap = 0 + self.footer_height = 0 + elif self.tier == "small": # 64x32 + self.max_players = 3 # Was 4 - breathe + self.name_len = 10 + self.row_height = 7 + self.header_height = 8 + self.logo_size = 10 + self.show_pos_badge = True + self.show_thru = True + self.show_country = False + self.show_headshot = False + self.headshot_size = 0 + self.row_gap = 1 # 1px gap between rows + self.footer_height = 5 # Page dots + else: # 128x64 + self.max_players = 5 # Was 7 - much more readable + self.name_len = 14 + self.row_height = 9 # Was 7 - more vertical space + self.header_height = 11 + self.logo_size = 18 + self.show_pos_badge = True + self.show_thru = True + self.show_country = True + self.show_headshot = True + self.headshot_size = 28 # Larger to fill the border box + self.row_gap = 1 # 1px gap between rows + self.footer_height = 6 # Page dots + + def _load_fonts(self): + if self.tier == "tiny": + self.font_header = _load_font("tiny") + self.font_body = _load_font("tiny") + self.font_score = _load_font("tiny") + self.font_detail = _load_font("tiny") + elif self.tier == "small": + self.font_header = _load_font("small") + self.font_body = _load_font("small") + self.font_score = _load_font("small") + self.font_detail = _load_font("tiny") + else: + self.font_header = _load_font("medium") + self.font_body = _load_font("small") + self.font_score = _load_font("medium") + self.font_detail = _load_font("small") + + # ═══════════════════════════════════════════════════════════ + # DRAWING HELPERS + # ═══════════════════════════════════════════════════════════ + + def _text_shadow(self, draw, pos, text, font, fill, offset=(1, 1)): + x, y = pos + draw.text((x + offset[0], y + offset[1]), text, font=font, fill=COLORS["shadow"]) + draw.text((x, y), text, font=font, fill=fill) + + def _text_width(self, draw, text, font) -> int: + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0] + + def _text_height(self, draw, text, font) -> int: + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[3] - bbox[1] + + def _draw_gradient_bg(self, c1, c2, vertical=True) -> Image.Image: + img = Image.new("RGB", (self.width, self.height)) + draw = ImageDraw.Draw(img) + steps = self.height if vertical else self.width + for i in range(steps): + ratio = i / max(steps - 1, 1) + r = int(c1[0] + (c2[0] - c1[0]) * ratio) + g = int(c1[1] + (c2[1] - c1[1]) * ratio) + b = int(c1[2] + (c2[2] - c1[2]) * ratio) + if vertical: + draw.line([(0, i), (self.width, i)], fill=(r, g, b)) + else: + draw.line([(i, 0), (i, self.height)], fill=(r, g, b)) + return img + + def _draw_header_bar(self, img, draw, title, show_logo=True): + h = self.header_height + draw.rectangle([(0, 0), (self.width - 1, h - 1)], fill=COLORS["masters_green"]) + draw.line([(0, h - 1), (self.width, h - 1)], fill=COLORS["masters_yellow"]) + + x_text = 2 + if show_logo and self.logo_size > 0: + logo_img = self.logo_loader.get_masters_logo( + max_width=self.logo_size, max_height=h - 2 + ) + if logo_img: + img.paste(logo_img, (1, 1), logo_img if logo_img.mode == "RGBA" else None) + x_text = self.logo_size + 3 + + self._text_shadow(draw, (x_text, 1), title, self.font_header, COLORS["white"]) + + def _draw_page_dots(self, draw, current_page: int, total_pages: int): + """Draw pagination dots at bottom of display.""" + if total_pages <= 1 or self.footer_height == 0: + return + + dot_r = 1 if self.tier == "small" else 2 + dot_spacing = dot_r * 4 + total_w = total_pages * dot_spacing + start_x = (self.width - total_w) // 2 + dot_y = self.height - self.footer_height // 2 + + for i in range(total_pages): + x = start_x + i * dot_spacing + dot_r + color = COLORS["page_dot_on"] if i == current_page else COLORS["page_dot_off"] + draw.ellipse([x - dot_r, dot_y - dot_r, x + dot_r, dot_y + dot_r], fill=color) + + def _get_flag(self, country_code: str) -> Optional[Image.Image]: + if country_code in self._flag_cache: + return self._flag_cache[country_code] + flag_path = self.flags_dir / f"{country_code}.png" + if flag_path.exists(): + try: + flag = Image.open(flag_path).convert("RGBA") + flag.thumbnail((10, 7), Image.Resampling.NEAREST) + self._flag_cache[country_code] = flag + return flag + except Exception: + pass + return None + + def _score_color(self, score, position=None) -> Tuple[int, int, int]: + if position == 1: + return COLORS["masters_yellow"] + if score < 0: + return COLORS["under_par"] + elif score > 0: + return COLORS["over_par"] + return COLORS["even_par"] + + # ═══════════════════════════════════════════════════════════ + # LEADERBOARD - Paginated + # ═══════════════════════════════════════════════════════════ + + def render_leaderboard( + self, leaderboard_data: List[Dict], show_favorites: bool = True, + page: int = 0, + ) -> Optional[Image.Image]: + """Render paginated broadcast-style leaderboard.""" + if not leaderboard_data: + return None + + total_pages = max(1, (len(leaderboard_data) + self.max_players - 1) // self.max_players) + page = page % total_pages + + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) + draw = ImageDraw.Draw(img) + + self._draw_header_bar(img, draw, "LEADERBOARD") + + y = self.header_height + 2 + start = page * self.max_players + players = leaderboard_data[start : start + self.max_players] + + for i, player in enumerate(players): + if i % 2 == 0: + draw.rectangle([(0, y), (self.width - 1, y + self.row_height - 1)], + fill=COLORS["row_alt"]) + + self._draw_leaderboard_row(img, draw, player, y, i, show_favorites) + y += self.row_height + self.row_gap + + # Page indicator + self._draw_page_dots(draw, page, total_pages) + + return img + + def _draw_leaderboard_row(self, img, draw, player, y, index, show_favorites): + pos_text = str(player.get("position", "")) + name = format_player_name(player.get("player", "?"), self.name_len) + score = player.get("score", 0) + score_text = format_score_to_par(score) + position = player.get("position", 99) + is_leader = (isinstance(position, int) and position == 1) or pos_text == "1" + + # Vertically center text in row + text_y = y + (self.row_height - self._text_height(draw, "A", self.font_body)) // 2 + x = 1 + + # Position badge + if self.show_pos_badge and self.tier != "tiny": + badge_w = 10 if self.tier == "large" else 8 + badge_color = COLORS["masters_yellow"] if is_leader else COLORS["masters_dark"] + text_color = COLORS["bg"] if is_leader else COLORS["white"] + draw.rectangle([(x, y), (x + badge_w, y + self.row_height - 1)], fill=badge_color) + tw = self._text_width(draw, pos_text, self.font_body) + draw.text((x + (badge_w - tw) // 2 + 1, text_y), + pos_text, fill=text_color, font=self.font_body) + x += badge_w + 3 + else: + draw.text((x, text_y), pos_text, fill=COLORS["masters_yellow"], font=self.font_body) + x += max(8, self._text_width(draw, "T99", self.font_body) + 2) + + # Country flag + if self.show_country: + country = player.get("country", "") + flag = self._get_flag(country) + if flag: + flag_y = y + (self.row_height - flag.height) // 2 + img.paste(flag, (x, flag_y), flag) + x += flag.width + 2 + + # Player name + is_fav = show_favorites and self._is_favorite(player) + if is_fav: + name_color = COLORS["azalea_pink"] + elif is_leader: + name_color = COLORS["masters_yellow"] + else: + name_color = COLORS["white"] + + draw.text((x, text_y), name, fill=name_color, font=self.font_body) + + # Score and thru (right-aligned, non-overlapping) + right_x = self.width - 2 + + if self.show_thru: + thru = str(player.get("thru", "")) + if thru: + thru_w = self._text_width(draw, thru, self.font_detail) + draw.text((right_x - thru_w, text_y + 1), thru, + fill=COLORS["gray"], font=self.font_detail) + right_x -= thru_w + 4 + + score_w = self._text_width(draw, score_text, self.font_body) + draw.text((right_x - score_w, text_y), score_text, + fill=self._score_color(score, position if isinstance(position, int) else 99), + font=self.font_body) + + # ═══════════════════════════════════════════════════════════ + # PLAYER CARD - Spacious layout + # ═══════════════════════════════════════════════════════════ + + def render_player_card(self, player: Dict) -> Optional[Image.Image]: + """Render spacious player card with headshot and stats.""" + if not player: + return None + + img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) + draw = ImageDraw.Draw(img) + + # Gold border + draw.rectangle([(0, 0), (self.width - 1, self.height - 1)], + outline=COLORS["masters_yellow"]) + + x = 4 + y = 4 + + # Headshot on left + if self.show_headshot: + headshot = self.logo_loader.get_player_headshot( + player.get("player_id", ""), + player.get("headshot_url"), + max_size=self.headshot_size, + ) + if headshot: + draw.rectangle( + [x - 1, y - 1, x + self.headshot_size, y + self.headshot_size], + outline=COLORS["masters_yellow"], + ) + img.paste(headshot, (x, y), + headshot if headshot.mode == "RGBA" else None) + + # Text area to the right of headshot + tx = x + self.headshot_size + 6 if self.show_headshot else x + + # Player name - larger, with room to breathe + name = player.get("player", "Unknown") + if self.tier == "tiny": + name = format_player_name(name, 10) + elif self.tier == "small": + name = format_player_name(name, 12) + + self._text_shadow(draw, (tx, y), name, self.font_header, COLORS["white"]) + y_text = y + self._text_height(draw, name, self.font_header) + 3 + + # Country flag + code + country = player.get("country", "") + if country and self.tier != "tiny": + flag = self._get_flag(country) + fx = tx + if flag: + img.paste(flag, (fx, y_text), flag) + fx += flag.width + 3 + draw.text((fx, y_text), country, fill=COLORS["light_gray"], font=self.font_detail) + y_text += 10 + + # Score - big and prominent with spacing + score = player.get("score", 0) + score_text = format_score_to_par(score) + + if self.tier == "large": + self._text_shadow(draw, (tx, y_text), score_text, + self.font_score, self._score_color(score)) + y_text += self._text_height(draw, score_text, self.font_score) + 4 + else: + draw.text((tx, y_text), score_text, + fill=self._score_color(score), font=self.font_body) + y_text += 9 + + # Position and thru - spread across with spacing + pos = player.get("position", "") + thru = player.get("thru", "") + if pos: + draw.text((tx, y_text), f"Pos: {pos}", + fill=COLORS["masters_yellow"], font=self.font_detail) + if thru and self.tier != "tiny": + pos_w = self._text_width(draw, f"Pos: {pos}", self.font_detail) + draw.text((tx + pos_w + 8, y_text), f"Thru: {thru}", + fill=COLORS["white"], font=self.font_detail) + y_text += 9 + + # Green jacket count at bottom + jacket_count = MULTIPLE_WINNERS.get(player.get("player", ""), 0) + if jacket_count > 0 and self.tier != "tiny": + jy = self.height - 10 + jacket_icon = self.logo_loader.get_green_jacket_icon(size=8) + jx = 4 + if jacket_icon: + img.paste(jacket_icon, (jx, jy), + jacket_icon if jacket_icon.mode == "RGBA" else None) + jx += 10 + draw.text((jx, jy), f"x{jacket_count} Green Jackets", + fill=COLORS["masters_yellow"], font=self.font_detail) + + return img + + # ═══════════════════════════════════════════════════════════ + # HOLE CARD - Clean layout + # ═══════════════════════════════════════════════════════════ + + def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: + hole_info = get_hole_info(hole_number) + + img = self._draw_gradient_bg((15, 80, 30), COLORS["augusta_green"]) + draw = ImageDraw.Draw(img) + + # Header + h = self.header_height + draw.rectangle([(0, 0), (self.width - 1, h - 1)], fill=COLORS["masters_green"]) + draw.line([(0, h - 1), (self.width, h - 1)], fill=COLORS["masters_yellow"]) + + hole_text = f"HOLE {hole_number}" + self._text_shadow(draw, (3, 1), hole_text, self.font_header, COLORS["white"]) + + if self.tier != "tiny": + name_text = hole_info["name"] + name_w = self._text_width(draw, name_text, self.font_detail) + draw.text((self.width - name_w - 3, 2), name_text, + fill=COLORS["masters_yellow"], font=self.font_detail) + + # Hole layout image (clamp to min 1px for tiny displays) + hole_img = self.logo_loader.get_hole_image( + hole_number, + max_width=max(1, self.width - 8), + max_height=max(1, self.height - h - 14), + ) + if hole_img: + hx = (self.width - hole_img.width) // 2 + hy = h + 2 + img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) + + # Footer + footer_y = self.height - 9 + draw.rectangle([(0, footer_y), (self.width - 1, self.height - 1)], fill=(0, 0, 0)) + info_text = f"Par {hole_info['par']} {hole_info['yardage']}y" + self._text_shadow(draw, (3, footer_y + 1), info_text, + self.font_detail, COLORS["white"]) + + zone = hole_info.get("zone") + if zone and self.tier != "tiny": + badge_text = zone.upper() + bw = self._text_width(draw, badge_text, self.font_detail) + 4 + draw.rectangle([(self.width - bw - 2, footer_y), + (self.width - 2, self.height - 1)], + fill=COLORS["masters_dark"]) + draw.text((self.width - bw, footer_y + 1), badge_text, + fill=COLORS["masters_yellow"], font=self.font_detail) + + return img + + # ═══════════════════════════════════════════════════════════ + # AMEN CORNER - Spacious + # ═══════════════════════════════════════════════════════════ + + def render_amen_corner(self, scoring_data: Optional[Dict] = None) -> Optional[Image.Image]: + img = self._draw_gradient_bg((5, 50, 25), COLORS["augusta_green"]) + draw = ImageDraw.Draw(img) + + # Header + h = self.header_height + 2 + draw.rectangle([(0, 0), (self.width - 1, h - 1)], fill=COLORS["masters_green"]) + draw.line([(0, 0), (self.width, 0)], fill=COLORS["masters_yellow"]) + draw.line([(0, h - 1), (self.width, h - 1)], fill=COLORS["masters_yellow"]) + + title = "AMEN CORNER" + tw = self._text_width(draw, title, self.font_header) + self._text_shadow(draw, ((self.width - tw) // 2, 2), title, + self.font_header, COLORS["masters_yellow"]) + + # Content area + content_h = self.height - h - 4 + hole_h = content_h // 3 # Equal space for each hole + + y = h + 3 + for hole_num in [11, 12, 13]: + info = AUGUSTA_HOLES[hole_num] + text_y = y + (hole_h - self._text_height(draw, "A", self.font_body)) // 2 + + if self.tier == "tiny": + text = f"#{hole_num} P{info['par']} {info['yardage']}y" + draw.text((2, text_y), text, fill=COLORS["white"], font=self.font_body) + else: + # Gold number circle + cx, cy = 10, y + hole_h // 2 + r = 5 + draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=COLORS["masters_yellow"]) + num_text = str(hole_num) + ntw = self._text_width(draw, num_text, self.font_detail) + draw.text((cx - ntw // 2, cy - 3), num_text, + fill=COLORS["bg"], font=self.font_detail) + + # Name + draw.text((20, text_y), info['name'], + fill=COLORS["white"], font=self.font_body) + + # Par and yardage right-aligned + par_text = f"Par {info['par']} {info['yardage']}y" + ptw = self._text_width(draw, par_text, self.font_detail) + draw.text((self.width - ptw - 4, text_y + 1), par_text, + fill=COLORS["light_gray"], font=self.font_detail) + + y += hole_h + + return img + + # ═══════════════════════════════════════════════════════════ + # PAST CHAMPIONS - Paginated + # ═══════════════════════════════════════════════════════════ + + def render_past_champions(self, page: int = 0) -> Optional[Image.Image]: + img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) + draw = ImageDraw.Draw(img) + + self._draw_header_bar(img, draw, "CHAMPIONS", show_logo=False) + + # Green jacket icon in header + jacket = self.logo_loader.get_green_jacket_icon(size=self.header_height - 2) + if jacket and self.tier != "tiny": + jx = self.width - jacket.width - 2 + img.paste(jacket, (jx, 1), jacket if jacket.mode == "RGBA" else None) + + content_top = self.header_height + 2 + content_bottom = self.height - self.footer_height - 1 + usable_h = content_bottom - content_top + + row_h = self.row_height + self.row_gap + 1 # Extra spacing + max_rows = max(1, usable_h // row_h) + + total_pages = max(1, (len(PAST_CHAMPIONS) + max_rows - 1) // max_rows) + page = page % total_pages + + start = page * max_rows + champs = PAST_CHAMPIONS[start : start + max_rows] + + y = content_top + for i, (year, name, country, score) in enumerate(champs): + if i % 2 == 0: + draw.rectangle([(0, y), (self.width - 1, y + self.row_height - 1)], + fill=COLORS["row_alt"]) + + text_y = y + (self.row_height - self._text_height(draw, "A", self.font_body)) // 2 + + # Year in yellow + draw.text((3, text_y), str(year), + fill=COLORS["masters_yellow"], font=self.font_body) + + # Name + disp_name = format_player_name(name, self.name_len - 2) + draw.text((26, text_y), disp_name, fill=COLORS["white"], font=self.font_body) + + # Score right-aligned + score_text = format_score_to_par(score) + sw = self._text_width(draw, score_text, self.font_body) + draw.text((self.width - sw - 3, text_y), score_text, + fill=self._score_color(score), font=self.font_body) + + y += row_h + + self._draw_page_dots(draw, page, total_pages) + return img + + # ═══════════════════════════════════════════════════════════ + # FUN FACTS - Scrolling text + # ═══════════════════════════════════════════════════════════ + + def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0) -> Optional[Image.Image]: + """Render a fun fact with vertical scroll support for long text.""" + if fact_index < 0: + fact = get_random_fun_fact() + else: + fact = get_fun_fact_by_index(fact_index) + + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) + draw = ImageDraw.Draw(img) + + # Header + h = self.header_height + draw.rectangle([(0, 0), (self.width - 1, h - 1)], fill=COLORS["masters_green"]) + draw.line([(0, h - 1), (self.width, h - 1)], fill=COLORS["masters_yellow"]) + + title = "DID YOU KNOW?" + self._text_shadow(draw, (3, 1), title, self.font_header, COLORS["masters_yellow"]) + + # Word-wrap the fact text with generous padding + content_top = h + 4 + font = self.font_detail + line_h = self._text_height(draw, "Ag", font) + 2 # Extra line spacing + max_w = self.width - 10 # More horizontal padding + + words = fact.split() + lines = [] + current_line = "" + for word in words: + test = f"{current_line} {word}".strip() + if self._text_width(draw, test, font) <= max_w: + current_line = test + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + + # Apply scroll offset (for long facts) + visible_lines = max(1, (self.height - content_top - 4) // line_h) + if len(lines) > visible_lines: + start_line = scroll_offset % max(1, len(lines) - visible_lines + 1) + lines = lines[start_line : start_line + visible_lines] + + # Draw lines centered with spacing + y = content_top + for line in lines: + draw.text((5, y), line, fill=COLORS["white"], font=font) + y += line_h + + # Scroll indicator if text is long + if len(words) > visible_lines * 4: # Rough heuristic + # Small down arrow + ax = self.width - 6 + ay = self.height - 6 + draw.polygon([(ax - 2, ay - 2), (ax + 2, ay - 2), (ax, ay + 1)], + fill=COLORS["masters_yellow"]) + + return img + + # ═══════════════════════════════════════════════════════════ + # TOURNAMENT STATS - Paginated (2 pages) + # ═══════════════════════════════════════════════════════════ + + def render_tournament_stats(self, page: int = 0) -> Optional[Image.Image]: + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) + draw = ImageDraw.Draw(img) + + self._draw_header_bar(img, draw, "RECORDS", show_logo=False) + + content_top = self.header_height + 3 + font = self.font_detail + line_h = self._text_height(draw, "A", font) + 3 # Generous spacing + + all_records = [ + ("Lowest 72", f"{TOURNAMENT_RECORDS['lowest_72']['total']} - D. Johnson, 2020"), + ("Low Round", "63 - Nick Price, 1986"), + ("Most Wins", "6 - Jack Nicklaus"), + ("Youngest W", "21 - Tiger Woods, 1997"), + ("Oldest W", "46 - Jack Nicklaus, 1986"), + ("Biggest W", "12 strokes - Tiger, '97"), + ("First", "1934 - Horton Smith"), + ] + + visible = max(1, (self.height - content_top - self.footer_height - 2) // line_h) + total_pages = max(1, (len(all_records) + visible - 1) // visible) + page = page % total_pages + + start = page * visible + records = all_records[start : start + visible] + + y = content_top + for label, value in records: + # Label in yellow + draw.text((3, y), label, fill=COLORS["masters_yellow"], font=font) + y += line_h - 1 + + # Value indented in white + draw.text((6, y), value, fill=COLORS["white"], font=font) + y += line_h + 1 + + self._draw_page_dots(draw, page, total_pages) + return img + + # ═══════════════════════════════════════════════════════════ + # SCHEDULE - Paginated + # ═══════════════════════════════════════════════════════════ + + def render_schedule(self, schedule_data: List[Dict], page: int = 0) -> Optional[Image.Image]: + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) + draw = ImageDraw.Draw(img) + + self._draw_header_bar(img, draw, "TEE TIMES") + + if not schedule_data: + y = self.header_height + 8 + draw.text((3, y), "No tee times", fill=COLORS["gray"], font=self.font_body) + return img + + content_top = self.header_height + 2 + # Each tee time gets 2 lines: time + players + entry_h = (self.row_height + self.row_gap) * 2 + 2 + visible = max(1, (self.height - content_top - self.footer_height - 2) // entry_h) + + total_pages = max(1, (len(schedule_data) + visible - 1) // visible) + page = page % total_pages + + start = page * visible + entries = schedule_data[start : start + visible] + + y = content_top + for i, entry in enumerate(entries): + # Time in yellow + time_text = entry.get("time", "") + draw.text((3, y), time_text, fill=COLORS["masters_yellow"], font=self.font_body) + y += self.row_height + 1 + + # Players indented + players = entry.get("players", []) + players_text = ", ".join(format_player_name(p, 10) for p in players[:3]) + draw.text((6, y), players_text, fill=COLORS["white"], font=self.font_detail) + y += self.row_height + 3 + + self._draw_page_dots(draw, page, total_pages) + return img + + # ═══════════════════════════════════════════════════════════ + # COUNTDOWN - Centered and spacious + # ═══════════════════════════════════════════════════════════ + + def render_countdown(self, days: int, hours: int, minutes: int) -> Optional[Image.Image]: + img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) + draw = ImageDraw.Draw(img) + + # Masters logo centered at top + logo = self.logo_loader.get_masters_logo( + max_width=min(self.width - 10, 48), + max_height=min(self.height // 3, 20), + ) + if logo: + lx = (self.width - logo.width) // 2 + img.paste(logo, (lx, 3), logo if logo.mode == "RGBA" else None) + + # Countdown number - big and centered + if days > 0: + count_text = str(days) + unit_text = "DAYS" if days > 1 else "DAY" + elif hours > 0: + count_text = f"{hours}:{minutes:02d}" + unit_text = "HOURS" + else: + count_text = "NOW" + unit_text = "" + + mid_y = self.height // 2 + + # "UNTIL THE MASTERS" centered + until_text = "UNTIL THE MASTERS" if self.tier != "tiny" else "TO MASTERS" + uw = self._text_width(draw, until_text, self.font_detail) + draw.text(((self.width - uw) // 2, mid_y - 6), + until_text, fill=COLORS["white"], font=self.font_detail) + + # Big countdown number + cw = self._text_width(draw, count_text, self.font_score) + self._text_shadow(draw, ((self.width - cw) // 2, mid_y + 4), + count_text, self.font_score, COLORS["masters_yellow"]) + + # Unit below + if unit_text: + uw2 = self._text_width(draw, unit_text, self.font_detail) + draw.text(((self.width - uw2) // 2, mid_y + 16), + unit_text, fill=COLORS["light_gray"], font=self.font_detail) + + return img + + # ═══════════════════════════════════════════════════════════ + # FIELD OVERVIEW - Spacious stats + # ═══════════════════════════════════════════════════════════ + + def render_field_overview(self, leaderboard_data: List[Dict]) -> Optional[Image.Image]: + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) + draw = ImageDraw.Draw(img) + + self._draw_header_bar(img, draw, "THE FIELD") + + total = len(leaderboard_data) + under = sum(1 for p in leaderboard_data if p.get("score", 0) < 0) + over = sum(1 for p in leaderboard_data if p.get("score", 0) > 0) + even = total - under - over + + y = self.header_height + 4 + line_h = 10 if self.tier == "large" else 8 + + draw.text((4, y), f"Players: {total}", fill=COLORS["white"], font=self.font_body) + y += line_h + 2 + + draw.text((4, y), f"Under par: {under}", fill=COLORS["under_par"], font=self.font_detail) + y += line_h + draw.text((4, y), f"Even par: {even}", fill=COLORS["even_par"], font=self.font_detail) + y += line_h + draw.text((4, y), f"Over par: {over}", fill=COLORS["over_par"], font=self.font_detail) + y += line_h + 3 + + # Leader highlight + if leaderboard_data: + draw.line([(3, y), (self.width - 3, y)], fill=COLORS["masters_yellow"]) + y += 4 + + leader = leaderboard_data[0] + leader_name = format_player_name(leader.get("player", ""), self.name_len) + leader_score = format_score_to_par(leader.get("score", 0)) + + draw.text((4, y), "Leader", fill=COLORS["masters_yellow"], font=self.font_detail) + y += line_h + + self._text_shadow(draw, (4, y), f"{leader_name} {leader_score}", + self.font_body, COLORS["white"]) + + return img + + # ═══════════════════════════════════════════════════════════ + # UTILITIES + # ═══════════════════════════════════════════════════════════ + + def _is_favorite(self, player: Dict) -> bool: + favorites = self.config.get("favorite_players", []) + player_name = player.get("player", "") + return any(fav.lower() in player_name.lower() for fav in favorites) + + def _format_score(self, score: int) -> str: + return format_score_to_par(score) + + def _get_hole_info(self, hole_number: int) -> Dict[str, Any]: + return get_hole_info(hole_number) diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py new file mode 100644 index 0000000..8c55791 --- /dev/null +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -0,0 +1,338 @@ +""" +Enhanced Masters Tournament Renderer + +Extends MastersRenderer with additional visual polish: +- Texture overlay backgrounds +- Enhanced player cards with round-by-round scores +- Course overview with pagination +- Live scoring alerts +- All methods support pagination/scrolling from base class +""" + +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +from PIL import Image, ImageDraw + +from masters_helpers import ( + AUGUSTA_HOLES, + AUGUSTA_PAR, + MULTIPLE_WINNERS, + PAST_CHAMPIONS, + format_player_name, + format_score_to_par, + get_hole_info, + get_score_description, +) +from masters_renderer import COLORS, MastersRenderer + +logger = logging.getLogger(__name__) + + +class MastersRendererEnhanced(MastersRenderer): + """Enhanced renderer with texture backgrounds, extended player cards, and more.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.backgrounds_dir = self.plugin_dir / "assets" / "masters" / "backgrounds" + + def _get_textured_bg(self) -> Image.Image: + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) + texture_path = self.backgrounds_dir / "augusta_green_texture.png" + if texture_path.exists() and self.tier != "tiny": + try: + texture = Image.open(texture_path).convert("RGBA") + texture = texture.resize((self.width, self.height), Image.Resampling.NEAREST) + img = Image.blend(img.convert("RGBA"), texture, 0.15).convert("RGB") + except Exception: + pass + return img + + def render_leaderboard( + self, leaderboard_data: List[Dict], show_favorites: bool = True, + page: int = 0, + ) -> Optional[Image.Image]: + """Enhanced leaderboard with texture background + pagination.""" + if not leaderboard_data: + return None + + total_pages = max(1, (len(leaderboard_data) + self.max_players - 1) // self.max_players) + page = page % total_pages + + img = self._get_textured_bg() + draw = ImageDraw.Draw(img) + + self._draw_header_bar(img, draw, "LEADERBOARD") + + y = self.header_height + 2 + start = page * self.max_players + players = leaderboard_data[start : start + self.max_players] + + for i, player in enumerate(players): + if i % 2 == 0: + draw.rectangle( + [(0, y), (self.width - 1, y + self.row_height - 1)], + fill=COLORS["row_alt"], + ) + self._draw_leaderboard_row(img, draw, player, y, i, show_favorites) + y += self.row_height + self.row_gap + + self._draw_page_dots(draw, page, total_pages) + return img + + def render_player_card(self, player: Dict) -> Optional[Image.Image]: + """Enhanced player card with round scores and green jacket info.""" + if not player: + return None + + img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) + draw = ImageDraw.Draw(img) + + # Gold border + draw.rectangle( + [(0, 0), (self.width - 1, self.height - 1)], + outline=COLORS["masters_yellow"], + ) + + x = 4 + y = 4 + + # Headshot on the left + headshot_drawn = False + if self.show_headshot: + headshot = self.logo_loader.get_player_headshot( + player.get("player_id", ""), + player.get("headshot_url"), + max_size=self.headshot_size, + ) + if headshot: + draw.rectangle( + [x - 1, y - 1, x + self.headshot_size, y + self.headshot_size], + outline=COLORS["masters_yellow"], + ) + img.paste( + headshot, (x, y), + headshot if headshot.mode == "RGBA" else None, + ) + headshot_drawn = True + + tx = x + self.headshot_size + 6 if headshot_drawn else x + + # Player name + name = player.get("player", "Unknown") + display_name = format_player_name(name, self.name_len) + self._text_shadow(draw, (tx, y), display_name, self.font_header, COLORS["white"]) + y_text = y + self._text_height(draw, display_name, self.font_header) + 3 + + # Country + country = player.get("country", "") + if country and self.tier != "tiny": + flag = self._get_flag(country) + fx = tx + if flag: + img.paste(flag, (fx, y_text), flag) + fx += flag.width + 3 + draw.text((fx, y_text), country, fill=COLORS["light_gray"], font=self.font_detail) + y_text += 10 + + # Score - big + score = player.get("score", 0) + score_text = format_score_to_par(score) + self._text_shadow(draw, (tx, y_text), score_text, + self.font_score, self._score_color(score)) + y_text += self._text_height(draw, score_text, self.font_score) + 3 + + # Position and thru + pos = player.get("position", "") + thru = player.get("thru", "") + status_parts = [] + if pos: + status_parts.append(f"Pos:{pos}") + if thru: + status_parts.append(f"Thru:{thru}") + if status_parts: + draw.text((tx, y_text), " ".join(status_parts), + fill=COLORS["light_gray"], font=self.font_detail) + y_text += 10 + + # Round scores (if room) + rounds = player.get("rounds", [None, None, None, None]) + if any(r is not None for r in rounds) and self.tier == "large": + draw.line([(tx, y_text), (self.width - 6, y_text)], + fill=COLORS["masters_yellow"]) + y_text += 3 + + rx = tx + for i, r in enumerate(rounds): + if r is not None: + r_label = f"R{i+1}:" + draw.text((rx, y_text), r_label, + fill=COLORS["gray"], font=self.font_detail) + lw = self._text_width(draw, r_label, self.font_detail) + r_color = COLORS["under_par"] if r < AUGUSTA_PAR else COLORS["over_par"] if r > AUGUSTA_PAR else COLORS["even_par"] + draw.text((rx + lw + 1, y_text), str(r), + fill=r_color, font=self.font_detail) + rx += lw + self._text_width(draw, str(r), self.font_detail) + 6 + + # Green jacket count at bottom + jacket_count = MULTIPLE_WINNERS.get(player.get("player", ""), 0) + if jacket_count > 0 and self.tier != "tiny": + jy = self.height - 10 + jacket = self.logo_loader.get_green_jacket_icon(size=8) + jx = 4 + if jacket: + img.paste(jacket, (jx, jy), jacket if jacket.mode == "RGBA" else None) + jx += 10 + draw.text((jx, jy), f"x{jacket_count} Green Jackets", + fill=COLORS["masters_yellow"], font=self.font_detail) + + return img + + def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: + """Enhanced hole card.""" + hole_info = get_hole_info(hole_number) + + img = self._draw_gradient_bg((10, 70, 25), COLORS["augusta_green"]) + draw = ImageDraw.Draw(img) + + # Header + h = self.header_height + draw.rectangle([(0, 0), (self.width - 1, h - 1)], fill=COLORS["masters_green"]) + draw.line([(0, h - 1), (self.width, h - 1)], fill=COLORS["masters_yellow"]) + + hole_text = f"#{hole_number}" + self._text_shadow(draw, (3, 1), hole_text, self.font_header, COLORS["white"]) + + name_text = hole_info["name"] + nw = self._text_width(draw, name_text, self.font_body) + draw.text((self.width - nw - 3, 1), name_text, + fill=COLORS["masters_yellow"], font=self.font_body) + + # Hole layout image + hole_img = self.logo_loader.get_hole_image( + hole_number, + max_width=self.width - 6, + max_height=self.height - h - 14, + ) + if hole_img: + hx = (self.width - hole_img.width) // 2 + hy = h + 1 + img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) + + # Footer bar + fy = self.height - 10 + draw.rectangle([(0, fy), (self.width - 1, self.height - 1)], fill=(0, 0, 0)) + draw.line([(0, fy), (self.width, fy)], fill=COLORS["masters_yellow"]) + + info_text = f"Par {hole_info['par']} {hole_info['yardage']} yards" + iw = self._text_width(draw, info_text, self.font_detail) + draw.text(((self.width - iw) // 2, fy + 2), info_text, + fill=COLORS["white"], font=self.font_detail) + + zone = hole_info.get("zone") + if zone and self.tier != "tiny": + badge = zone.upper() + bw = self._text_width(draw, badge, self.font_detail) + 4 + draw.rectangle( + [(self.width - bw - 1, fy + 1), (self.width - 2, self.height - 2)], + fill=COLORS["masters_dark"], + ) + draw.text((self.width - bw + 1, fy + 2), badge, + fill=COLORS["masters_yellow"], font=self.font_detail) + + return img + + def render_live_alert( + self, player_name: str, hole: int, score_desc: str + ) -> Optional[Image.Image]: + """Render a live scoring alert with generous spacing.""" + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) + draw = ImageDraw.Draw(img) + + is_great = score_desc.lower() in ("eagle", "albatross", "hole in one") + header_color = COLORS["gold"] if is_great else COLORS["masters_green"] + draw.rectangle([(0, 0), (self.width - 1, self.header_height - 1)], fill=header_color) + draw.line([(0, self.header_height - 1), (self.width, self.header_height - 1)], + fill=COLORS["masters_yellow"]) + + self._text_shadow(draw, (3, 1), "LIVE", self.font_header, + COLORS["white"] if not is_great else COLORS["bg"]) + + y = self.header_height + 6 + + # Player name with room + name = format_player_name(player_name, self.name_len) + self._text_shadow(draw, (4, y), name, self.font_body, COLORS["white"]) + y += self._text_height(draw, name, self.font_body) + 6 + + # Score type - big and centered + desc_upper = score_desc.upper() + "!" + desc_color = COLORS["masters_yellow"] if is_great else COLORS["under_par"] + dw = self._text_width(draw, desc_upper, self.font_score) + self._text_shadow(draw, ((self.width - dw) // 2, y), + desc_upper, self.font_score, desc_color) + y += self._text_height(draw, desc_upper, self.font_score) + 6 + + # Hole info + if 1 <= hole <= 18: + hole_info = get_hole_info(hole) + hole_text = f"Hole {hole} - {hole_info['name']}" + htw = self._text_width(draw, hole_text, self.font_detail) + draw.text(((self.width - htw) // 2, y), hole_text, + fill=COLORS["light_gray"], font=self.font_detail) + + return img + + def render_course_overview(self, page: int = 0) -> Optional[Image.Image]: + """Render Augusta National overview - paginated front/back nine.""" + img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) + draw = ImageDraw.Draw(img) + + if page % 2 == 0: + title = "FRONT NINE" + holes = range(1, 10) + else: + title = "BACK NINE" + holes = range(10, 19) + + self._draw_header_bar(img, draw, title, show_logo=True) + + if self.tier == "tiny": + par = sum(AUGUSTA_HOLES[h]["par"] for h in holes) + y = self.header_height + 2 + draw.text((2, y), f"Par {par}", fill=COLORS["white"], font=self.font_body) + return img + + y = self.header_height + 3 + font = self.font_detail + line_h = self._text_height(draw, "A", font) + 3 + + # Show each hole with spacing + for h in holes: + info = AUGUSTA_HOLES[h] + + # Hole number in yellow + num_text = f"{h:2d}" + draw.text((3, y), num_text, fill=COLORS["masters_yellow"], font=font) + + # Hole name + name = info["name"] + if self.tier == "small": + name = name[:10] + draw.text((18, y), name, fill=COLORS["white"], font=font) + + # Par and yardage right-aligned + par_text = f"P{info['par']} {info['yardage']}y" + pw = self._text_width(draw, par_text, font) + draw.text((self.width - pw - 3, y), par_text, + fill=COLORS["light_gray"], font=font) + + y += line_h + if y > self.height - self.footer_height - 4: + break + + # Page dots (2 pages: front/back) + self._draw_page_dots(draw, page % 2, 2) + + return img diff --git a/plugins/masters-tournament/requirements.txt b/plugins/masters-tournament/requirements.txt new file mode 100644 index 0000000..a1662f0 --- /dev/null +++ b/plugins/masters-tournament/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.31.0 +Pillow>=10.2.0 +pytz>=2023.3