This commit is contained in:
pporcheret
2026-10-26 00:06:24 +01:00
parent c9c3d5f223
commit cd37ac3c0a
207 changed files with 19205 additions and 54 deletions
@@ -0,0 +1,534 @@
"""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
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)
_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-v4-bestof-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')
# 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'
)
def _random_seed() -> int:
return random.randint(1, 2**31 - 1)
def _prepare_workflow(workflow: dict, prompt: str, force_new_seed: bool = False) -> 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
return workflow
def _submit_workflow(prompt: str, force_new_seed: bool = False) -> str:
workflow_path = WORKFLOWS_DIR / 'text_to_image.json'
if not workflow_path.exists():
raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}')
workflow = _prepare_workflow(json.loads(workflow_path.read_text(encoding='utf-8')), prompt, force_new_seed=force_new_seed)
response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30)
response.raise_for_status()
return response.json()['prompt_id']
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[:2]
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 two accessories, and only if they are natural to wear or hold. '
'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:
"""Lightweight review using the existing ComfyUI vision workflow when available."""
default = {'score': 7.0, 'ok': True, 'issues': []}
if not REVIEW_ENABLED:
return default
workflow_path = WORKFLOWS_DIR / 'vision_analyze.json'
if not workflow_path.exists():
return default
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'))
workflow[str(image_node)]['inputs']['image'] = image_url
review_prompt = (
'Review a fashion illustration candidate for a weather dashboard. '
f'Expected subject: {prompt_summary}. '
'Answer ONLY with compact JSON object with keys: '
'people_count (int), gender_age_ok (bool), face_ok (bool), accessories_ok (bool), '
'deformation (bool), score (number 0-10), issues (array of strings).'
)
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', {}).items():
for key in ('text', 'result', 'string'):
if key in output:
raw_text = output[key]
break
if 'texts' in output and output['texts']:
raw_text = output['texts'][0]
break
if isinstance(raw_text, list):
raw_text = raw_text[0] if raw_text else ''
if isinstance(raw_text, dict):
parsed = raw_text
else:
text = str(raw_text or '').strip()
start = text.find('{')
end = text.rfind('}')
if start >= 0 and end > start:
text = text[start:end+1]
parsed = json.loads(text)
score = float(parsed.get('score', 0) or 0)
people_count = int(parsed.get('people_count', 1) or 1)
gender_age_ok = bool(parsed.get('gender_age_ok', True))
face_ok = bool(parsed.get('face_ok', True))
accessories_ok = bool(parsed.get('accessories_ok', True))
deformation = bool(parsed.get('deformation', False))
issues = list(parsed.get('issues', []))
ok = (people_count == 1 and gender_age_ok and face_ok and not deformation)
return {'score': score, 'ok': ok, 'issues': issues, 'people_count': people_count,
'gender_age_ok': gender_age_ok, 'face_ok': face_ok, 'accessories_ok': accessories_ok,
'deformation': deformation}
except Exception as exc:
return {'score': 7.0, 'ok': True, 'issues': [f'review_fallback:{exc}']}
def _render_best_one(kind: str, day: dict, outfits: dict, refresh: bool = False):
prompt = _single_character_prompt(kind, day, outfits)
summary = f'{kind} with clothes {", ".join(outfits[kind]["clothes"])} and accessories {", ".join(_primary_accessories(kind, outfits)) or "none"}'
best = None
best_review = None
for attempt in range(1, MAX_ATTEMPTS_PER_CHARACTER + 1):
prompt_id = _submit_workflow(prompt, force_new_seed=True if (refresh or attempt > 1) else True)
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)
candidate = {'image': image, 'prompt_id': prompt_id, 'review': review}
if best is None or review.get('score', 0) > best_review.get('score', 0):
best = candidate
best_review = review
if review.get('ok') and review.get('score', 0) >= QUALITY_TARGET:
break
return best['image'], best['prompt_id'], best_review
def _compose_duo(child_img, adult_img, dest: Path):
from PIL import Image, ImageOps, ImageDraw
w = DISPLAY_SIZE * 2 + 12
h = DISPLAY_SIZE
gap = 12
tile_w = DISPLAY_SIZE
tile_h = DISPLAY_SIZE
canvas = Image.new('RGB', (w, h), (42, 50, 56))
left = ImageOps.fit(child_img, (tile_w, tile_h), method=Image.Resampling.LANCZOS, centering=(.5, .5))
right = ImageOps.fit(adult_img, (tile_w, tile_h), method=Image.Resampling.LANCZOS, centering=(.5, .5))
canvas.paste(left, (0, 0))
canvas.paste(right, (tile_w + gap, 0))
draw = ImageDraw.Draw(canvas)
draw.rounded_rectangle((tile_w, 0, tile_w + gap - 1, h - 1), radius=2, fill=(42, 50, 56))
dest.parent.mkdir(parents=True, exist_ok=True)
temp = dest.with_suffix('.tmp.jpg')
canvas.save(temp, format='JPEG', quality=90, 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)
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,
}