diff --git a/scripts/build-basel-panorama.mjs b/scripts/build-basel-panorama.mjs index 3ce02ad..c83a657 100644 --- a/scripts/build-basel-panorama.mjs +++ b/scripts/build-basel-panorama.mjs @@ -11,7 +11,7 @@ * 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 300 Pixel weich überblendet. + * 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 @@ -49,7 +49,17 @@ const FRONT_WATER = 0.60 const LEFT_WATER = 0.64 /** Skalierung der beiden Quellen. */ const FRONT_SCALE = 1.3 -const LEFT_SCALE = 2.45 +/** + * 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. @@ -61,8 +71,43 @@ const LEFT_SCALE = 2.45 * vor, nämlich im Mittelmotiv vom Tischende. */ const LEFT_CROP = { sx: 330, sw: 370 } -/** Breite der Überblendung zwischen Fortsetzung und Mittelmotiv. */ -const FEATHER = 320 +/** + * 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, @@ -90,39 +135,24 @@ const result = await page.evaluate(async (cfg) => { const [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)]) const fW = Math.round(fImg.width * cfg.FRONT_SCALE) - const fH = Math.round(fImg.height * cfg.FRONT_SCALE) const lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE) - const lH = Math.round(lImg.height * cfg.LEFT_SCALE) - const W = lW + fW - cfg.FEATHER + // 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 - /** Mittlere Helligkeit eines Quellbereichs. */ - function meanLuma(img, sy, sh) { - const c = document.createElement('canvas') - c.width = 48 - c.height = 48 - const cg = c.getContext('2d') - cg.drawImage(img, 0, sy, img.width, sh, 0, 0, 48, 48) - const d = cg.getImageData(0, 0, 48, 48).data - let sum = 0 - for (let i = 0; i < d.length; i += 4) sum += 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2] - return sum / (d.length / 4) - } - - // Helligkeit der Fortsetzung an das Mittelmotiv angleichen. Ohne diesen - // Abgleich bliebe trotz Überblendung ein Helligkeitssprung sichtbar. - const bandF = meanLuma(fImg, fImg.height * 0.3, fImg.height * 0.45) - const bandL = meanLuma(lImg, lImg.height * 0.3, lImg.height * 0.45) - const korrektur = Math.max(0.8, Math.min(1.25, bandF / bandL)) - /** - * Eine Quelle als Schicht über die volle Zielhöhe: das Foto an der - * Wasserlinie ausgerichtet, darüber der Himmel und darunter das Wasser aus der - * jeweiligen Randzeile gestreckt. So deckt jede Schicht die ganze Höhe ab und - * es entsteht keine harte obere oder untere Bildgrenze. + * 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, brightness) { + 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) @@ -135,17 +165,55 @@ const result = await page.evaluate(async (cfg) => { const cg = c.getContext('2d') cg.imageSmoothingEnabled = true cg.imageSmoothingQuality = 'high' - cg.save() - if (brightness !== 1) cg.filter = `brightness(${brightness.toFixed(3)})` cg.drawImage(img, sx, 0, sw, img.height, 0, y, w, h) - cg.restore() - // Himmel nach oben, Wasser nach unten strecken - if (y > 0) cg.drawImage(c, 0, y, w, 2, 0, 0, w, y + 2) - const unten = y + h - if (unten < H) cg.drawImage(c, 0, unten - 2, w, 2, 0, unten - 2, w, H - unten + 2) 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 @@ -153,33 +221,271 @@ const result = await page.evaluate(async (cfg) => { g.imageSmoothingEnabled = true g.imageSmoothingQuality = 'high' - // 1) Fortsetzung rechts, einmal verwendet, helligkeitsangeglichen. Der - // Zylinder beginnt beim Mittelmotiv und läuft nach links in den Raum - // weiter; die Bildfolge muss dieser Richtung folgen. - const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER, korrektur) - g.drawImage(fort.canvas, W - fort.w, 0) + // 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) Mittelmotiv links, unverändert, mit weicher rechter Flanke. - const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER, 1) + // 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' - const mGrad = mg.createLinearGradient(0, 0, vorne.w, 0) - mGrad.addColorStop(0, 'rgba(0,0,0,1)') - mGrad.addColorStop(1 - cfg.FEATHER / vorne.w, 'rgba(0,0,0,1)') - mGrad.addColorStop(1, 'rgba(0,0,0,0)') - mg.fillStyle = mGrad - mg.fillRect(0, 0, vorne.w, H) + mg.drawImage(maske, 0, 0) g.drawImage(mid, 0, 0) - return { dataUri: cv.toDataURL('image/jpeg', 0.84), W, H, korrektur } + 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, LEFT_CROP, + H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, FEATHER_WEICH, BAND, LEFT_CROP, SCHAERFE, }) await browser.close() @@ -187,4 +493,4 @@ 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, Helligkeitsangleichung ×${result.korrektur.toFixed(3)}`) +console.log(` ${result.W}x${result.H}, ${kb} KB`) diff --git a/src/assets/staff/basel-panorama.jpg b/src/assets/staff/basel-panorama.jpg index b4dd62f..0672c9c 100644 Binary files a/src/assets/staff/basel-panorama.jpg and b/src/assets/staff/basel-panorama.jpg differ diff --git a/src/components/staff/meetingRoomPalette.ts b/src/components/staff/meetingRoomPalette.ts index 6dc0363..cb70195 100644 --- a/src/components/staff/meetingRoomPalette.ts +++ b/src/components/staff/meetingRoomPalette.ts @@ -32,10 +32,6 @@ export const ROOM_COLORS = { tableTop: 0x8f7350, tableEdge: 0x6f5637, tableBase: 0x35322f, - /** Stuhlschalen. */ - chair: 0x2f343c, - /** Anzüge der Agenten, leicht variiert, damit die Reihe nicht uniform wirkt. */ - suits: [0x2c3444, 0x36302c, 0x2a3a3c, 0x3a2f36, 0x24303a, 0x1f2836], /** Ring um das Porträt — hebt den Kopf vom Hintergrund ab. */ headRing: 0xffffff, /** Ring bei Hover: Gold aus dem Designsystem. */ @@ -61,6 +57,12 @@ export const CAMERA = { /** Knapp hinter der vorderen Tischkante. */ z: 1.9, fov: 48, + /** + * Obergrenze des waagrechten Bildwinkels in Grad. Greift nur bei sehr breiten + * und gleichzeitig flachen Fenstern; bei üblichen Seitenverhältnissen liegt + * der waagrechte Winkel darunter und die Ansicht bleibt unverändert. + */ + maxHFov: 84, /** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */ lookAt: [0, 1.24, -5.2] as const, /** @@ -82,23 +84,19 @@ export const TABLE = { zFar: -5.75, } as const -/** Sitzende Personen: Kopfhöhe und Grössen. */ +/** Sitzende Personen: Höhe der Porträtkreise. */ export const SEATED = { /** - * Höhe der Porträtkreise über dem Boden. + * Luft zwischen Tischplatte und Unterkante des Porträtkreises (Runde 7, §2). * - * Bewusst deutlich über der Blickachse der Kamera (1.19 m). Lagen die Kreise - * auf Augenhöhe, projizierten alle fünf auf fast dieselbe Bildhöhe und die - * hinteren verschwanden hinter den vorderen — unabhängig davon, wie weit man - * sie seitlich auseinanderzog. Erst der Höhenunterschied zur Blickachse - * trennt sie in der Tiefe (§3.2). + * Daraus ergibt sich die Kopfhöhe: `TABLE.top + radius + headGap`. Die Kreise + * schweben damit nur knapp über der Tischkante statt weit darüber. Vorher + * hingen sie fest auf 1.52 m — bei den jetzt deutlich grösseren Kreisen sähe + * das aus, als schwebten Köpfe frei im Raum. + * + * Getrennt gehalten werden die Plätze in der Tiefe nicht über die Höhe, + * sondern seitlich: die Werte in `meetingRoomSeats` sind so gewählt, dass sich + * im Bild keine zwei Kreise überschneiden. */ - headY: 1.52, - headRadius: 0.142, - /** Torso vom Stuhl bis zum Hals. */ - torsoTop: 1.12, - torsoBottom: 0.42, - torsoRadiusTop: 0.19, - torsoRadiusBottom: 0.25, - shoulderWidth: 0.46, + headGap: 0.05, } as const diff --git a/src/components/staff/meetingRoomScene.ts b/src/components/staff/meetingRoomScene.ts index 9b6cfd9..4f7421e 100644 --- a/src/components/staff/meetingRoomScene.ts +++ b/src/components/staff/meetingRoomScene.ts @@ -123,7 +123,9 @@ function addRoomShell(scene: THREE.Scene) { new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }), ) skirting.rotation.y = -Math.PI / 2 - skirting.position.set(halfW - 0.01, 0.04, -depth / 2) + // Eine Idee vor dem Paneel: auf gleicher Höhe streiten sich die beiden + // Flächen um dieselben Bildpunkte, und die Kante franst sichtbar aus. + skirting.position.set(halfW - 0.015, 0.04, -depth / 2) scene.add(skirting) } @@ -148,16 +150,27 @@ function addRoomShell(scene: THREE.Scene) { function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAnisotropy: number) { /** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */ const radius = 62 + /** + * Masse der Panoramadatei. Ändert sich das Bild, gehören die Zahlen + * nachgeführt — sonst zieht der Zylinder es in die Länge oder staucht es. + */ + const panorama = { breite: 1028, hoehe: 660 } /** * Abgedeckter Bogen. Er beginnt rechts neben der Frontscheibe und läuft über * die Front nach links bis hinter die linke Glasfassade — mehr braucht es * nicht, und weniger Bogen heisst mehr Bildpunkte je Grad. + * + * Der Wert hält die Bildpunkte je Grad konstant: das Panorama ist seit + * Runde 7 schmaler, weil die Fortsetzung nicht mehr überskaliert wird, und + * der Bogen schrumpft im selben Verhältnis mit. Die Frontscheibe zeigt + * dadurch unverändert denselben Ausschnitt in derselben Grösse (§1: der + * Gesamteindruck bleibt, nur die Unsauberkeiten verschwinden). */ - const arc = 2.62 - /** Höhe passend zum Seitenverhältnis des Panoramas (1523 × 660). */ - const height = (radius * arc) / (1523 / 660) + const arc = 1.514 + /** Höhe passend zum Seitenverhältnis des Panoramas. */ + const height = (radius * arc) / (panorama.breite / panorama.hoehe) /** Wasserlinie im Panorama, als Anteil von oben. */ - const waterFraction = 400 / 660 + const waterFraction = 400 / panorama.hoehe /** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */ const waterY = -7.5 @@ -179,7 +192,7 @@ function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAn mesh.position.y = waterY - height * (0.5 - waterFraction) /** * Drehung: geradeaus sichtbar ist die Bildstelle u = (π − Drehung) / Bogen. - * Der Abzug von 0.68 setzt damit u ≈ 0.26 in die Blickachse — dort steht im + * Der Abzug von 0.68 setzt damit u ≈ 0.45 in die Blickachse — dort steht im * Panorama das Münster. Die Frontscheibe zeigt so das gute Motiv vom * Tischende, die linke Fassade seine Fortsetzung nach links. */ @@ -310,15 +323,20 @@ function addSeatedAgent( : seat.side === SeatSide.LEFT ? 0.42 : -0.42 + // Radius und Höhe stammen vom Platz: näher sitzende Agenten haben einen + // kleineren Kreis, erscheinen im Bild aber grösser (Runde 7, §2). + const radius = seat.radius + const kopfHoehe = TABLE.top + radius + SEATED.headGap + const head = new THREE.Group() - head.position.y = SEATED.headY + head.position.y = kopfHoehe head.rotation.y = yaw const ring = new THREE.Mesh( - new THREE.CircleGeometry(SEATED.headRadius + 0.016, 48), + new THREE.CircleGeometry(radius * 1.06, 48), new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }), ) - ring.position.z = -0.004 + ring.position.z = -0.006 head.add(ring) if (photoUrl) { @@ -343,7 +361,7 @@ function addSeatedAgent( tex.magFilter = THREE.LinearFilter const face = new THREE.Mesh( - new THREE.CircleGeometry(SEATED.headRadius, 48), + new THREE.CircleGeometry(radius, 96), new THREE.MeshBasicMaterial({ map: tex }), ) head.add(face) @@ -360,12 +378,12 @@ function addSeatedAgent( * Klick auf Livia bei Ferdi. Die Trefferfläche deckt sich jetzt mit dem, was * man sieht. */ - new THREE.SphereGeometry(SEATED.headRadius * 1.18, 12, 8), + new THREE.SphereGeometry(radius * 1.1, 12, 8), // Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde // das Objekt aus der Strahlprüfung nehmen. new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }), ) - hitBox.position.y = SEATED.headY + hitBox.position.y = kopfHoehe group.add(hitBox) scene.add(group) @@ -380,7 +398,7 @@ function addSeatedAgent( // die Plätze dort auf wenige Bildpunkte zusammenrücken (§8). labelAnchor: new THREE.Vector3( seat.x, - SEATED.headY + SEATED.headRadius + 0.16 + Math.min(0.78, Math.max(0, (Math.abs(seat.z) - 1.75) * 0.135)), + kopfHoehe + radius + 0.10 + Math.min(0.34, Math.max(0, (Math.abs(seat.z) - 0.9) * 0.06)), seat.z, ), } @@ -459,7 +477,15 @@ export function createMeetingRoomScene( // schneidet das hohe Format den Tisch links und rechts ab. Statt einer // eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die // Kamera, bis der Raum wieder hineinpasst. - camera.fov = CAMERA.fov + Math.max(0, (1.45 - aspect) * 26) + const weit = CAMERA.fov + Math.max(0, (1.45 - aspect) * 26) + /* + * Nach oben begrenzt durch den waagrechten Bildwinkel. Bei sehr breiten, + * flachen Fenstern — etwa 1280 × 380 auf einem Notebook — griffe sonst ein + * waagrechter Winkel von über 110°: der Raum wirkt gezerrt, und der Blick + * reicht seitlich über den Rand des Panoramas hinaus. + */ + const maxTan = Math.tan((CAMERA.maxHFov / 2) * Math.PI / 180) / aspect + camera.fov = Math.min(weit, 2 * Math.atan(maxTan) * 180 / Math.PI) camera.updateProjectionMatrix() }, diff --git a/src/components/staff/meetingRoomSeats.ts b/src/components/staff/meetingRoomSeats.ts index c1c60db..83c1e22 100644 --- a/src/components/staff/meetingRoomSeats.ts +++ b/src/components/staff/meetingRoomSeats.ts @@ -39,34 +39,54 @@ export interface MeetingSeat { /** Sitzposition in Metern. */ x: number z: number + /** Radius des Porträtkreises in Metern. */ + radius: number } +/** Ein Platz, so wie er unten eingetragen wird. */ +type Platz = { side: SeatSide; x: number; z: number; radius: number } + /** - * Sitzplätze der fünf Agenten, in Metern (Runde 6, §3.2). + * Sitzplätze der fünf Agenten, in Metern (Runde 6 §3.2, Runde 7 §2). * * Ferdi und Bruno sind die direkten Nachbarn des Betrachters — einer links, - * einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dadurch - * entsteht hinter ihnen Platz, in dem die übrigen drei gestaffelt Raum haben, - * ohne sich zu überlagern. + * einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dahinter + * staffeln sich die übrigen drei. * - * Vorher standen alle fünf zu weit hinten und drängten sich am Fluchtpunkt. + * Die Radien wachsen nach hinten, die **Bildgrösse** nimmt trotzdem mit der + * Tiefe ab: ein Kreis von 0.30 m in 5 m Entfernung erscheint kleiner als einer + * von 0.265 m in 3 m. Ohne diese Gegensteuerung wären die hinteren Porträts bei + * gleichem Radius kaum noch erkennbar (§2: «tiefenlogisch», aber «insgesamt klar + * besser sichtbar»). + * + * Alle Werte sind gemeinsam gewählt, nicht einzeln: mit den Kamerawerten aus + * `meetingRoomPalette` durchgerechnet ergeben sie gegenüber dem Stand vor dieser + * Runde die Faktoren 2.11× (Ferdi/Bruno), 2.22× (Livia/Nora), 2.50× (Sina) und + * 3.18× (Thomas) — und dabei **keine** einzige Überschneidung zweier Kreise im + * Bild. Wer einen Wert ändert, verschiebt beides; die Kreise überlappen dann + * schnell wieder. */ -const PLAETZE: Record = { - ferdi: { side: SeatSide.LEFT, x: -1.14, z: -1.20 }, - bruno: { side: SeatSide.RIGHT, x: 1.14, z: -1.20 }, - livia: { side: SeatSide.LEFT, x: -1.42, z: -2.90 }, - nora: { side: SeatSide.RIGHT, x: 1.42, z: -2.90 }, - sina: { side: SeatSide.LEFT, x: -1.30, z: -4.75 }, +const PLAETZE: Record = { + ferdi: { side: SeatSide.LEFT, x: -1.16, z: -0.88, radius: 0.268 }, + bruno: { side: SeatSide.RIGHT, x: 1.16, z: -0.88, radius: 0.268 }, + livia: { side: SeatSide.LEFT, x: -1.32, z: -3.35, radius: 0.345 }, + nora: { side: SeatSide.RIGHT, x: 1.32, z: -3.35, radius: 0.345 }, + sina: { side: SeatSide.LEFT, x: -0.95, z: -5.50, radius: 0.395 }, } /** * Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt * lautlos im Nichts. */ -const FALLBACK = { side: SeatSide.RIGHT, x: 1.30, z: -4.75 } +const FALLBACK: Platz = { side: SeatSide.RIGHT, x: 0.95, z: -5.50, radius: 0.395 } -/** Kopfende gegenüber. Unverändert: die Position wirkt gut (§3.2). */ -const HEAD_Z = -6.75 +/** + * Kopfende gegenüber. Position unverändert, nur der Kreis wächst kräftig: von + * dort aus ist der Abstand am grössten, und Thomas war zuvor kaum auszumachen + * (§2). Grösser darf er nicht werden — sein Kreis überträfe im Bild sonst den + * von Sina, die näher sitzt, und die Tiefenwirkung kippte. + */ +const KOPFENDE: Platz = { side: SeatSide.HEAD, x: 0, z: -6.75, radius: 0.452 } export const MEETING_SEATS: MeetingSeat[] = [ ...AGENT_WORKSPACES.map(agent => ({ @@ -81,8 +101,6 @@ export const MEETING_SEATS: MeetingSeat[] = [ name: AGENT_SUPERVISOR.name, role: AGENT_SUPERVISOR.role, path: AGENT_SUPERVISOR.path, - side: SeatSide.HEAD, - x: 0, - z: HEAD_Z, + ...KOPFENDE, }, ]