689 lines
31 KiB
Python
689 lines
31 KiB
Python
"""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,
|
||
}
|