/** * Erzeugt das Basler Panorama für den 3D-Meetingraum (Runde 6, §2). * * Vorher lagen zwei getrennte Fotoflächen vor den Glasfronten — vorne und * links. An deren Stossstelle war auf der linken Fensterseite eine harte Kante * sichtbar. Diese Naht lässt sich nicht wegblenden; sie verschwindet nur, wenn * es gar keine zwei Bilder mehr gibt. * * Dieses Skript baut aus den beiden Referenzfotos des Auftrags eine einzige * durchgehende Panoramadatei. Jede Quelle wird **genau einmal** verwendet — * kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende * («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe * unverändert; die Panoramaaufnahme («left») setzt es nach links fort, in * Helligkeit angeglichen und über eine schmale Zone überblendet. * * Himmel und Wasser werden anschliessend aus der jeweiligen Randzeile nach oben * und unten gestreckt. Ohne das stünde über und unter dem Bildstreifen eine * harte Grenze, die im Raum als Kante sichtbar wäre. * * Einmalig auszuführen; das Ergebnis liegt als Asset im Repository: * * node scripts/build-basel-panorama.mjs * * Es braucht keine Bildbibliothek — die Komposition entsteht auf einem Canvas * im bereits vorhandenen Chrome, den auch die visuellen Prüfungen nutzen. */ import { chromium } from 'playwright-core' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' const here = dirname(fileURLToPath(import.meta.url)) const assets = resolve(here, '../src/assets/staff') const SRC_FRONT = resolve(assets, 'basel-front.jpg') const SRC_LEFT = resolve(assets, 'basel-left.jpg') const OUT = resolve(assets, 'basel-panorama.jpg') /** * Zielmasse. Die Breite ergibt sich aus den beiden Quellen: jede einmal, * abzüglich der Überblendung. Grösser wäre reines Hochskalieren. */ const H = 660 /** Wasserlinie im Ziel — beide Quellen werden darauf ausgerichtet. */ const WATER_Y = 400 /** Wasserlinie in den Quellen, als Anteil der Bildhöhe. */ const FRONT_WATER = 0.60 const LEFT_WATER = 0.64 /** Skalierung der beiden Quellen. */ const FRONT_SCALE = 1.3 /** * Massstab der Fortsetzung (Runde 7, §1). * * Nicht frei gewählt, sondern aus den Fotos gemessen: das Münster steht in * basel-front 106 Pixel über der Wasserlinie, in basel-left 130. Damit * dieselben Häuser in beiden Bildhälften gleich gross erscheinen, muss die * Fortsetzung mit 1.3 × 106/130 laufen. Vorher stand hier 2.45 — daher der * Massstabssprung an der Nahtstelle, bei dem die linken Häuser doppelt so hoch * wirkten wie die vorderen. */ const LEFT_SCALE = 1.06 /** * Ausschnitt der Fortsetzung, in Pixeln der Quelle. * * Beide Referenzfotos zeigen das Münster. Nebeneinander gesetzt stünde es * zweimal im Bild — eine Doppelstruktur, die §2.1 ausdrücklich ausschliesst. * Aus der Panoramaaufnahme wird deshalb nur der Abschnitt östlich des Münsters * verwendet: die Häuserzeilen am Rhein. Das Münster selbst kommt genau einmal * vor, nämlich im Mittelmotiv vom Tischende. */ const LEFT_CROP = { sx: 330, sw: 370 } /** * Breite der Überblendung zwischen Mittelmotiv und Fortsetzung. * * Bewusst schmal (Runde 7, §1). Eine breite Überblendung mischt zwei * verschiedene Häuserzeilen ineinander: beide bleiben halbdurchsichtig sichtbar * und es entstehen «Geisterhäuser», die vor und über anderen Gebäuden stehen. * Ein Überblenden funktioniert nur bei deckungsgleichem Inhalt — den gibt es * hier nicht — schon 64 Pixel liessen ein blasses Dach über der echten Zeile * stehen. Praktisch hart getrennt liest sich die Stelle als Übergang zwischen * zwei Häuserblöcken am Fluss, und jedes Gebäude bleibt eindeutig. */ const FEATHER = 6 /** Überblendbreite in Himmel und Wasser — dort ist das Bild strukturlos. */ const FEATHER_WEICH = 300 /** * Höhenband der Häuserzeile im fertigen Bild, in Pixeln. * * Innerhalb dieses Bands wird schmal überblendet und nachgeschärft, ausserhalb * breit überblendet und nicht geschärft. Die Grenzen folgen aus der Wasserlinie * bei 400 und den Dachfirsten darüber. * * Die obere Grenze liegt über **allen** Dächern beider Aufnahmen, auch über den * Dachaufbauten. Lag sie tiefer, schnitt sie die Häuserzeile des Mittelmotivs * waagrecht durch, und deren Gauben blieben als Reihe schwebender Klötzchen im * Himmel stehen. */ const BAND = { oben: 220, obenEnde: 238, unten: 398, untenEnde: 420 } /** * Nachschärfung (Unschärfemaske) gegen die Weichheit des Hochskalierens. * * Die Fortsetzung stammt aus einem kleinen Panoramafoto und wird auf ein * Mehrfaches vergrössert; ohne Nachschärfung verlieren die Dachkanten ihre * Kontur. `staerke` ist der Anteil der zurückgegebenen Kantenzeichnung. */ const SCHAERFE = { staerke: 0.85, radius: 1.1 } const CHROME = [ process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', ].filter(Boolean).find(p => existsSync(p)) if (!CHROME) throw new Error('Kein Chrome gefunden — CHROME_PATH setzen.') for (const f of [SRC_FRONT, SRC_LEFT]) { if (!existsSync(f)) throw new Error(`Referenzbild fehlt: ${f}`) } const asDataUri = f => `data:image/jpeg;base64,${readFileSync(f).toString('base64')}` const browser = await chromium.launch({ executablePath: CHROME }) const page = await browser.newPage() const result = await page.evaluate(async (cfg) => { const load = src => new Promise((ok, err) => { const img = new Image() img.onload = () => ok(img) img.onerror = err img.src = src }) const [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)]) const fW = Math.round(fImg.width * cfg.FRONT_SCALE) const lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE) // Die beiden Bilder überlappen sich geometrisch über die volle weiche Breite. // Nur so lässt sich in Himmel und Wasser breit überblenden — ausserhalb der // Überlappung gäbe es nichts, worauf übergeblendet werden könnte, und es // entstünde ein durchsichtiger und damit schwarzer Streifen. const UEBER = cfg.FEATHER_WEICH const W = lW + fW - UEBER const H = cfg.H /** * Eine Quelle als Schicht, an der Wasserlinie ausgerichtet. * * Bewusst **ohne** senkrechtes Strecken über den Bildrand hinaus. Ein * gestreckter Streifen Wasser zerfällt in Flecken und Striche. Was oben und * unten fehlt, füllt weiter unten der Zeilenabgleich sauber auf. */ function schicht(img, crop, scale, waterFraction) { const sx = crop ? crop.sx : 0 const sw = crop ? crop.sw : img.width const w = Math.round(sw * scale) const h = Math.round(img.height * scale) const y = Math.round(cfg.WATER_Y - img.height * waterFraction * scale) const c = document.createElement('canvas') c.width = w c.height = H const cg = c.getContext('2d') cg.imageSmoothingEnabled = true cg.imageSmoothingQuality = 'high' cg.drawImage(img, sx, 0, sw, img.height, 0, y, w, h) return { canvas: c, w } } /** * Helligkeit der Fortsetzung **zeilenweise** an das Mittelmotiv angleichen * (Runde 7, §1). * * Ein einziger Faktor für das ganze Bild reicht nicht: die beiden Aufnahmen * unterscheiden sich im Himmel anders als in der Häuserzeile und im Wasser. * Der übrig bleibende Unterschied stand als waagrechte Tonkante quer durch den * Himmel. Verglichen wird nur dort, wo beide Bilder tatsächlich übereinander * liegen; jede Zeile bekommt ihren eigenen Faktor. */ function tonAngleichen(fort, vorne, abX) { const vonX = abX + Math.round(UEBER * 0.2) const bisX = abX + Math.round(UEBER * 0.8) const fb = fort.canvas.getContext('2d').getImageData(0, 0, fort.w, H) const vb = vorne.canvas.getContext('2d').getImageData(0, 0, vorne.w, H) /* * Ein gemeinsamer Faktor für alle drei Kanäle, kein eigener je Kanal. * Kanalweise verglichen werden Inhalte, die einander nicht entsprechen — * an derselben Stelle stehen im einen Bild Bäume, im anderen Hausfassaden. * Die Verhältnisse laufen dann auseinander und färben die Fortsetzung. */ let letzterFaktor = 1 for (let y = 0; y < H; y++) { let sf = 0, sv = 0, n = 0 for (let x = vonX; x < bisX; x++) { const i = (y * fort.w + (x - abX)) * 4 const j = (y * vorne.w + x) * 4 if (fb.data[i + 3] < 250 || vb.data[j + 3] < 250) continue sf += 0.2126 * fb.data[i] + 0.7152 * fb.data[i + 1] + 0.0722 * fb.data[i + 2] sv += 0.2126 * vb.data[j] + 0.7152 * vb.data[j + 1] + 0.0722 * vb.data[j + 2] n++ } // Zeilen ohne Überlappung übernehmen den Faktor der letzten vergleichbaren. if (n >= 20 && sf >= 1) letzterFaktor = Math.max(0.75, Math.min(1.35, sv / sf)) for (let x = 0; x < fort.w; x++) { const i = (y * fort.w + x) * 4 for (let c = 0; c < 3; c++) { const wert = fb.data[i + c] * letzterFaktor fb.data[i + c] = wert > 255 ? 255 : wert } } } fort.canvas.getContext('2d').putImageData(fb, 0, 0) } const cv = document.createElement('canvas') cv.width = W cv.height = H const g = cv.getContext('2d') g.imageSmoothingEnabled = true g.imageSmoothingQuality = 'high' // 1) Beide Schichten aufbauen und die Fortsetzung zeilenweise im Ton // angleichen, bevor irgendetwas zusammengesetzt wird. const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER) const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER) tonAngleichen(fort, vorne, W - fort.w) // 2) Fortsetzung rechts, darüber das Mittelmotiv mit höhenabhängig weicher // rechter Flanke. Der Zylinder beginnt beim Mittelmotiv und läuft nach // links in den Raum weiter; die Bildfolge muss dieser Richtung folgen. /* * Die Fortsetzung trägt **nur** die Häuserzeile bei (Runde 7, §1). * * Darüber und darunter wird sie ausgeblendet. Ihr Himmel ist milchig-weiss, * der des Mittelmotivs kräftig blau; nebeneinander stand er als heller Block * im Bild, und eine Helligkeitsangleichung holt die fehlende Farbsättigung * nicht zurück. Ihr Wasser wiederum endet früh. Beides deckt weiter unten der * Zeilenabgleich sauber ab — er verlängert Himmel und Wasser des * Mittelmotivs über die volle Breite. */ const fk = fort.canvas.getContext('2d') const fbild = fk.getImageData(0, 0, fort.w, H) const fd = fbild.data /* * Oben: der Himmel wird **spaltenweise entlang der echten Dachkante** * entfernt, nicht mit einer waagrechten Linie abgeschnitten. Eine gerade * Linie liegt entweder über den Dächern — dann bleibt ein heller Streifen * Fremdhimmel stehen — oder sie schneidet mitten durch die Dächer. * * Gesucht wird je Spalte die erste Zeile, die sich deutlich von der * Himmelsfarbe ganz oben in derselben Spalte unterscheidet. Drei Zeilen in * Folge müssen abweichen, damit eine Wolke die Kante nicht vortäuscht. */ const obenRand = new Array(fort.w).fill(0) for (let x = 0; x < fort.w; x++) { let y0 = 0 while (y0 < H && fd[(y0 * fort.w + x) * 4 + 3] < 250) y0++ if (y0 >= H) { obenRand[x] = H; continue } /* * Verglichen wird mit der Zeile acht darüber, nicht mit der obersten Zeile * der Spalte. Der Himmel verläuft von oben nach unten langsam von blau nach * dunstig; gegen einen festen Bezugswert überschreitet allein dieser Verlauf * irgendwann jede Schwelle, und die Kante läge zu hoch. Ein Dachrand * dagegen ändert die Farbe auf wenigen Zeilen sprunghaft. */ let treffer = 0 let kante = H for (let y = y0 + 8; y < H; y++) { const i = (y * fort.w + x) * 4 const o = ((y - 8) * fort.w + x) * 4 const sprung = Math.abs(fd[i] - fd[o]) + Math.abs(fd[i + 1] - fd[o + 1]) + Math.abs(fd[i + 2] - fd[o + 2]) treffer = sprung > 60 ? treffer + 1 : 0 if (treffer >= 2) { kante = y - 9; break } } obenRand[x] = kante } /* * Median statt Mittelwert über fünf Spalten. Ein Mittelwert zieht die Kante * an jedem Dachabsatz zu weit nach oben, und darüber blieben kleine Fetzen * fremden Himmels als Flecken stehen. Der Median folgt der Dachlinie und * bügelt trotzdem einzelne Ausreisser aus. */ const geglaettet = obenRand.map((_, x) => { const fenster = [] for (let k = -2; k <= 2; k++) if (obenRand[x + k] !== undefined) fenster.push(obenRand[x + k]) fenster.sort((a, b) => a - b) return fenster[Math.floor(fenster.length / 2)] }) /** Himmelsfarbe je Spalte, gemessen an der obersten Bildzeile der Schicht. */ const himmel = [] for (let x = 0; x < fort.w; x++) { let y0 = 0 while (y0 < H && fd[(y0 * fort.w + x) * 4 + 3] < 250) y0++ const i = (y0 * fort.w + x) * 4 himmel.push(y0 < H ? [fd[i], fd[i + 1], fd[i + 2]] : [0, 0, 0]) } // Unten endet die Fortsetzung kurz unter der Wasserlinie; darunter übernimmt // das Wasser des Mittelmotivs, das viel weiter hinunterreicht. const untenAb = cfg.BAND.untenEnde + 10 const untenBis = cfg.BAND.untenEnde + 40 for (let x = 0; x < fort.w; x++) { const kante = geglaettet[x] const ref = himmel[x] for (let y = 0; y < H; y++) { const i = (y * fort.w + x) * 4 let obenA = Math.max(0, Math.min(1, (y - (kante - 2)) / 5)) /* * Sicherheitsnetz dicht unter der Kante: was dort noch Himmelsfarbe hat, * ist Himmel und verschwindet ebenfalls. Sonst bleiben an Dachabsätzen * einzelne helle Flecken zwischen den Häusern stehen. */ if (obenA > 0 && y < kante + 14) { const abstand = Math.abs(fd[i] - ref[0]) + Math.abs(fd[i + 1] - ref[1]) + Math.abs(fd[i + 2] - ref[2]) if (abstand < 70) obenA = 0 } const untenA = y < untenAb ? 1 : Math.max(0, 1 - (y - untenAb) / (untenBis - untenAb)) fd[i + 3] = Math.round(fd[i + 3] * obenA * untenA) } } fk.putImageData(fbild, 0, 0) g.drawImage(fort.canvas, W - fort.w, 0) const mid = document.createElement('canvas') mid.width = vorne.w mid.height = H const mg = mid.getContext('2d') mg.drawImage(vorne.canvas, 0, 0) /* * Überblendung mit höhenabhängiger Breite (Runde 7, §1). * * Über der Häuserzeile und unter der Wasserlinie ist das Bild strukturlos — * dort wird über die volle Überlappung übergeblendet, und nur so verschwindet * der Helligkeitssprung im Himmel, den die Angleichung sonst als senkrechte * Kante stehen lässt. In der Häuserzeile selbst muss die Überblendung schmal * sein: dort stehen zwei verschiedene Häuserreihen übereinander, und jede * Breite mischt sie zu halbdurchsichtigen «Geisterhäusern». * * Beide Übergänge haben dieselbe Mitte, damit die Trennlinie über die ganze * Bildhöhe an derselben Stelle liegt und nicht schräg verläuft. */ const mitte = vorne.w - UEBER / 2 const maske = document.createElement('canvas') maske.width = vorne.w maske.height = H const kg = maske.getContext('2d') for (let y = 0; y < H; y++) { const imBand = y < cfg.BAND.oben ? 0 : y < cfg.BAND.obenEnde ? (y - cfg.BAND.oben) / (cfg.BAND.obenEnde - cfg.BAND.oben) : y < cfg.BAND.unten ? 1 : y < cfg.BAND.untenEnde ? 1 - (y - cfg.BAND.unten) / (cfg.BAND.untenEnde - cfg.BAND.unten) : 0 /* * Ausserhalb der Häuserzeile reicht das Mittelmotiv bis an seinen eigenen * rechten Rand: dort trägt die Fortsetzung nichts mehr bei, weil sie oben * und unten ausgeblendet ist. Innerhalb der Zeile wandert die Trennung in * die Mitte der Überlappung und wird praktisch hart. */ const x0 = (vorne.w - 4) + (mitte - (vorne.w - 4)) * imBand const b0 = 8 + (cfg.FEATHER - 8) * imBand const grad = kg.createLinearGradient(x0 - b0 / 2, 0, x0 + b0 / 2, 0) grad.addColorStop(0, 'rgba(0,0,0,1)') grad.addColorStop(1, 'rgba(0,0,0,0)') kg.fillStyle = grad kg.fillRect(0, y, vorne.w, 1) } // Die Maske entsteht zuerst vollständig auf einer eigenen Fläche. Zeilenweise // direkt mit `destination-in` zu arbeiten ginge nicht: dieser Modus wirkt auf // die ganze Fläche und löschte bei jeder Zeile alle übrigen. mg.globalCompositeOperation = 'destination-in' mg.drawImage(maske, 0, 0) g.drawImage(mid, 0, 0) const bild = g.getImageData(0, 0, W, H) const d = bild.data /* * 3) Freie Stellen zeilenweise auffüllen. * * Oben und unten reichen die Fotos nicht über die volle Höhe, und rechts endet * die Fortsetzung früher als das Mittelmotiv. Diese Stellen sind Himmel oder * Wasser: beides ist waagrecht nahezu einfarbig. Sie bekommen deshalb die * Durchschnittsfarbe ihrer eigenen Zeile, zum Rand hin weich an die dort * tatsächlich vorhandenen Bildpunkte angeglichen. Das ergibt eine ruhige * Fortsetzung ohne Striche, Flecken oder Wiederholungen. */ // Erst je Zeile die Durchschnittsfarbe der echten Bildpunkte bestimmen. const mittelwerte = new Array(H).fill(null) const luecken = new Array(H).fill(0) for (let y = 0; y < H; y++) { const zeile = y * W * 4 let sr = 0, sg = 0, sb = 0, n = 0 for (let x = 0; x < W; x++) { if (d[zeile + x * 4 + 3] > 250) { sr += d[zeile + x * 4]; sg += d[zeile + x * 4 + 1]; sb += d[zeile + x * 4 + 2]; n++ } } if (n > 0) mittelwerte[y] = [sr / n, sg / n, sb / n] luecken[y] = W - n } // Zeilen ganz ohne Bildpunkte — ganz oben und ganz unten — übernehmen die // Farbe der nächsten Zeile, die welche hat. Ein fester Ersatzwert stünde dort // sonst als grauer Streifen quer über dem Himmel. for (let y = 0; y < H; y++) if (!mittelwerte[y]) { for (let k = 1; k < H; k++) { if (y + k < H && mittelwerte[y + k]) { mittelwerte[y] = mittelwerte[y + k]; break } if (y - k >= 0 && mittelwerte[y - k]) { mittelwerte[y] = mittelwerte[y - k]; break } } } for (let y = 0; y < H; y++) { if (luecken[y] === 0) continue const zeile = y * W * 4 const mittel = mittelwerte[y] // Farbe des nächstgelegenen echten Bildpunkts links und rechts merken for (let x = 0; x < W; x++) { const i = zeile + x * 4 if (d[i + 3] > 250) continue /* * Nur die unmittelbare Nachbarschaft zählt. Wurde weiter gesucht, trug * die Füllung die Farbe des nächsten Dachs waagrecht in den Himmel * hinein — als lange helle Schlieren über der Häuserzeile (Runde 7, §1). * Ein paar Bildpunkte reichen, um die Kante weich zu machen; alles * weitere bekommt die ruhige Durchschnittsfarbe der Zeile. */ let nachbar = null for (let dx = 1; dx <= 12 && !nachbar; dx++) { for (const xx of [x - dx, x + dx]) { if (xx >= 0 && xx < W && d[zeile + xx * 4 + 3] > 250) { nachbar = { c: [d[zeile + xx * 4], d[zeile + xx * 4 + 1], d[zeile + xx * 4 + 2]], dist: dx } break } } } /* * Der Nachbar zählt nur, wenn er der Zeile ähnlich sieht. Ein Dach oder * ein Kamin am Rand der Lücke färbte die Füllung sonst ein und zog seine * Farbe als helle Schliere waagrecht in den Himmel. */ const passend = nachbar && Math.abs(nachbar.c[0] - mittel[0]) + Math.abs(nachbar.c[1] - mittel[1]) + Math.abs(nachbar.c[2] - mittel[2]) < 90 const t = passend ? Math.min(1, nachbar.dist / 10) : 1 const quelle = passend ? nachbar.c : mittel for (let c = 0; c < 3; c++) d[i + c] = quelle[c] * (1 - t) + mittel[c] * t d[i + 3] = 255 } } g.putImageData(bild, 0, 0) // 4) Nachschärfen (Unschärfemaske): das Bild minus seiner weichgezeichneten // Fassung ergibt die Kantenzeichnung; sie wird verstärkt zurückgegeben. // Das holt die Dachkanten der hochskalierten Häuserzeile zurück (§1). const weich = document.createElement('canvas') weich.width = W weich.height = H const wg = weich.getContext('2d') wg.filter = `blur(${cfg.SCHAERFE.radius}px)` wg.drawImage(cv, 0, 0) const oBild = g.getImageData(0, 0, W, H) const wBild = wg.getImageData(0, 0, W, H) const o = oBild.data const w = wBild.data // Nur im Strukturband schärfen. Im Himmel brächte Schärfen nichts, im Wasser // verstärkte es jede Unruhe der aufgefüllten Fläche. for (let y = cfg.BAND.oben; y < cfg.BAND.untenEnde && y < H; y++) { const rand = Math.min(y - cfg.BAND.oben, cfg.BAND.untenEnde - y, 24) / 24 const k = cfg.SCHAERFE.staerke * Math.max(0, rand) for (let x = 0; x < W; x++) { const i = (y * W + x) * 4 for (let c = 0; c < 3; c++) { const wert = o[i + c] + k * (o[i + c] - w[i + c]) o[i + c] = wert < 0 ? 0 : wert > 255 ? 255 : wert } } } g.putImageData(oBild, 0, 0) return { dataUri: cv.toDataURL('image/jpeg', 0.9), W, H } }, { front: asDataUri(SRC_FRONT), left: asDataUri(SRC_LEFT), H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, FEATHER_WEICH, BAND, LEFT_CROP, SCHAERFE, }) await browser.close() writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64')) const kb = (readFileSync(OUT).length / 1024).toFixed(0) console.log(`Panorama erzeugt: ${OUT}`) console.log(` ${result.W}x${result.H}, ${kb} KB`)