DOCS/outputs/mnaha/seed/gen_images.py
Thomas Fransolet d14f151517 Livrables de presentation, seed de demo MNAHA et wireframes
- outputs/platform, outputs/prospect : scripts de construction des decks
  commerciaux + captures sources et .pptx generes.
- outputs/mnaha : seed complet de l'instance de demo luxembourgeoise
  (scripts par etape, contenus, images generees), le deck en version
  marque blanche et en version brandee, le PDF de presentation.
- outputs/Wireframes : maquettes app mobile, app web et manager.
- interview clients : notes de l'entretien Fourneau St Michel.

state.json et les __pycache__ sont ignores : le premier porte les cles
d'API de l'instance seedee, les scripts le regenerent.
2026-09-11 15:47:57 +02:00

364 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""Visuels d'illustration pour l'instance de démonstration.
Compositions abstraites, générées : aucune photographie de collection n'est
utilisée, les droits n'appartiennent pas au projet. Chaque motif évoque son sujet
sans le représenter — trame de tesselles, géométrie de bastion, pilettes
d'hypocauste — dans la palette du site auquel il appartient.
"""
import math
import random
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
OUT = Path(__file__).resolve().parent / "images"
PALETTES = {
"feschmaart": {"deep": (18, 32, 54), "mid": (31, 58, 95), "warm": (176, 141, 87),
"pale": (214, 199, 176)},
"draieechelen": {"deep": (28, 36, 24), "mid": (74, 92, 61), "warm": (156, 122, 74),
"pale": (206, 199, 176)},
"echternach": {"deep": (48, 28, 18), "mid": (122, 74, 47), "warm": (192, 161, 107),
"pale": (222, 206, 180)},
}
def blend(a, b, t):
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
def background(size, palette, tilt=0.0):
"""Dégradé diagonal doux, du plus sombre en haut à gauche."""
w, h = size
base = Image.new("RGB", size)
px = base.load()
for y in range(h):
for x in range(0, w, 4):
t = (x / w * (0.35 + tilt) + y / h * 0.65)
colour = blend(palette["deep"], palette["mid"], min(1.0, t))
for dx in range(4):
if x + dx < w:
px[x + dx, y] = colour
return base
def grain(img, amount=7, seed=0):
rnd = random.Random(seed)
noise = Image.new("L", img.size)
noise.putdata([128 + rnd.randint(-amount, amount)
for _ in range(img.size[0] * img.size[1])])
noise = noise.filter(ImageFilter.GaussianBlur(0.4))
return Image.blend(img, Image.merge("RGB", (noise, noise, noise)), 0.06)
def vignette(img, strength=0.45):
w, h = img.size
mask = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(mask)
d.ellipse([-w * 0.25, -h * 0.35, w * 1.25, h * 1.35], fill=255)
mask = mask.filter(ImageFilter.GaussianBlur(min(w, h) * 0.18))
dark = Image.new("RGB", (w, h), (0, 0, 0))
return Image.composite(img, Image.blend(img, dark, strength), mask)
def layer(size):
return Image.new("RGBA", size, (0, 0, 0, 0))
# ----------------------------------------------------------------- motifs
def tesserae(size, palette, seed):
"""Trame de tesselles, pour la mosaïque."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
cell = max(14, w // 46)
for y in range(0, h + cell, cell):
for x in range(0, w + cell, cell):
if rnd.random() < 0.14:
continue
jx, jy = rnd.randint(-2, 2), rnd.randint(-2, 2)
t = rnd.random()
colour = blend(palette["warm"], palette["pale"], t)
radial = math.hypot(x - w * 0.5, y - h * 0.5) / (w * 0.6)
alpha = int(max(0, 150 * (1.05 - radial)) * (0.45 + rnd.random() * 0.55))
d.rectangle([x + jx, y + jy, x + jx + cell - 3, y + jy + cell - 3],
fill=colour + (alpha,))
return lay
def bastion(size, palette, seed):
"""Tracé bastionné : enceintes concentriques, glacis hachuré, lignes de tir."""
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
cx, cy = w * 0.5, h * 0.52
for i in range(0, int(w * 1.6), max(9, w // 90)):
d.line([(i - w * 0.3, h), (i, 0)], fill=palette["warm"] + (16,), width=1)
for ring in range(15):
radius = min(w, h) * (0.05 + ring * 0.031)
points = []
spikes = 6
for i in range(spikes * 2):
angle = math.pi * i / spikes + ring * 0.05
r = radius * (1.0 if i % 2 == 0 else 0.74)
points.append((cx + r * math.cos(angle), cy + r * math.sin(angle) * 0.86))
colour = blend(palette["warm"], palette["pale"], ring / 15)
d.line(points + [points[0]], fill=colour + (max(34, int(130 - ring * 6)),),
width=max(2, int(w * 0.0028)))
if ring % 4 == 0:
d.polygon(points, fill=colour + (10,))
for i in range(12):
angle = math.tau * i / 12
d.line([cx + min(w, h) * 0.07 * math.cos(angle),
cy + min(w, h) * 0.07 * math.sin(angle) * 0.86,
cx + w * 0.75 * math.cos(angle),
cy + w * 0.75 * math.sin(angle) * 0.86],
fill=palette["pale"] + (30,), width=1)
return lay
def pillars(size, palette, seed):
"""Champ de pilettes en fuite : l'hypocauste sous le plancher."""
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
rows = 9
for r in range(rows):
depth = (r + 1) / rows
y = h * (0.16 + 0.86 * depth ** 1.7)
pw = w * 0.012 + w * 0.030 * depth
ph = h * 0.035 + h * 0.10 * depth
gap = pw * 2.35
colour = blend(palette["warm"], palette["pale"], 0.25 + depth * 0.5)
alpha = int(12 + depth * 62)
x = (r % 2) * gap * 0.5 - gap * 0.5
while x < w + gap:
d.rectangle([x, y - ph, x + pw, y], fill=colour + (alpha,))
d.rectangle([x - pw * 0.22, y - ph - ph * 0.10,
x + pw * 1.22, y - ph + ph * 0.02],
fill=colour + (min(255, alpha + 26),))
x += gap
return lay
def strata(size, palette, seed):
"""Couches sédimentaires : la fouille, la stratigraphie."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
y = h * 0.06
while y < h:
thickness = rnd.uniform(h * 0.018, h * 0.055)
t = y / h
colour = blend(palette["warm"], palette["pale"], rnd.uniform(0.1, 0.9))
alpha = int(18 + 46 * t)
points = [(0, y)]
x = 0
while x < w:
x += w / 18
points.append((x, y + rnd.uniform(-h * 0.014, h * 0.014)))
points += [(w, y + thickness), (0, y + thickness)]
d.polygon(points, fill=colour + (alpha,))
# Inclusions : éclats et tessons pris dans la couche.
for _ in range(int(thickness / h * 90)):
ix = rnd.uniform(0, w)
iy = rnd.uniform(y + thickness * 0.15, y + thickness * 0.85)
ir = rnd.uniform(w * 0.002, w * 0.006)
d.ellipse([ix - ir, iy - ir * 0.6, ix + ir, iy + ir * 0.6],
fill=palette["pale"] + (int(alpha * 0.9),))
y += thickness + rnd.uniform(h * 0.002, h * 0.012)
return lay
def arcs(size, palette, seed):
"""Portique : arcades en enfilade sur stylobate."""
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
bays = 5
span = w / bays
for register, (ground, bays_here) in enumerate([(h * 0.52, 7), (h * 0.94, 5)]):
span = w / bays_here
for b in range(bays_here + 1):
cx = span * b
for k in range(4):
r = span * (0.50 - k * 0.045)
top = ground - r * 1.15
colour = blend(palette["pale"], palette["warm"], k / 4)
d.arc([cx - r, top - r * 0.35, cx + r, top + r * 1.5], 180, 360,
fill=colour + (int(120 - k * 20),), width=max(2, int(w * 0.0028)))
cw = span * 0.075
d.rectangle([cx - cw, ground - span * 0.62, cx + cw, ground],
fill=palette["warm"] + (44,))
d.line([(0, ground), (w, ground)], fill=palette["warm"] + (70,),
width=max(2, int(w * 0.0025)))
return lay
def medallions(size, palette, seed):
"""Monnaies : disques serrés, frappés, qui se recouvrent."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
for _ in range(78):
r = rnd.uniform(w * 0.025, w * 0.085)
cx = rnd.uniform(-w * 0.02, w * 1.02)
cy = rnd.uniform(-h * 0.02, h * 1.02)
colour = blend(palette["warm"], palette["pale"], rnd.random())
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=colour + (16,),
outline=colour + (110,), width=max(2, int(w * 0.0022)))
d.ellipse([cx - r * 0.66, cy - r * 0.66, cx + r * 0.66, cy + r * 0.66],
outline=colour + (62,), width=max(1, int(w * 0.0014)))
beads = 18
for i in range(beads):
a = math.tau * i / beads
bx, by = cx + r * 0.84 * math.cos(a), cy + r * 0.84 * math.sin(a)
d.ellipse([bx - 1.4, by - 1.4, bx + 1.4, by + 1.4], fill=colour + (70,))
return lay
def deco(size, palette, seed):
"""Éventails et arcs : le vocabulaire Art déco, en trame pleine."""
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
for cx, cy, scale in [(w * 0.16, h * 1.02, 1.0), (w * 0.5, h * 1.02, 1.25),
(w * 0.84, h * 1.02, 1.0), (w * 0.33, h * -0.02, 0.7),
(w * 0.67, h * -0.02, 0.7)]:
upward = cy < h * 0.5
for i in range(19):
angle = (0 if upward else math.pi) + i * math.pi / 18
length = min(w, h) * scale * (0.62 if i % 2 == 0 else 0.48)
d.line([cx, cy, cx + length * math.cos(angle),
cy + length * math.sin(angle)],
fill=blend(palette["pale"], palette["warm"], i / 19)
+ (88 if i % 2 else 120,),
width=max(2, int(w * 0.0026)))
for ring in range(6):
r = min(w, h) * scale * (0.11 + ring * 0.105)
d.arc([cx - r, cy - r, cx + r, cy + r],
0 if upward else 180, 180 if upward else 360,
fill=palette["warm"] + (48,), width=max(1, int(w * 0.0018)))
return lay
def contours(size, palette, seed):
"""Courbes de niveau : le site, le terrain, la vallée."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
for i in range(32):
points = []
base = h * (0.02 + i * 0.032)
phase = rnd.uniform(0, math.tau)
for x in range(0, w + 12, 12):
y = base + math.sin(x / w * math.tau * 1.4 + phase) * h * 0.055 \
+ math.sin(x / w * math.tau * 3.1 + phase) * h * 0.018
points.append((x, y))
colour = blend(palette["warm"], palette["pale"], i / 32)
d.line(points, fill=colour + (int(100 - i * 1.6),),
width=max(1, int(w * 0.0018)))
return lay
def plan(size, palette, seed):
"""Trame orthogonale : le plan, la maquette, le relevé."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
step = w // 30
for x in range(0, w, step):
d.line([(x, 0), (x, h)], fill=palette["warm"] + (34,), width=1)
for y in range(0, h, step):
d.line([(0, y), (w, y)], fill=palette["warm"] + (34,), width=1)
for _ in range(22):
x0 = rnd.randrange(0, w - step * 5, step)
y0 = rnd.randrange(0, h - step * 4, step)
x1 = x0 + step * rnd.randint(2, 7)
y1 = y0 + step * rnd.randint(2, 6)
colour = blend(palette["pale"], palette["warm"], rnd.random())
d.rectangle([x0, y0, x1, y1], fill=colour + (14,), outline=colour + (120,),
width=max(2, int(w * 0.0024)))
return lay
MOTIFS = {
"tesserae": tesserae, "bastion": bastion, "pillars": pillars, "strata": strata,
"arcs": arcs, "medallions": medallions, "deco": deco, "contours": contours,
"plan": plan,
}
def compose(name, site, motif, size=(1600, 1000), seed=0, tilt=0.0):
palette = PALETTES[site]
img = background(size, palette, tilt).convert("RGBA")
img = Image.alpha_composite(img, MOTIFS[motif](size, palette, seed))
img = vignette(img.convert("RGB"))
img = grain(img, seed=seed)
OUT.mkdir(parents=True, exist_ok=True)
path = OUT / f"{name}.png"
img.save(path, "PNG", optimize=True)
print(f" {path.name:34} {size[0]}x{size[1]} {path.stat().st_size // 1024} Ko")
return path
# Cinq motifs retenus : ceux qui tiennent à taille de vignette. `bastion`,
# `pillars`, `arcs` et `strata` restent définis mais ne sont pas utilisés — ils
# se lisent comme un aplat vide ou comme une frise trop littérale une fois
# réduits. Un plan orthogonal évoque d'ailleurs mieux une fouille, un fort ou
# une villa que le motif inventé qu'ils remplaçaient.
#
# nom de fichier -> (site, motif, taille, graine)
IMAGES = [
# sites (tuiles d'accueil)
("site-feschmaart", "feschmaart", "tesserae", (1600, 1000), 11),
("site-draieechelen", "draieechelen", "plan", (1600, 1000), 12),
("site-echternach", "echternach", "contours", (1600, 1000), 13),
# habillage de l'application
("app-accueil", "feschmaart", "tesserae", (1200, 1600), 21),
("app-chargement", "feschmaart", "medallions", (1080, 1920), 22),
# articles du Fëschmaart
("art-vichten", "feschmaart", "tesserae", (1600, 1000), 31),
("art-goeblange", "feschmaart", "plan", (1600, 1000), 32),
("art-villeroy", "feschmaart", "deco", (1600, 1000), 33),
("art-medailles", "feschmaart", "medallions", (1600, 1000), 34),
# articles de Dräi Eechelen
("art-thungen", "draieechelen", "plan", (1600, 1000), 41),
("art-demantelement", "draieechelen", "contours", (1600, 1000), 42),
("art-maquettes", "draieechelen", "plan", (1600, 1000), 43),
# articles d'Echternach
("art-villa", "echternach", "plan", (1600, 1000), 51),
("art-thermes", "echternach", "tesserae", (1600, 1000), 52),
# parcours
("parcours-echternach", "echternach", "contours", (1600, 1000), 61),
("parcours-escape", "feschmaart", "tesserae", (1600, 1000), 62),
# points d'intérêt de la carte
("poi-entree", "echternach", "plan", (1200, 800), 71),
("poi-cour", "echternach", "plan", (1200, 800), 72),
("poi-thermes", "echternach", "tesserae", (1200, 800), 73),
("poi-bassins", "echternach", "contours", (1200, 800), 74),
("poi-panorama", "echternach", "contours", (1200, 800), 75),
]
def main():
print(f"génération dans {OUT}")
for name, site, motif, size, seed in IMAGES:
compose(name, site, motif, size, seed, tilt=(seed % 5) * 0.04)
print(f"\n{len(IMAGES)} visuels générés")
if __name__ == "__main__":
main()