"""Outfit recommendations and ComfyUI illustration for Alfred weather view. V4 improvements: - refresh really regenerates a new image (new random seeds, cache bypass); - best-of generation with lightweight vision review; - more reliable accessory rendering by limiting visible accessories in the image; - still generates the child and adult separately, then assembles them in one image. """ from __future__ import annotations import base64 import hashlib import io import json import os import threading import time import uuid import random import secrets import logging from datetime import datetime, date as Date from collections import Counter from pathlib import Path from urllib.parse import urlencode import requests from dotenv import load_dotenv, dotenv_values # Charger l'environnement avant de calculer BASE et WORKFLOWS_DIR. # Flask exécute son load_dotenv() après l'import de ce module. load_dotenv() load_dotenv('/opt/hermes/MCP_comfyui/.env', override=False) # Fichier de réglages météo indépendant de systemd et du MCP. _SETTINGS_FILE = Path(__file__).resolve().parents[1] / 'config' / 'weather_outfits.env' if _SETTINGS_FILE.is_file(): load_dotenv(_SETTINGS_FILE, override=True) _MCP_SETTINGS = dotenv_values('/opt/hermes/MCP_comfyui/.env') from . import ha_weather CACHE_DIR = Path(os.getenv('ALFRED_CACHE_DIR', str(Path(__file__).resolve().parents[1] / 'cache'))) CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_FILE = CACHE_DIR / 'weather_outfit_cache.json' BASE = (os.getenv('ALFRED_COMFYUI_URL') or _MCP_SETTINGS.get('COMFYUI_URL') or os.getenv('COMFYUI_URL') or 'http://192.168.1.13:8188').rstrip('/') WORKFLOWS_DIR = Path(os.getenv('ALFRED_COMFYUI_WORKFLOWS_DIR') or os.getenv('WORKFLOWS_DIR') or '/opt/hermes/MCP_comfyui/workflows') PROMPT_NODE = os.getenv('COMFY_PROMPT_NODE', '6') TIMEOUT = int(os.getenv('COMFYUI_TIMEOUT', '300')) TTL_HOURS = int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS', '12')) ENABLED = os.getenv('ALFRED_WEATHER_ILLUSTRATIONS', 'true').lower() in ('1', 'true', 'yes', 'on') IMAGE_SIZE = min(1024, max(512, int(os.getenv('ALFRED_WEATHER_COMFY_SIZE', '768')))) PROMPT_VERSION = 'outfits-v5-reference-20261025' MAX_ATTEMPTS_PER_CHARACTER = max(1, min(3, int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS', '2')))) QUALITY_TARGET = float(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE', '8.6')) REVIEW_ENABLED = os.getenv('ALFRED_WEATHER_REVIEW', 'true').lower() in ('1','true','yes','on') REFERENCE_MODE = os.getenv('ALFRED_WEATHER_REFERENCE_MODE', 'auto').strip().lower() REFERENCE_DENOISE = min(.85, max(.25, float(os.getenv('ALFRED_WEATHER_REFERENCE_DENOISE', '0.56')))) PRESERVE_FACE = os.getenv('ALFRED_WEATHER_PRESERVE_FACE', 'true').lower() in ('1','true','yes','on') ACCESSORY_LIMIT = max(0, min(2, int(os.getenv('ALFRED_WEATHER_ACCESSORY_LIMIT', '1')))) REFERENCE_DIR = Path(__file__).resolve().parents[1] / 'static' / 'assets' / 'weather' UPLOADED_REFERENCES = {} # Un seul job génération météo à la fois : évite les doublons concurrents. ILLUSTRATION_LOCK = threading.RLock() IMAGE_DIR = CACHE_DIR / 'weather_outfit_images' IMAGE_DIR.mkdir(parents=True, exist_ok=True) # Deux portraits distincts, réunis dans une seule image finale. PORTRAIT_WIDTH = min(512, IMAGE_SIZE) PORTRAIT_HEIGHT = IMAGE_SIZE DISPLAY_SIZE = int(os.getenv('ALFRED_WEATHER_DISPLAY_SIZE', '350')) def _load_cache() -> dict: try: return json.loads(CACHE_FILE.read_text(encoding='utf-8')) except Exception: return {} def _save_cache(cache: dict) -> None: CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding='utf-8') def _pick_day(data: dict, forced_date: str | None = None) -> dict: target = str(forced_date or data.get('focus_date') or '').strip() try: requested = Date.fromisoformat(target) except ValueError as exc: raise ValueError('Date météo invalide : format AAAA-MM-JJ attendu.') from exc for day in data.get('daily', []): if str(day.get('date')) == target: return day matching = [h for h in data.get('hourly', []) if h.get('date') == target] if matching: def available(key): return [float(h[key]) for h in matching if h.get(key) is not None] temps = available('temperature') chances = available('precipitation_probability') winds = available('wind_speed') conditions = [str(h['condition']) for h in matching if h.get('condition')] condition = Counter(conditions).most_common(1)[0][0] if conditions else 'unknown' return { 'date': target, 'condition': condition, 'description': ha_weather.CONDITIONS.get(condition, 'conditions non précisées'), 'temperature': max(temps) if temps else None, 'templow': min(temps) if temps else None, 'precipitation_probability': max(chances) if chances else None, 'wind_speed': max(winds) if winds else None, } if requested == datetime.now(ha_weather.TZ).date(): current = data.get('current', {}) return { 'date': target, 'condition': current.get('condition', 'unknown'), 'description': current.get('description', 'conditions non précisées'), 'temperature': current.get('temperature'), 'templow': current.get('temperature'), 'precipitation_probability': None, 'wind_speed': current.get('wind_speed'), } raise ValueError(f'Aucune prévision Home Assistant disponible pour le {requested:%d/%m/%Y}. Aucune image ne sera générée avec les données d’un autre jour.') def _temp_band(high: float | None, low: float | None) -> str: ref = high if high is not None else low if ref is None: return 'mild' if ref < 6: return 'cold' if ref < 14: return 'cool' if ref < 23: return 'mild' return 'warm' def _recommend(day: dict) -> dict: high = day.get('temperature') low = day.get('templow') pop = day.get('precipitation_probability') wind = day.get('wind_speed') cond = str(day.get('condition') or '') band = _temp_band(high, low) rainy = cond in {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} or (pop is not None and pop >= 45) sunny = cond in {'sunny', 'partlycloudy'} windy = wind is not None and wind >= 25 child = [] adult = [] child_acc = [] adult_acc = [] if band == 'cold': child += ['doudoune chaude', 'pantalon chaud', 'chaussures fermées'] adult += ['manteau chaud', 'pull', 'pantalon', 'chaussures fermées'] child_acc += ['bonnet', 'écharpe'] adult_acc += ['écharpe'] elif band == 'cool': child += ['veste légère', 'pantalon', 'baskets'] adult += ['veste mi-saison', 'pull fin', 'jean', 'chaussures fermées'] elif band == 'mild': child += ['sweat ou gilet', 'pantalon léger', 'baskets'] adult += ['veste légère', 'haut confortable', 'jean ou chino', 'baskets'] else: child += ['t-shirt', 'short ou pantalon léger', 'baskets légères'] adult += ['t-shirt ou polo', 'tenue légère', 'chaussures légères'] child_acc += ['casquette'] adult_acc += ['lunettes de soleil'] if rainy: child_acc += ['imperméable', 'parapluie'] adult_acc += ['parapluie'] if 'chaussures fermées' not in child: child.append('chaussures fermées') if 'chaussures fermées' not in adult: adult.append('chaussures fermées') if windy: child_acc += ['coupe-vent'] adult_acc += ['coupe-vent'] if sunny and band != 'cold': if 'casquette' not in child_acc: child_acc += ['casquette'] if 'lunettes de soleil' not in adult_acc: adult_acc += ['lunettes de soleil'] def uniq(seq): seen=[] for x in seq: if x not in seen: seen.append(x) return seen return { 'summary': day.get('description', 'Conditions du jour'), 'child': { 'title': 'Fille · 8 ans', 'clothes': uniq(child), 'accessories': uniq(child_acc), }, 'adult': { 'title': 'Homme · 42 ans', 'clothes': uniq(adult), 'accessories': uniq(adult_acc), } } def _weather_signature(day: dict, outfits: dict) -> str: raw = json.dumps({'version': PROMPT_VERSION, 'date': day.get('date'), 'day': day, 'outfits': outfits, 'size': IMAGE_SIZE}, ensure_ascii=False, sort_keys=True) return hashlib.sha256(raw.encode('utf-8')).hexdigest() def _extract_images(history: dict) -> list[dict]: images = [] for node_id, output in history.get('outputs', {}).items(): for image in output.get('images', []): filename = image.get('filename') if not filename: continue subfolder = image.get('subfolder', '') image_type = image.get('type', 'output') query = urlencode({'filename': filename, 'subfolder': subfolder, 'type': image_type}) images.append({'filename': filename, 'url': f'{BASE}/view?{query}', 'node_id': str(node_id)}) return images NEGATIVE_PROMPT = ( 'more than one person, group photo, two people, extra person, background crowd, ' 'mirror reflection, human silhouette in background, duplicate heads, cloned body, ' 'deformed face, malformed face, mutated hands, extra limbs, extra fingers, ' 'disfigured features, asymmetrical face, photorealistic, horror, blurry, ' 'text, captions, logos, watermark, oversized accessory, floating accessory, ' 'extra umbrella, duplicated bag, disconnected hands, duplicate limbs, ' 'accessories not mentioned, extra props, toys, clothing mannequin' ) def _random_seed() -> int: return secrets.randbelow(2**32 - 1) + 1 def _prepare_workflow(workflow: dict, prompt: str, force_new_seed: bool = False, reference_name: str | None = None) -> dict: node = workflow.get(PROMPT_NODE) if not isinstance(node, dict) or not isinstance(node.get('inputs'), dict) or 'text' not in node['inputs']: raise ValueError(f'Nœud de prompt ComfyUI {PROMPT_NODE} introuvable ou incompatible.') node['inputs']['text'] = prompt for item in workflow.values(): inputs = item.get('inputs') or {} if item.get('class_type') == 'CLIPTextEncode' and 'text' in inputs: # negative prompt link target handled below when referenced by sampler. pass if force_new_seed: for key in ('seed', 'noise_seed'): if key in inputs and isinstance(inputs[key], (int, float)): inputs[key] = _random_seed() if item.get('class_type') in ('RandomNoise', 'Noise') and force_new_seed: for key in ('noise_seed', 'seed'): if key in inputs and isinstance(inputs[key], (int, float)): inputs[key] = _random_seed() for sampler in workflow.values(): if sampler.get('class_type') not in ('KSampler', 'KSamplerAdvanced'): continue inputs = sampler.get('inputs') or {} if force_new_seed: for key in ('seed', 'noise_seed'): if key in inputs and isinstance(inputs[key], (int, float)): inputs[key] = _random_seed() negative_link = inputs.get('negative') if not isinstance(negative_link, (list, tuple)) or not negative_link: continue neg = workflow.get(str(negative_link[0])) if neg and neg.get('class_type') == 'CLIPTextEncode' and 'text' in neg.get('inputs', {}): neg['inputs']['text'] = NEGATIVE_PROMPT for latent in workflow.values(): if latent.get('class_type') in ('EmptyLatentImage', 'EmptySD3LatentImage'): inputs = latent.get('inputs') or {} if 'width' in inputs and 'height' in inputs: inputs['width'] = PORTRAIT_WIDTH inputs['height'] = PORTRAIT_HEIGHT # Conversion en image-vers-image : uniquement si le workflow expose des liens # KSampler et VAEEncode compatibles. Le JSON source de Hermes est INCHANGÉ. if reference_name: _inject_reference_img2img(workflow, reference_name) return workflow def _new_node_id(workflow: dict) -> str: return str(max([int(k) for k in workflow if str(k).isdigit()] + [0]) + 1) def _inject_reference_img2img(workflow: dict, reference_name: str) -> None: # Gestion prudente des workflows courants SD 1.5 / SDXL (KSampler). sampler = next((n for n in workflow.values() if n.get('class_type') == 'KSampler' and isinstance(n.get('inputs', {}).get('latent_image'), list)), None) decoder = next((n for n in workflow.values() if n.get('class_type') == 'VAEDecode' and isinstance(n.get('inputs', {}).get('vae'), list)), None) if sampler is None or decoder is None: raise ValueError('Workflow non compatible img2img automatique (KSampler/VAEDecode requis).') vae_link = decoder['inputs']['vae'] load_id = _new_node_id(workflow) workflow[load_id] = {'class_type': 'LoadImage', 'inputs': {'image': reference_name}} vae_id = _new_node_id(workflow) workflow[vae_id] = {'class_type': 'VAEEncode', 'inputs': {'pixels': [load_id, 0], 'vae': vae_link}} sampler['inputs']['latent_image'] = [vae_id, 0] sampler['inputs']['denoise'] = REFERENCE_DENOISE def _upload_reference(path: Path) -> str: # Téléverse l’image référence vers ComfyUI, retourne son nom LoadImage. cache_key = (str(path), path.stat().st_mtime_ns) if cache_key in UPLOADED_REFERENCES: return UPLOADED_REFERENCES[cache_key] with path.open('rb') as stream: response = requests.post(f'{BASE}/upload/image', files={'image': (path.name, stream, 'image/png')}, data={'overwrite': 'true'}, timeout=30) response.raise_for_status() item = response.json() name = str(item.get('name') or '') subfolder = str(item.get('subfolder') or '') if not name or '/' in name or '\\' in name: raise RuntimeError('Réponse upload ComfyUI invalide : nom de fichier de référence absent.') result = (subfolder.strip('/') + '/' if subfolder else '') + name UPLOADED_REFERENCES[cache_key] = result return result def _submit_workflow(prompt: str, force_new_seed: bool = True, kind: str | None = None) -> tuple[str, bool]: workflow_path = WORKFLOWS_DIR / 'text_to_image.json' if not workflow_path.exists(): raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}') workflow = json.loads(workflow_path.read_text(encoding='utf-8')) reference_applied = False reference_name = None if kind and REFERENCE_MODE != 'off': reference_file = REFERENCE_DIR / f'{kind}-reference.png' if reference_file.is_file(): try: # D'abord valider la capacité img2img, avant upload. preview = _prepare_workflow(json.loads(workflow_path.read_text(encoding='utf-8')), prompt, force_new_seed=False, reference_name='check.png') del preview reference_name = _upload_reference(reference_file) reference_applied = True except (ValueError, requests.RequestException, OSError) as exc: if REFERENCE_MODE == 'required': raise RuntimeError('Référence obligatoire indisponible : ' + str(exc)) from exc print('[ALFRED METEO] Référence img2img non disponible ; repli texte : ' + str(exc), flush=True) workflow = _prepare_workflow(workflow, prompt, force_new_seed=force_new_seed, reference_name=reference_name) response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) if not response.ok and reference_applied and REFERENCE_MODE == 'auto': print('[ALFRED METEO] Workflow img2img rejeté ; nouvel essai texte seul.', flush=True) workflow = _prepare_workflow(json.loads(workflow_path.read_text(encoding='utf-8')), prompt, force_new_seed=True, reference_name=None) response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) reference_applied = False response.raise_for_status() return response.json()['prompt_id'], reference_applied def _wait_history(prompt_id: str) -> dict: deadline = time.monotonic() + TIMEOUT while time.monotonic() < deadline: response = requests.get(f'{BASE}/history/{prompt_id}', timeout=15) response.raise_for_status() result = response.json() if prompt_id in result: return result[prompt_id] time.sleep(1) raise RuntimeError(f'ComfyUI timeout ({prompt_id})') def _primary_accessories(kind: str, outfits: dict) -> list[str]: accessories = list(outfits[kind]['accessories']) ordered = [] priorities = ['parapluie', 'imperméable', 'bonnet', 'écharpe', 'coupe-vent', 'casquette', 'lunettes de soleil'] for wanted in priorities: if wanted in accessories and wanted not in ordered: ordered.append(wanted) for acc in accessories: if acc not in ordered: ordered.append(acc) return ordered[:ACCESSORY_LIMIT] def _single_character_prompt(kind: str, day: dict, outfits: dict) -> str: profile = outfits[kind] if kind == 'child': identity = ( 'ONE young GIRL, eight years old, clearly a primary-school child, ' 'small body proportions of a young child, friendly and natural face, ' 'casual appropriate haircut, no grown-up features. ' ) else: identity = ( 'ONE adult MAN, forty-two years old, unmistakably masculine adult, ' 'short neat hair, mature male face, optionally light stubble, ' 'adult male shoulders and body proportions, not a teenager. ' ) weather = str(day.get('description') or 'daily weather') primary_acc = _primary_accessories(kind, outfits) image_acc = ', '.join(primary_acc) if primary_acc else 'none' return ( 'Single character fashion illustration for a family weather dashboard. ' 'Exactly ONE character, standing ALONE. ' + identity + 'Full body visible from head to shoes, centrally positioned, front three-quarter view, ' 'generous empty space around body, relaxed neutral standing pose, smiling gently. ' 'Soft polished 2D digital illustration, smooth clean outlines, tasteful pastel colors, ' 'slight dimensional shading, consistent editorial children-book illustration style, ' 'simple light neutral plain studio background, no other people, no human reflections, ' 'no poster, no photographs, no text. ' f'Weather for {day.get("date", "selected day")}: {weather}. ' f'Clothing: {", ".join(profile["clothes"])}. ' f'Visible accessories to show clearly: {image_acc}. ' 'Show at most ONE prop or wearable accessory, and only if specified; ' 'no extra bags, umbrellas, pets, garments floating in the air or background props. ' 'Keep exactly the same recognizable face, hair style and hair color as the input reference image when provided. ' 'Correct age and correct body proportions. Clothing must be clearly visible. ' 'High priority: one person only, clean natural face, no deformations.' ) def _render_image_from_url(url: str): from PIL import Image response = requests.get(url, timeout=45) response.raise_for_status() if len(response.content) > 18_000_000: raise RuntimeError('Image ComfyUI trop volumineuse (limite 18 Mo).') with Image.open(io.BytesIO(response.content)) as raw: raw.load() return raw.convert('RGB') def _vision_review(kind: str, prompt_summary: str, image_url: str) -> dict: """Évalue une image via VLM ComfyUI si le workflow vision le permet. IMPORTANT: LoadImage attend un *fichier d'entrée*, pas une URL HTTP /view. """ fallback = {'score': 0.0, 'ok': False, 'issues': ['review_unavailable'], 'reviewed': False} if not REVIEW_ENABLED: return fallback workflow_path = WORKFLOWS_DIR / 'vision_analyze.json' if not workflow_path.exists(): return fallback image_node = os.getenv('COMFY_VISION_IMAGE_NODE', '1') prompt_node = os.getenv('COMFY_VISION_PROMPT_NODE', '2') try: workflow = json.loads(workflow_path.read_text(encoding='utf-8')) node = workflow[str(image_node)] if node.get('class_type') not in ('LoadImage', 'LoadImageMask'): # Éviter un faux diagnostic sur un workflow VLM non standard. return fallback response_image = requests.get(image_url, timeout=45) response_image.raise_for_status() if len(response_image.content) > 18_000_000: return fallback filename = 'alfred-weather-review-' + uuid.uuid4().hex[:12] + '.png' response_upload = requests.post(f'{BASE}/upload/image', files={'image': (filename, io.BytesIO(response_image.content), 'image/png')}, data={'overwrite': 'true'}, timeout=30) response_upload.raise_for_status() remote = response_upload.json() name = str(remote.get('name') or filename) folder = str(remote.get('subfolder') or '') node['inputs']['image'] = ((folder.strip('/') + '/') if folder else '') + name review_prompt = ( 'Review a single-person illustrated weather outfit. ' f'Expected: {prompt_summary}. ' 'Answer only JSON with fields: people_count (integer), gender_age_ok (boolean), ' 'face_ok (boolean), accessories_ok (boolean), deformation (boolean), ' 'score (number 0 to 10), issues (list of short strings). ' 'Score harshly for extra people, bad anatomy or wrong-age character.' ) workflow[str(prompt_node)]['inputs']['text'] = review_prompt response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) response.raise_for_status() prompt_id = response.json()['prompt_id'] history = _wait_history(prompt_id) raw_text = None for output in history.get('outputs', {}).values(): for key in ('text', 'texts', 'result', 'string'): if key in output: raw_text = output[key] break if raw_text is not None: break if isinstance(raw_text, list): raw_text = raw_text[0] if raw_text else '' if isinstance(raw_text, dict): parsed = raw_text else: raw_text = str(raw_text or '') begin, finish = raw_text.find('{'), raw_text.rfind('}') if begin < 0 or finish <= begin: return fallback parsed = json.loads(raw_text[begin:finish + 1]) score = max(0.0, min(10.0, float(parsed['score']))) count = int(parsed['people_count']) gender_age_ok = parsed.get('gender_age_ok') is True face_ok = parsed.get('face_ok') is True deformation = parsed.get('deformation') is True accessories_ok = parsed.get('accessories_ok') is True ok = count == 1 and gender_age_ok and face_ok and not deformation return {'score': score, 'ok': ok, 'issues': parsed.get('issues', []), 'reviewed': True, 'accessories_ok': accessories_ok, 'people_count': count, 'gender_age_ok': gender_age_ok, 'face_ok': face_ok, 'deformation': deformation} except (ValueError, TypeError, KeyError, json.JSONDecodeError, requests.RequestException, OSError, RuntimeError, TimeoutError) as exc: print('[ALFRED METEO] Relecture VLM non disponible : ' + str(exc), flush=True) return fallback def _quick_quality(img) -> float: """Indice technique non sémantique (non utilisé comme preuve de conformité).""" from PIL import ImageFilter, ImageStat gray = img.convert('L').resize((256, 256)) edges = gray.filter(ImageFilter.FIND_EDGES) score = ImageStat.Stat(edges).stddev[0] return min(7.0, max(1.0, score / 3.0)) def _lock_reference_face(img, kind: str, referenced: bool): """Préserve la tête du portrait validé, pour un cadrage img2img identique. Uniquement appliqué si ComfyUI est parti du portrait de référence, afin d'éviter de superposer la tête sur une pose générée sans contrainte. """ if not referenced or not PRESERVE_FACE: return img ref_file = REFERENCE_DIR / f'{kind}-reference.png' if not ref_file.is_file(): return img from PIL import Image, ImageDraw, ImageFilter with Image.open(ref_file) as reference: reference = reference.convert('RGB').resize(img.size, Image.Resampling.LANCZOS) # Coordonnées relatives aux portraits 512x768 fournis dans le ZIP. region = ((.35, .08, .66, .36) if kind == 'child' else (.34, .06, .66, .34)) box = tuple(round(v * (img.width if i % 2 == 0 else img.height)) for i,v in enumerate(region)) mask = Image.new('L', img.size, 0) drawer = ImageDraw.Draw(mask) drawer.ellipse(box, fill=230) mask = mask.filter(ImageFilter.GaussianBlur(max(8, round(img.width * .022)))) return Image.composite(reference, img, mask) def _render_best_one(kind: str, day: dict, outfits: dict, refresh: bool = False): prompt = _single_character_prompt(kind, day, outfits) subject = ('one 8 year old girl' if kind == 'child' else 'one adult 42 year old man') summary = subject + ', clothes: ' + ', '.join(outfits[kind]['clothes']) + ', accessories: ' + ( ', '.join(_primary_accessories(kind, outfits)) or 'none') best = None for attempt in range(1, MAX_ATTEMPTS_PER_CHARACTER + 1): prompt_id, referenced = _submit_workflow(prompt, force_new_seed=True, kind=kind) result = _wait_history(prompt_id) images = _extract_images(result) if not images: raise RuntimeError(f'Aucune illustration ComfyUI générée pour {kind}.') url = images[0]['url'] image = _render_image_from_url(url) review = _vision_review(kind, summary, url) quick = _quick_quality(image) score = review['score'] if review.get('reviewed') else quick # Penalty stronger than score for invalid identity/count from a real review if review.get('reviewed') and not review['ok']: score -= 4.0 candidate = {'image': image, 'prompt_id': prompt_id, 'review': review, 'score': score, 'referenced': referenced} if best is None or score > best['score']: best = candidate # Early stop only when a real review validates quality, not with fallback. if review.get('reviewed') and review.get('ok') and review.get('score', 0) >= QUALITY_TARGET: break # Without a functioning VLM, avoid double generation on reasonable output. if not review.get('reviewed') and quick >= 4.3: break final = _lock_reference_face(best['image'], kind, best['referenced']) best['review']['reference_applied'] = best['referenced'] return final, best['prompt_id'], best['review'] def _compose_duo(child_img, adult_img, dest: Path): """Assemble deux personnages distincts dans une vignette carrée 350×350.""" from PIL import Image, ImageOps, ImageDraw size = max(200, min(700, DISPLAY_SIZE)) gap = max(4, round(size * .023)) tile_w = (size - gap) // 2 tile_h = size canvas = Image.new('RGB', (size, size), (42, 50, 56)) # contain au lieu de fit : ne coupe pas les bras, chaussures ou accessoires. for image, x in ((child_img, 0), (adult_img, tile_w + gap)): thumb = ImageOps.fit(image, (tile_w, tile_h), method=Image.Resampling.LANCZOS) px = x + (tile_w - thumb.width)//2 py = (size - thumb.height)//2 canvas.paste(thumb, (px, py)) draw = ImageDraw.Draw(canvas) draw.rectangle((tile_w, 0, tile_w + gap - 1, size), fill=(42, 50, 56)) dest.parent.mkdir(parents=True, exist_ok=True) temp = dest.with_suffix('.tmp.jpg') canvas.save(temp, format='JPEG', quality=92, optimize=True) temp.replace(dest) def _cache_url(file_path: Path) -> str: return 'data:image/jpeg;base64,' + base64.b64encode(file_path.read_bytes()).decode('ascii') def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None: if not ENABLED: return None sig = _weather_signature(day, outfits) image_path = IMAGE_DIR / (sig + '.jpg') with ILLUSTRATION_LOCK: cache = _load_cache() entry = cache.get(sig) now_ts = time.time() if (entry and image_path.is_file() and not refresh and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600): return { 'url': _cache_url(image_path), 'cached_at': entry.get('cached_at'), 'prompt_ids': entry.get('prompt_ids', []), 'reviews': entry.get('reviews', {}), 'from_cache': True, } child, child_id, child_review = _render_best_one('child', day, outfits, refresh=refresh) adult, adult_id, adult_review = _render_best_one('adult', day, outfits, refresh=refresh) _compose_duo(child, adult, image_path) entry = { 'file': image_path.name, 'prompt_ids': [child_id, adult_id], 'reviews': {'child': child_review, 'adult': adult_review}, 'cached_at': now_ts, 'focus_label': focus_label, } cache[sig] = entry _save_cache(cache) return { 'url': _cache_url(image_path), 'prompt_ids': entry['prompt_ids'], 'reviews': entry['reviews'], 'cached_at': now_ts, 'from_cache': False, } def build_payload(q: str = '', date: str = '', label: str = '', refresh: bool = False) -> dict: data = ha_weather.get_weather(q) if date: try: Date.fromisoformat(date) except (ValueError, TypeError) as exc: raise ValueError('Date invalide (AAAA-MM-JJ requis).') from exc target = date or data.get('focus_date', '') day = _pick_day(data, target) focus_label = label or data.get('focus_label', 'aujourd’hui') outfits = _recommend(day) illustration = None illustration_error = None try: illustration = _generate_illustration(day, outfits, focus_label, refresh=(refresh or os.getenv("ALFRED_WEATHER_ALWAYS_NEW", "true").strip().lower() in ("true", "1", "yes", "on"))) except (OSError, ValueError, RuntimeError, TimeoutError, ImportError, requests.RequestException) as exc: illustration_error = f'{type(exc).__name__} : {exc}' print(f'[ALFRED METEO] Illustration indisponible : {illustration_error}', flush=True) return { 'type': 'weather_outfits', 'focus_label': focus_label, 'date': day.get('date'), 'condition': day.get('condition'), 'description': day.get('description'), 'temperature': day.get('temperature'), 'templow': day.get('templow'), 'precipitation_probability': day.get('precipitation_probability'), 'wind_speed': day.get('wind_speed'), 'summary': outfits.get('summary'), 'child': outfits['child'], 'adult': outfits['adult'], 'image': illustration, 'illustration_error': illustration_error, }