Files
Alfredv2/alfred/app/services/weather_outfits.py
T
2026-10-30 22:10:03 +01:00

689 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
}