import os from PIL import Image, ImageDraw, ImageFont, ImageFilter from typing import Optional from models import LabReportData class VisualRenderer: def __init__(self, assets_dir: str = "assets"): self.assets_dir = assets_dir self.font_dir = os.path.join(assets_dir, "fonts") self.image_dir = os.path.join(assets_dir, "images") # Colors - Premium Futuristic Dark Mode self.bg_color = (10, 10, 11) # #0a0a0b self.accent_color = (138, 43, 226) # BlueViolet self.text_color = (255, 255, 255) self.sub_text_color = (200, 200, 200) self.muted_text_color = (150, 150, 150) self.card_bg = (20, 20, 25, 180) # semi-transparent # Initialize fonts (fallbacks) self.fonts = {} # System font paths to try on Mac/Linux font_paths = [ os.path.join(self.font_dir, "Inter-Bold.ttf"), os.path.join(self.font_dir, "Inter-Regular.ttf"), "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/Supplemental/Arial.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" ] def load_font(size, bold=False): for path in font_paths: try: # PIL can handle .ttc if you don't specify index (it takes index 0) return ImageFont.truetype(path, size) except Exception: continue return ImageFont.load_default() self.fonts['title'] = load_font(72, True) self.fonts['subtitle'] = load_font(36) self.fonts['section'] = load_font(28, True) self.fonts['body'] = load_font(22) self.fonts['small'] = load_font(18) def generate_dashboard(self, data: LabReportData) -> Image.Image: # Create canvas (1920x1080 for high resolution) width, height = 1920, 1080 img = Image.new('RGB', (width, height), color=self.bg_color) draw = ImageDraw.Draw(img, 'RGBA') # Add subtle radial gradient background or ambient glow self._draw_ambient_glow(draw, width, height) # 1. Header Section self._draw_header(draw, data, width) # 2. Left Panel: Cannabinoids self._draw_cannabinoids(draw, data.cannabinoids) # 3. Center: Visual (Flower/Placeholder) self._draw_center_visual(img, draw) # 4. Right Panel: Terpenes self._draw_terpenes(draw, data.terpenes) # 5. Bottom: Info Strip self._draw_footer(draw, data, width, height) # 6. Watermark self._draw_watermark(draw, width, height) return img def _draw_watermark(self, draw, width, height): text = "POWERED BY STRAINAI" draw.text((width - 300, height - 60), text, font=self.fonts['small'], fill=(255, 255, 255, 80)) def _draw_ambient_glow(self, draw, width, height): # Subtle purple glow in the center center_x, center_y = width // 2, height // 2 for r in range(500, 0, -10): alpha = int(30 * (1 - r/500)) draw.ellipse([center_x - r, center_y - r, center_x + r, center_y + r], fill=(138, 43, 226, alpha)) def _draw_header(self, draw, data, width): # Strain name title = data.strain_name.upper() subtitle = f"{data.strain_type or 'HYBRID'} | {data.dominance or 'BALANCED'}" # Centered Header title_bbox = draw.textbbox((0, 0), title, font=self.fonts['title']) title_w = title_bbox[2] - title_bbox[0] draw.text(((width - title_w) // 2, 80), title, font=self.fonts['title'], fill=self.text_color) subtitle_bbox = draw.textbbox((0, 0), subtitle, font=self.fonts['subtitle']) subtitle_w = subtitle_bbox[2] - subtitle_bbox[0] draw.text(((width - subtitle_w) // 2, 160), subtitle, font=self.fonts['subtitle'], fill=self.accent_color) def _draw_cannabinoids(self, draw, cannabinoids): # Left panel card x, y = 100, 250 w, h = 450, 600 self._draw_glass_card(draw, x, y, w, h, "CANNABINOID PROFILE") # Draw bars y_offset = y + 100 for i, cb in enumerate(cannabinoids[:8]): # Limit to 8 # Label draw.text((x + 30, y_offset), f"{cb.name}", font=self.fonts['section'], fill=self.text_color) draw.text((x + w - 100, y_offset), f"{cb.value}{cb.unit}", font=self.fonts['section'], fill=self.accent_color) # Progress bar background draw.rectangle([x + 30, y_offset + 40, x + w - 30, y_offset + 50], fill=(50, 50, 60, 255)) # Progress bar fill bar_w = (cb.value / 35.0) * (w - 60) # Max 35% for normalization bar_w = min(bar_w, w - 60) draw.rectangle([x + 30, y_offset + 40, x + 30 + bar_w, y_offset + 50], fill=self.accent_color) y_offset += 65 def _draw_terpenes(self, draw, terpenes): # Right panel card x, y = 1370, 250 w, h = 450, 600 self._draw_glass_card(draw, x, y, w, h, "TERPENE PROFILE") # Rank terpenes y_offset = y + 100 for i, terp in enumerate(terpenes[:8]): draw.text((x + 30, y_offset), f"{i+1}. {terp.name}", font=self.fonts['body'], fill=self.text_color) draw.text((x + w - 100, y_offset), f"{terp.value}{terp.unit}", font=self.fonts['body'], fill=self.accent_color) y_offset += 55 def _draw_center_visual(self, img, draw): # Placeholder or flower image # For now, let's just use a stylized circle/glow center_x, center_y = 1920 // 2, 1080 // 2 # Future: Load flower image, crop, add glow pass def _draw_footer(self, draw, data, width, height): # Info strip at the bottom y = height - 120 info_text = f"ORIGIN: {data.origin or 'N/A'} | LAB: {data.lab_name or 'N/A'} | BATCH: {data.batch or 'N/A'} | TEST DATE: {data.test_date or 'N/A'}" bbox = draw.textbbox((0, 0), info_text, font=self.fonts['small']) text_w = bbox[2] - bbox[0] draw.text(((width - text_w) // 2, y), info_text, font=self.fonts['small'], fill=self.muted_text_color) def _draw_glass_card(self, draw, x, y, w, h, title): # Draw rounded rectangle with semi-transparent fill draw.rounded_rectangle([x, y, x + w, y + h], radius=30, fill=self.card_bg, outline=(100, 100, 120, 100), width=2) # Draw section title draw.text((x + 30, y + 30), title, font=self.fonts['section'], fill=self.muted_text_color)