# -*- 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()