Test axonométrique du 27/09 : pipeline de fabrication et prototype

pipeline/axo : extraction du Rhino, recalage sur la planche (1:640), maisons,
arbres (positions réelles des blocs, couleurs de la planche), scène en mètres,
sols depuis le PDF Illustrator vectoriel (calques pilotés), bulles de la page 15,
planche de l'eau nettoyée. prototypes/axo-test : la page de test et son assembleur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jules Nény
2026-09-27 16:46:14 +02:00
co-authored by Claude Opus 5.5
parent a9e0143536
commit 731531dbe2
17 changed files with 1334 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# Extrait toute la géométrie utile du plan Rhino TRANSFO en mètres (JSON), une fois pour toutes.
import sys, json, rhino3dm, math
m = rhino3dm.File3dm.Read(sys.argv[1])
L = {i: l.Name for i, l in enumerate(m.Layers)}
def pts(c, n=48):
if isinstance(c, rhino3dm.PolylineCurve):
return [(c.Point(i).X/100, c.Point(i).Y/100) for i in range(c.PointCount)]
if isinstance(c, rhino3dm.LineCurve):
return [(c.PointAtStart.X/100, c.PointAtStart.Y/100), (c.PointAtEnd.X/100, c.PointAtEnd.Y/100)]
d = c.Domain
return [(c.PointAt(d.T0+(d.T1-d.T0)*i/n).X/100, c.PointAt(d.T0+(d.T1-d.T0)*i/n).Y/100) for i in range(n+1)]
courbes = []
instances = []
defs = {str(d.Id): d.Name for d in m.InstanceDefinitions}
for o in m.Objects:
g = o.Geometry; ln = L.get(o.Attributes.LayerIndex, "")
if isinstance(g, rhino3dm.InstanceReference):
x = g.Xform
px, py = x.M03/100, x.M13/100
sx = math.hypot(x.M00, x.M10);
instances.append({"l": ln, "d": defs.get(str(g.ParentIdefId), "?"), "x": round(px, 3), "y": round(py, 3), "s": round(sx, 4)})
continue
if not isinstance(g, rhino3dm.Curve): continue
try: P = pts(g)
except Exception: continue
if len(P) < 2: continue
courbes.append({"l": ln, "f": bool(g.IsClosed), "p": [[round(a, 3), round(b, 3)] for a, b in P]})
# boîte englobante de chaque définition de bloc (pour le rayon des arbres)
bb = {}
for d in m.InstanceDefinitions:
xs, ys = [], []
for oid in d.GetObjectIds():
ob = m.Objects.FindId(oid)
if ob is None: continue
try:
b = ob.Geometry.GetBoundingBox(); xs += [b.Min.X, b.Max.X]; ys += [b.Min.Y, b.Max.Y]
except Exception: pass
if xs: bb[d.Name] = [round((max(xs)-min(xs))/100, 3), round((max(ys)-min(ys))/100, 3)]
json.dump({"courbes": courbes, "instances": instances, "defs_bbox": bb}, open(sys.argv[2], "w"))
import collections
print("courbes", len(courbes), "instances", len(instances))
print(collections.Counter(i["l"] for i in instances).most_common(12))
print("defs bbox (échantillon)", list(bb.items())[:8])
+55
View File
@@ -0,0 +1,55 @@
import json, numpy as np
from PIL import Image, ImageDraw
R = json.load(open("rhino.json"))
img = np.asarray(Image.open("base100-1.png").convert("RGB")).astype(int)
H, W, _ = img.shape
r, g, b = img[..., 0], img[..., 1], img[..., 2]
bleu = ((b - r) > 30) & (b > 150) # eau
vert = ((g - r) > 6) & ((g - b) > 25) & (g > 170) # limites de parcelles (vert pâle)
cible = (bleu * 2.0 + vert * 1.0).astype(np.float32)
print("pixels bleus", bleu.sum(), "verts", vert.sum())
eau = [c for c in R["courbes"] if c["l"] == "B_EAU noues" and c["f"]]
parc = [c for c in R["courbes"] if c["l"] == "PARCELLES"]
allp = np.array([p for c in parc for p in c["p"]])
x0, y1 = allp[:, 0].min(), allp[:, 1].max()
def raster(s, shape):
im = Image.new("F", (shape[1], shape[0]), 0); d = ImageDraw.Draw(im)
for c in eau:
d.polygon([((x - x0) * s, (y1 - y) * s) for x, y in c["p"]], fill=2.0)
for c in parc:
d.line([((x - x0) * s, (y1 - y) * s) for x, y in c["p"]], fill=1.0, width=max(1, int(s * 1.2)))
return np.asarray(im, dtype=np.float32)
def correl(a, bimg):
P = (2 * H, 2 * W)
Fa = np.fft.rfft2(a, P); Fb = np.fft.rfft2(bimg, P)
c = np.fft.irfft2(Fa * np.conj(Fb), P)
k = np.unravel_index(np.argmax(c), c.shape)
return c[k], k
best = None
for s in np.arange(2.0, 9.0, 0.05):
rs = raster(s, (H, W))
v, k = correl(cible, rs)
v = v / (np.sqrt((rs ** 2).sum()) + 1e-6)
if best is None or v > best[0]: best = (v, s, k)
v, s, k = best
dy = k[0] if k[0] < H else k[0] - 2 * H
dx = k[1] if k[1] < W else k[1] - 2 * W
print("grossier : s=%.3f px/m dx=%d dy=%d score=%.1f" % (s, dx, dy, v))
# affinage
best2 = None
for s2 in np.arange(s - 0.06, s + 0.06, 0.005):
rs = raster(s2, (H, W)); v2, k2 = correl(cible, rs); v2 /= (np.sqrt((rs ** 2).sum()) + 1e-6)
if best2 is None or v2 > best2[0]: best2 = (v2, s2, k2)
v, s, k = best2
dy = k[0] if k[0] < H else k[0] - 2 * H
dx = k[1] if k[1] < W else k[1] - 2 * W
print("fin : s=%.4f px/m à 100 dpi dx=%d dy=%d" % (s, dx, dy))
# transformation mètres -> pixels (100 dpi) : px = (x - x0)*s + dx ; py = (y1 - y)*s + dy
T = {"dpi": 100, "s": float(s), "x0": float(x0), "y1": float(y1), "dx": int(dx), "dy": int(dy)}
json.dump(T, open("transfo.json", "w"))
# contrôle visuel : géométrie Rhino en rouge par-dessus le dessin
ov = Image.open("base100-1.png").convert("RGB"); d = ImageDraw.Draw(ov)
f = lambda x, y: ((x - x0) * s + dx, (y1 - y) * s + dy)
for c in parc: d.line([f(*p) for p in c["p"]], fill=(220, 30, 30), width=1)
for c in eau: d.line([f(*p) for p in c["p"]] + [f(*c["p"][0])], fill=(200, 0, 200), width=2)
ov.save("controle-recalage.png")
+33
View File
@@ -0,0 +1,33 @@
import json, numpy as np, collections
from PIL import Image, ImageDraw
from shapely.geometry import Polygon
R = json.load(open("rhino.json")); T = json.load(open("transfo.json"))
s, x0, y1, dx, dy = T["s"], T["x0"], T["y1"], T["dx"], T["dy"]
f = lambda x, y: ((x - x0) * s + dx, (y1 - y) * s + dy)
W, H = 1654, 1170
cand = []
for c in R["courbes"]:
if not c["f"] or len(c["p"]) < 4: continue
try: P = Polygon(c["p"])
except Exception: continue
if not P.is_valid: continue
a = P.area
if not (40 <= a <= 1500): continue
mrr = P.minimum_rotated_rectangle
if mrr.area <= 0 or a / mrr.area < 0.88: continue
cx, cy = f(P.centroid.x, P.centroid.y)
if not (0 <= cx <= W and 0 <= cy <= H): continue
cand.append({"l": c["l"], "a": round(a, 1), "poly": P, "c": (cx, cy)})
print(collections.Counter(c["l"] for c in cand).most_common())
ov = Image.open("base100-1.png").convert("RGB"); d = ImageDraw.Draw(ov)
pal = {}
cols = [(230, 40, 40), (40, 90, 230), (20, 160, 60), (200, 120, 0), (160, 0, 200), (0, 170, 170), (120, 120, 120), (230, 0, 130)]
for c in cand:
col = pal.setdefault(c["l"], cols[len(pal) % len(cols)])
xs, ys = c["poly"].exterior.xy
d.line([f(x, y) for x, y in zip(xs, ys)], fill=col, width=3)
y = 10
for l, col in pal.items():
d.rectangle([10, y, 30, y + 14], fill=col); d.text((36, y), f"{l}", fill=(0, 0, 0)); y += 18
ov.save("controle-maisons.png")
json.dump([{"l": c["l"], "a": c["a"], "p": [list(p) for p in c["poly"].exterior.coords]} for c in cand], open("cand_maisons.json", "w"))
+77
View File
@@ -0,0 +1,77 @@
import json, numpy as np, collections, math
from PIL import Image, ImageDraw
from shapely.geometry import Polygon, MultiPoint, LineString, Point
R = json.load(open("rhino.json")); T = json.load(open("transfo.json"))
s, x0, y1, dx, dy = T["s"], T["x0"], T["y1"], T["dx"], T["dy"]
f = lambda x, y: ((x - x0) * s + dx, (y1 - y) * s + dy)
W, H = 1654, 1170
inimg = lambda x, y: 0 <= f(x, y)[0] <= W and 0 <= f(x, y)[1] <= H
# 1. maisons existantes : rectangles de 3_ TRAITS VUS
cand = json.load(open("cand_maisons.json"))
exist = [Polygon(c["p"]) for c in cand if c["l"] == "3_ TRAITS VUS"]
# faîtage : ligne intérieure la plus longue de 3_ TRAITS VUS contenue dans la maison
lignes = [LineString(c["p"]) for c in R["courbes"] if c["l"] in ("3_ TRAITS VUS", "4_ TRAITS VUS FIINS") and not c["f"] and len(c["p"]) >= 2]
maisons = []
for P in exist:
inner = [l for l in lignes if P.buffer(0.3).contains(l) and l.length > 3]
mrr = list(P.minimum_rotated_rectangle.exterior.coords)[:4]
e1 = np.subtract(mrr[1], mrr[0]); e2 = np.subtract(mrr[2], mrr[1])
if inner:
l = max(inner, key=lambda l: l.length); a, b = l.coords[0], l.coords[-1]
ang = math.atan2(b[1] - a[1], b[0] - a[0])
else:
e = e1 if np.linalg.norm(e1) >= np.linalg.norm(e2) else e2; ang = math.atan2(e[1], e[0])
maisons.append({"type": "existante", "p": [list(map(lambda v: round(v, 2), q)) for q in mrr], "faitage": round(ang, 4)})
# 2. maisons transformées et grand bâtiment : grappes du calque A_ISOL _ COUPE
iso = [c for c in R["courbes"] if c["l"] == "A_ISOL _ COUPE"]
pts = [(p, i) for i, c in enumerate(iso) for p in c["p"]]
centres = [np.mean(np.array(c["p"]), axis=0) for c in iso]
# regroupement simple par distance (8 m)
groupes = []
for i, cc in enumerate(centres):
for g in groupes:
if min(np.linalg.norm(cc - centres[j]) for j in g) < 8: g.append(i); break
else: groupes.append([i])
# fusion itérative des groupes proches
chg = True
while chg:
chg = False
for a in range(len(groupes)):
for b in range(a + 1, len(groupes)):
if min(np.linalg.norm(centres[i] - centres[j]) for i in groupes[a] for j in groupes[b]) < 8:
groupes[a] += groupes[b]; del groupes[b]; chg = True; break
if chg: break
transfo = []
for g in groupes:
P = MultiPoint([tuple(p) for i in g for p in iso[i]["p"]]).minimum_rotated_rectangle
if P.area < 60: continue
cx, cy = P.centroid.x, P.centroid.y
if not inimg(cx, cy): continue
mrr = list(P.exterior.coords)[:4]
e1 = np.subtract(mrr[1], mrr[0]); e2 = np.subtract(mrr[2], mrr[1])
e = e1 if np.linalg.norm(e1) >= np.linalg.norm(e2) else e2
transfo.append({"type": "grand" if P.area > 300 else "transformee", "p": [[round(v, 2) for v in q] for q in mrr], "faitage": round(math.atan2(e[1], e[0]), 4), "aire": round(P.area, 1)})
print("existantes", len(maisons), "| transformées/grand", [(t["type"], t["aire"]) for t in transfo])
# 3. arbres : instances de blocs, couleur échantillonnée sur le dessin TOTAL
tot = np.asarray(Image.open("total100-1.png").convert("RGB")).astype(int)
arbres = []
rayons = {"Ishigami's fruit tree's 1": 1.6, "ishigami foret": 2.6, "B_Existantbuisson": 1.2, "A_Existant arbre": 2.4, "0": 2.0, "Ishigami's tree's 2": 2.4}
INST = json.load(open("instances_monde.json"))
for it in INST:
if it["l"] not in rayons or not inimg(it["x"], it["y"]): continue
rayons[it["l"]] = rayons[it["l"]]
px, py = f(it["x"], it["y"]); r = max(2, int(max(it["r"], 0.8) * s * 0.7))
x_, y_ = int(px), int(py)
zone = tot[max(0, y_ - r):y_ + r, max(0, x_ - r):x_ + r].reshape(-1, 3)
col = zone[(zone.sum(1) < 690) & (zone.max(1) - zone.min(1) > 18)]
c = col.mean(0) if len(col) > 3 else np.array([150, 185, 120])
arbres.append({"x": it["x"], "y": it["y"], "k": it["l"], "r": round(max(it["r"], 0.8), 2), "c": "#%02x%02x%02x" % tuple(int(v) for v in c)})
print("arbres", len(arbres), collections.Counter(a["k"] for a in arbres))
json.dump({"maisons": maisons, "speciaux": transfo, "arbres": arbres}, open("batiments.json", "w"))
# contrôle sur TOTAL
ov = Image.open("total100-1.png").convert("RGB"); d = ImageDraw.Draw(ov)
for m_ in maisons: d.polygon([f(*q) for q in m_["p"]], outline=(0, 60, 230), width=3)
for t in transfo: d.polygon([f(*q) for q in t["p"]], outline=(230, 0, 0), width=4)
for a in arbres[::3]:
px, py = f(a["x"], a["y"]); rr = a["r"] * s * 0.5; d.ellipse([px - rr, py - rr, px + rr, py + rr], outline=(0, 0, 0))
ov.save("controle-batiments.png")
+32
View File
@@ -0,0 +1,32 @@
import sys, json, rhino3dm, math
m = rhino3dm.File3dm.Read(sys.argv[1])
L = {i: l.Name for i, l in enumerate(m.Layers)}
centre = {}
for d in m.InstanceDefinitions:
xs, ys = [], []
for oid in d.GetObjectIds():
ob = m.Objects.FindId(oid)
if ob is None: continue
try:
b = ob.Geometry.GetBoundingBox(); xs += [b.Min.X, b.Max.X]; ys += [b.Min.Y, b.Max.Y]
except Exception: pass
if xs: centre[str(d.Id)] = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2, (max(xs) - min(xs)), (max(ys) - min(ys)))
out = []
for o in m.Objects:
g = o.Geometry
if not isinstance(g, rhino3dm.InstanceReference): continue
ln = L.get(o.Attributes.LayerIndex, "")
c = centre.get(str(g.ParentIdefId))
if not c: continue
x = g.Xform
wx = x.M00 * c[0] + x.M01 * c[1] + x.M03
wy = x.M10 * c[0] + x.M11 * c[1] + x.M13
sc = math.hypot(x.M00, x.M10)
out.append({"l": ln, "x": round(wx / 100, 3), "y": round(wy / 100, 3), "r": round(max(c[2], c[3]) * sc / 200, 3)})
json.dump(out, open(sys.argv[2], "w"))
import collections
print(len(out), collections.Counter(o["l"] for o in out).most_common(8))
for l in ["Ishigami's fruit tree's 1", "ishigami foret", "PERSOS"]:
a = [o for o in out if o["l"] == l]
xs = [o["x"] for o in a]; ys = [o["y"] for o in a]; rs = sorted(o["r"] for o in a)
print(l, "x %.0f..%.0f y %.0f..%.0f rayon médian %.2f m" % (min(xs), max(xs), min(ys), max(ys), rs[len(rs)//2]))
+99
View File
@@ -0,0 +1,99 @@
import json, math, random, numpy as np
from PIL import Image
from shapely.geometry import Polygon, Point, LineString
random.seed(7)
R = json.load(open("rhino.json")); T = json.load(open("transfo.json")); B = json.load(open("batiments.json"))
INST = json.load(open("instances_monde.json")); ROUTES = json.load(open("routes_px.json"))
s, x0, y1, dx, dy = T["s"], T["x0"], T["y1"], T["dx"], T["dy"]
W, H = 1654, 1170
px = lambda x, y: ((x - x0) * s + dx, (y1 - y) * s + dy) # Rhino m -> pixel (100 dpi)
wd = lambda X, Y: (round((X - W / 2) / s, 2), round((Y - H / 2) / s, 2)) # pixel -> monde (x, z)
rw = lambda x, y: wd(*px(x, y)) # Rhino m -> monde
dedans = lambda X, Y, m=0: -m <= X <= W + m and -m <= Y <= H + m
# --- routes : retouche ouest et carrefour (relevé sur zoom), reste = axe calculé
princ = ROUTES["principale"]
ouest = [(0, 180), (100, 236), (200, 292), (300, 349), (400, 405), (460, 440)]
milieu = [p for p in princ if 490 <= p[0] <= 846 and p[1] < 880]
bas = [p for p in princ if p[1] >= 960]
ROUTES["principale"] = ouest + milieu + [(868, 888), (930, 935)] + bas
routes = {k: [wd(*p) for p in v] for k, v in ROUTES.items()}
# --- maisons
def poly_monde(pts): return [rw(*p) for p in pts]
maisons = [{"t": m["type"], "p": poly_monde(m["p"]), "f": m["faitage"]} for m in B["maisons"]]
for sp in B["speciaux"]:
maisons.append({"t": "grand" if sp["aire"] > 200 else "transformee", "p": poly_monde(sp["p"]), "f": sp["faitage"]})
# --- arbres : palette quantifiée
arbres = B["arbres"]
def hexrgb(h): return tuple(int(h[i:i + 2], 16) for i in (1, 3, 5))
# densification de la forêt (zone est, délimitée sur le dessin)
foret = Polygon([(1335, 0), (1654, 0), (1654, 780), (1565, 752), (1160, 596), (1330, 40)])
fond = np.asarray(Image.open("fond1654.png").convert("RGB")).astype(int)
existants = [px(a["x"], a["y"]) for a in arbres]
grille = {}
for (X, Y) in existants: grille.setdefault((int(X // 20), int(Y // 20)), []).append((X, Y))
sentier_foret = [LineString([px(*p) for p in c["p"]]) for c in R["courbes"] if c["l"] == "C_SENTIER" and not c["f"] and len(c["p"]) > 5]
ajouts = 0
for _ in range(9000):
X, Y = random.uniform(1160, 1654), random.uniform(0, 780)
if not foret.contains(Point(X, Y)): continue
voisins = [q for gx in range(int(X // 20) - 1, int(X // 20) + 2) for gy in range(int(Y // 20) - 1, int(Y // 20) + 2) for q in grille.get((gx, gy), [])]
if any((X - a) ** 2 + (Y - b) ** 2 < (2.9 * s) ** 2 for a, b in voisins): continue
if any(l.distance(Point(X, Y)) < 1.6 * s for l in sentier_foret): continue
z = fond[max(0, int(Y) - 4):int(Y) + 5, max(0, int(X) - 4):int(X) + 5].reshape(-1, 3)
z = z[(z.sum(1) < 690) & (z.max(1) - z.min(1) > 18)]
c = z.mean(0) if len(z) > 4 else np.array([140, 190, 140])
r = random.uniform(1.3, 2.4)
Xm, Ym = (X - dx) / s + x0, y1 - (Y - dy) / s
arbres.append({"x": Xm, "y": Ym, "k": "foret+", "r": r, "c": "#%02x%02x%02x" % tuple(int(v) for v in c)})
grille.setdefault((int(X // 20), int(Y // 20)), []).append((X, Y)); ajouts += 1
# palette : couleurs éclaircies façon lavis, quantifiées
def lavis(c):
c = np.array(c, float); g = c.mean(); c = g + (c - g) * 1.15; c = c * 0.85 + 255 * 0.15
return tuple(int(max(0, min(255, v))) for v in c)
cols = [lavis(hexrgb(a["c"])) for a in arbres]
pal, idx = [], []
for c in cols:
q = tuple(int(v // 24 * 24 + 12) for v in c)
if q not in pal: pal.append(q)
idx.append(pal.index(q))
kind = {"Ishigami's fruit tree's 1": 0, "ishigami foret": 1, "foret+": 1, "A_Existant arbre": 2, "B_Existantbuisson": 3}
arbres_out = []
for a, i in zip(arbres, idx):
X, Z = rw(a["x"], a["y"]); arbres_out.append([X, Z, round(a["r"], 2), i, kind.get(a["k"], 1)])
# --- personnages
persos = [rw(i["x"], i["y"]) for i in INST if i["l"] == "PERSOS" and dedans(*px(i["x"], i["y"]))]
# --- eau, sentiers, étals, bassins plantés, liaisons
def polys(layer, ferme=True, min_pts=3):
out = []
for c in R["courbes"]:
if c["l"] != layer or c["f"] != ferme or len(c["p"]) < min_pts: continue
P = [px(*p) for p in c["p"]]
if not any(dedans(*q) for q in P): continue
out.append([wd(*q) for q in P])
return out
noues = polys("B_EAU noues", True)
liaisons = [l for l in polys("TIRE-T", False, 2) if LineString(l).length < 40]
plantes = polys("planté", True) + polys("planté", False, 2)
sentiers = polys("C_SENTIER", True)
chemins = polys("C_SENTIER", False, 2)
etals = []
for c in R["courbes"]:
if c["l"] == "5_ tire-T pointillés" and c["f"]:
P = Polygon(c["p"])
if 6 < P.area < 60:
q = px(P.centroid.x, P.centroid.y)
if dedans(*q): etals.append({"p": [rw(*p) for p in list(P.minimum_rotated_rectangle.exterior.coords)[:4]], "c": wd(*q)})
# --- bulles de la page 15 (centres relevés sur la page alignée, en pixels 100 dpi)
bulles = [("film", 880, 95, 30), ("marche", 388, 238, 38), ("ballons", 775, 240, 37), ("barbecue", 856, 238, 37), ("enfant", 937, 240, 37),
("confiture", 1153, 203, 37), ("bouteille", 1233, 196, 37), ("pomme", 1287, 292, 35), ("miel", 1494, 300, 38), ("abeille", 1403, 360, 38),
("fleur", 1310, 418, 38), ("arbre", 1376, 503, 37), ("biere", 157, 383, 38), ("couverts", 157, 463, 38), ("outils", 157, 543, 38),
("musique", 573, 513, 37), ("oiseaux", 684, 578, 27), ("promeneurs", 857, 388, 38), ("bols", 1036, 525, 28), ("soin", 963, 782, 28),
("entraide", 1259, 780, 28), ("reemploi", 225, 880, 28), ("garage", 1233, 992, 28), ("apaise1", 293, 352, 17), ("apaise2", 857, 860, 17)]
json.dump([{"id": b[0], "x": b[1], "y": b[2], "r": b[3], "w": wd(b[1], b[2])} for b in bulles], open("bulles_px.json", "w"))
scene = {"s": s, "W": W, "H": H, "Wm": round(W / s, 2), "Hm": round(H / s, 2), "maisons": maisons, "pal": pal, "arbres": arbres_out, "persos": persos,
"noues": noues, "liaisons": liaisons, "plantes": plantes, "sentiers": sentiers, "chemins": chemins, "etals": etals, "routes": routes,
"bulles": [{"id": b[0], "w": wd(b[1], b[2])} for b in bulles]}
json.dump(scene, open("scene.json", "w"), separators=(",", ":"))
print("maisons", len(maisons), "arbres", len(arbres_out), "(forêt ajoutée", ajouts, ") palette", len(pal), "persos", len(persos))
print("noues", len(noues), "liaisons", len(liaisons), "plantés", len(plantes), "sentiers", len(sentiers), "chemins", len(chemins), "étals", len(etals))
import os; print("scene.json %.0f Ko" % (os.path.getsize("scene.json") / 1024))
+62
View File
@@ -0,0 +1,62 @@
"""06 — Les deux sols du quartier (2035 et aujourd'hui), depuis le plan vectoriel Illustrator.
Entrées : TRANSFO_TOTAL.pdf (Illustrator, 155 calques), la planche de fond du concours (lavis),
la texture de papier aquarelle, transfo.json (recalage), scene.json (axes des routes).
Sorties : lignes2035.png, lignesauj.png, sol2035.jpg, solauj.jpg.
Principe : on éteint dans le PDF les calques qui deviendront des objets 3D (arbres, ombres,
personnages) ; pour « aujourd'hui », on éteint aussi tous les calques « 8_ NEW » et « 10_NEW ».
Le lavis (jardins vert pâle, routes grises, forêt ou champ) est multiplié sous les traits,
avec un grain de papier passe-haut (les grandes taches de la texture sont retirées).
Usage : python 06_sols.py <TOTAL.pdf> <texture_papier.jpeg> [dpi=155]
"""
import sys, json
import numpy as np
import pymupdf
from PIL import Image, ImageDraw, ImageFilter
PDF, PAPIER = sys.argv[1], sys.argv[2]
DPI = int(sys.argv[3]) if len(sys.argv) > 3 else 155
T = json.load(open("transfo.json")); s = T["s"] # px/m à 100 dpi
SC = json.load(open("scene.json"))
def rendre(eteindre, sortie):
doc = pymupdf.open(PDF)
for u in doc.layer_ui_configs():
if eteindre(u["text"]):
doc.set_layer_ui_config(u["number"], action=2) # 2 = éteint
doc[0].get_pixmap(dpi=DPI).save(sortie)
objets_3d = lambda t: any(k in t for k in ["FORET", "VEGETATION", "ombres", "OMBRES", "PERSOS", "Calque 37"]) or t == "0"
rendre(objets_3d, "lignes2035.png")
rendre(lambda t: objets_3d(t) or t.startswith("8_ NEW") or t.startswith("10_NEW"), "lignesauj.png")
L35 = Image.open("lignes2035.png").convert("RGB"); Wt, Ht = L35.size; k = Wt / 1654
tex = Image.open(PAPIER).convert("L").resize((Wt // 2, Ht // 2))
t = np.asarray(tex).astype(np.float32); lo = np.asarray(tex.filter(ImageFilter.GaussianBlur(14))).astype(np.float32)
g = np.clip((t - lo) / ((t - lo).std() + 1e-6), -2.5, 2.5)
g = np.asarray(Image.fromarray(((g + 2.5) / 5 * 255).astype(np.uint8)).resize((Wt, Ht), Image.BILINEAR)).astype(np.float32) / 255 - 0.5
pap = 1.0 + 0.035 * g
routes = {n: [(x * s + 827, z * s + 585) for x, z in pts] for n, pts in SC["routes"].items()}
largeur = {"principale": 9.5, "verticale": 7.5, "anneau": 6.5, "liaison_anneau": 6.5, "droite": 7.0}
foret = [(1335, 0), (1654, 0), (1654, 780), (1565, 752), (1160, 596), (1330, 40)] # relevé sur la planche
def lavis(couleur_est):
w = Image.new("RGB", (Wt, Ht), (239, 240, 219)); d = ImageDraw.Draw(w)
d.polygon([(x * k, y * k) for x, y in foret], fill=couleur_est)
for n, pts in routes.items():
wpx = largeur[n] * s * k
d.line([(x * k, y * k) for x, y in pts], fill=(229, 229, 224), width=int(wpx), joint="curve")
for x, y in pts:
d.ellipse([x * k - wpx / 2, y * k - wpx / 2, x * k + wpx / 2, y * k + wpx / 2], fill=(229, 229, 224))
return np.asarray(w.filter(ImageFilter.GaussianBlur(2))).astype(np.float32) * pap[..., None]
def composer(lignes, lav, sortie):
l = np.asarray(lignes.convert("RGB").resize((Wt, Ht))).astype(np.float32) / 255
Image.fromarray(np.clip(lav * l, 0, 255).astype(np.uint8)).save(sortie, quality=80, optimize=True, progressive=True)
composer(L35, lavis((226, 236, 212)), "sol2035.jpg") # forêt nourricière
composer(Image.open("lignesauj.png"), lavis((241, 236, 214)), "solauj.jpg") # aujourd'hui : un champ
print("sols écrits", Wt, "x", Ht)
+26
View File
@@ -0,0 +1,26 @@
"""07 — Les bulles d'icônes de la planche « le quartier en 2030 » (TMIP 2024, p. 15).
La carte de cette page est une image de 1 190 px (72 dpi) ; les bulles, elles, sont vectorielles.
On retire l'image de la page (rédaction PDF, images seules), on rend à 300 dpi et on découpe
chaque bulle en rond sur fond blanc. Les centres sont relevés sur la page, alignée au pixel
près sur la planche du concours (décalage mesuré : 1 px).
Usage : python 07_bulles.py <presentation_TMIP_2024.pdf> (lit bulles_px.json, écrit bulles/*.png)
"""
import sys, os, json
import pymupdf
from PIL import Image, ImageDraw, ImageFilter
doc = pymupdf.open(sys.argv[1]); page = doc[14]
page.add_redact_annot(page.rect)
page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_REMOVE, graphics=pymupdf.PDF_REDACT_LINE_ART_NONE, text=pymupdf.PDF_REDACT_TEXT_NONE)
page.get_pixmap(dpi=300).save("p15_sans_carte.png")
p = Image.open("p15_sans_carte.png").convert("RGBA")
os.makedirs("bulles", exist_ok=True)
for b in json.load(open("bulles_px.json")):
cx, cy, r = b["x"] * 3, b["y"] * 3, b["r"] * 3 + 6
c = p.crop((int(cx - r), int(cy - r), int(cx + r), int(cy + r)))
m = Image.new("L", c.size, 0); ImageDraw.Draw(m).ellipse([2, 2, c.size[0] - 3, c.size[1] - 3], fill=255)
fond = Image.new("RGBA", c.size, (255, 255, 255, 255)); fond.alpha_composite(c); fond.putalpha(m.filter(ImageFilter.GaussianBlur(1)))
fond.resize((112, 112), Image.LANCZOS).save("bulles/%s.png" % b["id"], optimize=True)
print(len(os.listdir("bulles")), "bulles")
+39
View File
@@ -0,0 +1,39 @@
"""08 — La planche du réseau hydrique alternatif, nettoyée de ses textes, pour le zoom « cycle de l'eau ».
Source : le PDF d'impression du concours Habiter Demain 2020 (page 6, dessins à 417 dpi),
identique au pixel près à la page 13 de la présentation TMIP 2024 (décalage mesuré : 0).
Les blocs de texte sont effacés : en dégradé dans la bande haute (le fond y passe du blanc au crème),
en aplat clair ailleurs. Les numéros d'étape deviennent des points actifs de la page web.
Si Jules fournit les originaux sans texte, remplacer cette étape par eux.
Usage : python 08_planche_eau.py <concours_IMPRESSION.pdf>
Sorties : eau_crop.jpg (2422 x 1290), eau_crop.json (points actifs et trajets, repère 100 dpi recadré)
"""
import sys, json, subprocess
import numpy as np
from PIL import Image
from scipy.ndimage import median_filter
subprocess.run(["pdftoppm", "-f", "6", "-l", "6", "-r", "150", "-png", sys.argv[1], "c6_150"], check=True)
im = np.asarray(Image.open("c6_150-06.png").convert("RGB")).astype(np.float32); k = 1.5
haut = [(55,28,1210,118),(85,443,305,543),(205,452,640,462),(632,452,642,532),(950,430,1113,572),(1118,428,1342,567)]
bas = [(958,568,968,652),(958,645,1182,655),(1226,560,1236,640),(78,722,248,818),(222,724,325,736),(88,925,282,1098),(230,932,495,944),
(578,936,752,952),(578,954,803,1118),(897,938,995,952),(915,954,1098,1118),(751,896,763,936),(915,905,926,938),
(1438,493,1620,752),(1410,657,1442,667),(1445,745,1622,962),(1355,752,1450,762),(1445,980,1615,1078),(1275,868,1462,878),(1453,868,1463,992),
(1158,978,1372,1090),(1165,948,1175,986),(480,1112,1220,1152),(1520,1100,1615,1145)]
for x0, y0, x1, y1 in haut:
X0, Y0, X1, Y1 = int(x0 * k), int(y0 * k), int(x1 * k), int(y1 * k)
h = median_filter(np.median(im[max(0, Y0 - 5):Y0, X0:X1], axis=0), size=(41, 1))
b = median_filter(np.median(im[Y1:Y1 + 5, X0:X1], axis=0), size=(41, 1))
t = np.linspace(0, 1, Y1 - Y0)[:, None, None]; im[Y0:Y1, X0:X1] = h[None] * (1 - t) + b[None] * t
for x0, y0, x1, y1 in bas:
X0, Y0, X1, Y1 = int(x0 * k), int(y0 * k), int(x1 * k), int(y1 * k)
a = np.concatenate([im[max(0, Y0 - 8):Y0, X0:X1].reshape(-1, 3), im[Y1:Y1 + 8, X0:X1].reshape(-1, 3), im[Y0:Y1, max(0, X0 - 8):X0].reshape(-1, 3), im[Y0:Y1, X1:X1 + 8].reshape(-1, 3)])
clair = a[a.mean(1) > 228]; im[Y0:Y1, X0:X1] = np.median(clair if len(clair) > 20 else a, axis=0)
Image.fromarray(np.clip(im, 0, 255).astype(np.uint8)).crop((30, 195, 2452, 1485)).save("eau_crop.jpg", quality=78, optimize=True, progressive=True)
pts = {"h1": (430, 790), "h2": (610, 850), "h3": (637, 612), "h4": (840, 660), "h5": (855, 800), "h6": (1130, 700), "h7": (1215, 790), "h8": (1378, 625),
"h9": (1320, 780), "h10": (1255, 880), "h11": (1105, 875), "h1b": (630, 300), "h5b": (1025, 372)}
flux = {"usees": [(592,822),(640,872),(700,925),(760,948),(830,950),(885,935),(940,895),(1000,850),(1050,805),(1090,765)], "pluie": [(585,748),(630,750),(690,758)]}
off = (20, 130)
json.dump({"W": 1615, "H": 860, "pts": {n: [x - off[0], y - off[1]] for n, (x, y) in pts.items()}, "flux": {n: [[x - off[0], y - off[1]] for x, y in l] for n, l in flux.items()}}, open("eau_crop.json", "w"))
print("planche de l'eau écrite")
+37
View File
@@ -0,0 +1,37 @@
# Pipeline « axo » — du dossier du concours à la maquette 3D
Écrit pendant le test axonométrique du 27/09/2026. Chaque étape est rejouable ; aucune géométrie n'est dessinée à la main hors des relevés signalés.
## Sources (hors Dropbox : copier dans `sources/`, ignoré par git)
| Fichier | Où dans le vault | Rôle |
|---|---|---|
| `plan de quartier-concours VP TRANSFO.3dm` | `Archives TMIP/04_ArchiJeunes/02_rhino,autocad,illus/PL-quartier transformé/` | géométrie en mètres (maisons, noues, sentiers, liaisons, étals, 3 962 blocs d'arbres et de personnages) |
| `…_TRANSFO_TOTAL.pdf` (Illustrator, **vectoriel, 155 calques**) | même dossier, `01_ai, pdf/` | le sol : ton dessin exact, calques pilotables |
| `…_TRANSFO_fond.png` | même dossier, `doc final/` | lavis de la planche finale (sert au recalage des couleurs) |
| `grey-watercolor-texture-7.jpeg` | même dossier, `00_psd textures/` | grain du papier |
| présentation TMIP 2024 (PDF) | `1 PROJETS/TMIP/TMIP présentation/` | page 15 : bulles vectorielles (la carte y est une image de 1 190 px, inutilisable en grand) |
| rendu A3 d'impression du concours 2020 | `Archives TMIP/04_ArchiJeunes/rendu/…IMPRESSION…pdf` | page 6 : planche de l'eau à 417 dpi |
À ne pas prendre : le rendu A3 « léger » (144 dpi) et ORENO 10p (200 dpi, c'est la maquette du site RE(in)NOVER de 2023, référence pour la page de couverture).
## Ordre
```
01_dump_rhino.py <TRANSFO.3dm> rhino.json # toutes les courbes et instances, en mètres
02_recalage.py # échelle et décalage Rhino -> planche (corrélation eau + parcelles) : 6,15 px/m à 100 dpi, soit 1:640
03a_maisons_candidates.py # rectangles du calque 3_ TRAITS VUS = 24 maisons existantes
04_instances_monde.py <TRANSFO.3dm> instances_monde.json # vraie position des blocs (centre de la définition x transformation)
03b_batiments_arbres.py # maisons transformées et grand bâtiment (grappes du calque d'isolation), arbres et couleurs échantillonnées
05_scene.py # scene.json en mètres monde : maisons, arbres (palette lavis), forêt densifiée, personnages, eau, sentiers, étals, routes, bulles
06_sols.py <TOTAL.pdf> <papier.jpeg> # sol2035.jpg, solauj.jpg
07_bulles.py <presentation_2024.pdf> # bulles/*.png
08_planche_eau.py <concours_IMPRESSION.pdf> # eau_crop.jpg, eau_crop.json
../../prototypes/axo-test/assembler.py # la maquette autonome
```
Contrôles visuels à regarder à chaque étape : superposition Rhino/planche (recalage), rectangles des maisons, cercles des arbres, axes des routes. Les relevés faits à l'œil (et donc à revérifier si la planche change) : axes des routes hors grande diagonale, contour de la forêt, centres des 25 bulles, points actifs et trajets de la planche de l'eau.
## Chiffres de ce premier passage
31 volumes (24 maisons existantes, 6 transformées, 1 grand bâtiment) · 3 704 arbres (2 577 de haies, 776 de forêt dont 150 ajoutés, 54 existants, 297 buissons) · 53 personnages · 11 noues · 39 liaisons maison-noue · 9 étals · 5 routes · 25 bulles · page autonome de 1,66 Mo.
+4
View File
@@ -1,3 +1,7 @@
rhino3dm
shapely
matplotlib
numpy
scipy
pillow
pymupdf