- 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.
364 lines
14 KiB
Python
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()
|