Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55274488d1 | ||
|
|
403161283c |
No files matched your search
@@ -11,7 +11,7 @@
|
|||||||
* kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende
|
* kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende
|
||||||
* («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe
|
* («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe
|
||||||
* unverändert; die Panoramaaufnahme («left») setzt es nach links fort, in
|
* 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
|
* Himmel und Wasser werden anschliessend aus der jeweiligen Randzeile nach oben
|
||||||
* und unten gestreckt. Ohne das stünde über und unter dem Bildstreifen eine
|
* 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
|
const LEFT_WATER = 0.64
|
||||||
/** Skalierung der beiden Quellen. */
|
/** Skalierung der beiden Quellen. */
|
||||||
const FRONT_SCALE = 1.3
|
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.
|
* Ausschnitt der Fortsetzung, in Pixeln der Quelle.
|
||||||
@@ -61,8 +71,43 @@ const LEFT_SCALE = 2.45
|
|||||||
* vor, nämlich im Mittelmotiv vom Tischende.
|
* vor, nämlich im Mittelmotiv vom Tischende.
|
||||||
*/
|
*/
|
||||||
const LEFT_CROP = { sx: 330, sw: 370 }
|
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 = [
|
const CHROME = [
|
||||||
process.env.CHROME_PATH,
|
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 [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)])
|
||||||
|
|
||||||
const fW = Math.round(fImg.width * cfg.FRONT_SCALE)
|
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 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
|
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
|
* Eine Quelle als Schicht, an der Wasserlinie ausgerichtet.
|
||||||
* 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
|
* Bewusst **ohne** senkrechtes Strecken über den Bildrand hinaus. Ein
|
||||||
* es entsteht keine harte obere oder untere Bildgrenze.
|
* 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 sx = crop ? crop.sx : 0
|
||||||
const sw = crop ? crop.sw : img.width
|
const sw = crop ? crop.sw : img.width
|
||||||
const w = Math.round(sw * scale)
|
const w = Math.round(sw * scale)
|
||||||
@@ -135,17 +165,55 @@ const result = await page.evaluate(async (cfg) => {
|
|||||||
const cg = c.getContext('2d')
|
const cg = c.getContext('2d')
|
||||||
cg.imageSmoothingEnabled = true
|
cg.imageSmoothingEnabled = true
|
||||||
cg.imageSmoothingQuality = 'high'
|
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.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 }
|
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')
|
const cv = document.createElement('canvas')
|
||||||
cv.width = W
|
cv.width = W
|
||||||
cv.height = H
|
cv.height = H
|
||||||
@@ -153,33 +221,271 @@ const result = await page.evaluate(async (cfg) => {
|
|||||||
g.imageSmoothingEnabled = true
|
g.imageSmoothingEnabled = true
|
||||||
g.imageSmoothingQuality = 'high'
|
g.imageSmoothingQuality = 'high'
|
||||||
|
|
||||||
// 1) Fortsetzung rechts, einmal verwendet, helligkeitsangeglichen. Der
|
// 1) Beide Schichten aufbauen und die Fortsetzung zeilenweise im Ton
|
||||||
// Zylinder beginnt beim Mittelmotiv und läuft nach links in den Raum
|
// angleichen, bevor irgendetwas zusammengesetzt wird.
|
||||||
// weiter; die Bildfolge muss dieser Richtung folgen.
|
const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER)
|
||||||
const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER, korrektur)
|
const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER)
|
||||||
g.drawImage(fort.canvas, W - fort.w, 0)
|
tonAngleichen(fort, vorne, W - fort.w)
|
||||||
|
|
||||||
// 2) Mittelmotiv links, unverändert, mit weicher rechter Flanke.
|
// 2) Fortsetzung rechts, darüber das Mittelmotiv mit höhenabhängig weicher
|
||||||
const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER, 1)
|
// 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')
|
const mid = document.createElement('canvas')
|
||||||
mid.width = vorne.w
|
mid.width = vorne.w
|
||||||
mid.height = H
|
mid.height = H
|
||||||
const mg = mid.getContext('2d')
|
const mg = mid.getContext('2d')
|
||||||
mg.drawImage(vorne.canvas, 0, 0)
|
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.globalCompositeOperation = 'destination-in'
|
||||||
const mGrad = mg.createLinearGradient(0, 0, vorne.w, 0)
|
mg.drawImage(maske, 0, 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)
|
|
||||||
g.drawImage(mid, 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),
|
front: asDataUri(SRC_FRONT),
|
||||||
left: asDataUri(SRC_LEFT),
|
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()
|
await browser.close()
|
||||||
@@ -187,4 +493,4 @@ await browser.close()
|
|||||||
writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64'))
|
writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64'))
|
||||||
const kb = (readFileSync(OUT).length / 1024).toFixed(0)
|
const kb = (readFileSync(OUT).length / 1024).toFixed(0)
|
||||||
console.log(`Panorama erzeugt: ${OUT}`)
|
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`)
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 144 KiB |
@@ -32,10 +32,6 @@ export const ROOM_COLORS = {
|
|||||||
tableTop: 0x8f7350,
|
tableTop: 0x8f7350,
|
||||||
tableEdge: 0x6f5637,
|
tableEdge: 0x6f5637,
|
||||||
tableBase: 0x35322f,
|
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. */
|
/** Ring um das Porträt — hebt den Kopf vom Hintergrund ab. */
|
||||||
headRing: 0xffffff,
|
headRing: 0xffffff,
|
||||||
/** Ring bei Hover: Gold aus dem Designsystem. */
|
/** Ring bei Hover: Gold aus dem Designsystem. */
|
||||||
@@ -61,6 +57,12 @@ export const CAMERA = {
|
|||||||
/** Knapp hinter der vorderen Tischkante. */
|
/** Knapp hinter der vorderen Tischkante. */
|
||||||
z: 1.9,
|
z: 1.9,
|
||||||
fov: 48,
|
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. */
|
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
|
||||||
lookAt: [0, 1.24, -5.2] as const,
|
lookAt: [0, 1.24, -5.2] as const,
|
||||||
/**
|
/**
|
||||||
@@ -82,23 +84,19 @@ export const TABLE = {
|
|||||||
zFar: -5.75,
|
zFar: -5.75,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Sitzende Personen: Kopfhöhe und Grössen. */
|
/** Sitzende Personen: Höhe der Porträtkreise. */
|
||||||
export const SEATED = {
|
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
|
* Daraus ergibt sich die Kopfhöhe: `TABLE.top + radius + headGap`. Die Kreise
|
||||||
* auf Augenhöhe, projizierten alle fünf auf fast dieselbe Bildhöhe und die
|
* schweben damit nur knapp über der Tischkante statt weit darüber. Vorher
|
||||||
* hinteren verschwanden hinter den vorderen — unabhängig davon, wie weit man
|
* hingen sie fest auf 1.52 m — bei den jetzt deutlich grösseren Kreisen sähe
|
||||||
* sie seitlich auseinanderzog. Erst der Höhenunterschied zur Blickachse
|
* das aus, als schwebten Köpfe frei im Raum.
|
||||||
* trennt sie in der Tiefe (§3.2).
|
*
|
||||||
|
* 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,
|
headGap: 0.05,
|
||||||
headRadius: 0.142,
|
|
||||||
/** Torso vom Stuhl bis zum Hals. */
|
|
||||||
torsoTop: 1.12,
|
|
||||||
torsoBottom: 0.42,
|
|
||||||
torsoRadiusTop: 0.19,
|
|
||||||
torsoRadiusBottom: 0.25,
|
|
||||||
shoulderWidth: 0.46,
|
|
||||||
} as const
|
} as const
|
||||||
@@ -123,7 +123,9 @@ function addRoomShell(scene: THREE.Scene) {
|
|||||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
|
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
|
||||||
)
|
)
|
||||||
skirting.rotation.y = -Math.PI / 2
|
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)
|
scene.add(skirting)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,16 +150,27 @@ function addRoomShell(scene: THREE.Scene) {
|
|||||||
function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAnisotropy: number) {
|
function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAnisotropy: number) {
|
||||||
/** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */
|
/** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */
|
||||||
const radius = 62
|
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
|
* Abgedeckter Bogen. Er beginnt rechts neben der Frontscheibe und läuft über
|
||||||
* die Front nach links bis hinter die linke Glasfassade — mehr braucht es
|
* die Front nach links bis hinter die linke Glasfassade — mehr braucht es
|
||||||
* nicht, und weniger Bogen heisst mehr Bildpunkte je Grad.
|
* 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
|
const arc = 1.514
|
||||||
/** Höhe passend zum Seitenverhältnis des Panoramas (1523 × 660). */
|
/** Höhe passend zum Seitenverhältnis des Panoramas. */
|
||||||
const height = (radius * arc) / (1523 / 660)
|
const height = (radius * arc) / (panorama.breite / panorama.hoehe)
|
||||||
/** Wasserlinie im Panorama, als Anteil von oben. */
|
/** 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. */
|
/** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
|
||||||
const waterY = -7.5
|
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)
|
mesh.position.y = waterY - height * (0.5 - waterFraction)
|
||||||
/**
|
/**
|
||||||
* Drehung: geradeaus sichtbar ist die Bildstelle u = (π − Drehung) / Bogen.
|
* 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
|
* Panorama das Münster. Die Frontscheibe zeigt so das gute Motiv vom
|
||||||
* Tischende, die linke Fassade seine Fortsetzung nach links.
|
* Tischende, die linke Fassade seine Fortsetzung nach links.
|
||||||
*/
|
*/
|
||||||
@@ -279,6 +292,113 @@ function addTable(scene: THREE.Scene) {
|
|||||||
scene.add(shadow)
|
scene.add(shadow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachschärfen nach dem Verkleinern (Unschärfemaske).
|
||||||
|
*
|
||||||
|
* Beim Herunterrechnen eines Fotos verschwimmen Kanten — Wimpern, Brillenrand,
|
||||||
|
* einzelne Haare. Das Bild minus seiner weichgezeichneten Fassung ergibt genau
|
||||||
|
* diese Kantenzeichnung; verstärkt zurückgegeben holt sie die Zeichnung wieder
|
||||||
|
* heran. Jedes Bildbearbeitungsprogramm macht das nach einer Verkleinerung.
|
||||||
|
*
|
||||||
|
* Die Stärke richtet sich danach, wie weit verkleinert wurde: wer kaum
|
||||||
|
* verkleinert, braucht kaum Schärfung, sonst wirkt das Ergebnis überzeichnet.
|
||||||
|
*/
|
||||||
|
function schaerfeNach(g: CanvasRenderingContext2D, flaeche: HTMLCanvasElement, ziel: number, kante: number) {
|
||||||
|
const staerke = Math.min(0.6, Math.max(0, (kante / ziel - 1) * 0.5))
|
||||||
|
if (staerke < 0.05) return
|
||||||
|
|
||||||
|
const weich = document.createElement('canvas')
|
||||||
|
weich.width = ziel
|
||||||
|
weich.height = ziel
|
||||||
|
const wg = weich.getContext('2d')
|
||||||
|
if (!wg) return
|
||||||
|
wg.filter = 'blur(0.8px)'
|
||||||
|
wg.drawImage(flaeche, 0, 0)
|
||||||
|
|
||||||
|
const scharf = g.getImageData(0, 0, ziel, ziel)
|
||||||
|
const unscharf = wg.getImageData(0, 0, ziel, ziel)
|
||||||
|
const a = scharf.data
|
||||||
|
const b = unscharf.data
|
||||||
|
for (let i = 0; i < a.length; i += 4) {
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
const wert = a[i + c] + staerke * (a[i + c] - b[i + c])
|
||||||
|
a[i + c] = wert < 0 ? 0 : wert > 255 ? 255 : wert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.putImageData(scharf, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Porträt laden und dabei auf seine Anzeigegrösse bringen (Runde 7, Nachtrag).
|
||||||
|
*
|
||||||
|
* Die Vorlagen haben 512 Bildpunkte, die Kreise erscheinen je nach Platz aber
|
||||||
|
* nur 170 bis 330 gross. Bei so starker Verkleinerung greift die Grafikkarte
|
||||||
|
* nicht auf die Vorlage zurück, sondern auf eine vorberechnete, halb oder
|
||||||
|
* viertel so grosse Zwischenfassung. Das Ergebnis sind weiche Gesichter,
|
||||||
|
* am deutlichsten bei den weiter hinten sitzenden Agenten.
|
||||||
|
*
|
||||||
|
* Wird die Textur vorab auf ihre Anzeigegrösse gerechnet, entfällt dieser
|
||||||
|
* Zwischenschritt. Verkleinert wird einmalig auf einer Zeichenfläche, und die
|
||||||
|
* rechnet dabei über alle Quellpunkte — deutlich sorgfältiger als die schnelle
|
||||||
|
* Zwischenfassung der Grafikkarte.
|
||||||
|
*
|
||||||
|
* Zugleich entsteht hier der mittige quadratische Ausschnitt. Ohne ihn zöge die
|
||||||
|
* Kreisfläche eine liegende Vorlage in die Breite.
|
||||||
|
*
|
||||||
|
* Geladen wird bewusst von Hand statt über `TextureLoader`: dessen Rückruf
|
||||||
|
* liefert das fertige Bild nicht zuverlässig, und ohne Bildmasse lässt sich
|
||||||
|
* weder zuschneiden noch massgerecht verkleinern. Die Zählung beim
|
||||||
|
* Ladeverwalter bleibt erhalten, damit die Szene nach dem letzten Bild wie
|
||||||
|
* bisher einmal neu zeichnet.
|
||||||
|
*/
|
||||||
|
function ladePortraet(url: string, seat: MeetingSeat, leinwandHoehe: number, maxAnisotropy: number) {
|
||||||
|
const tex = new THREE.Texture()
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace
|
||||||
|
// Anisotrope Filterung: die Kreise stehen schräg im Raum, ohne sie werden
|
||||||
|
// die Porträts an den Rändern matschig.
|
||||||
|
tex.anisotropy = maxAnisotropy
|
||||||
|
tex.generateMipmaps = true
|
||||||
|
tex.minFilter = THREE.LinearMipmapLinearFilter
|
||||||
|
tex.magFilter = THREE.LinearFilter
|
||||||
|
|
||||||
|
const verwalter = THREE.DefaultLoadingManager
|
||||||
|
verwalter.itemStart(url)
|
||||||
|
|
||||||
|
const bild = new Image()
|
||||||
|
bild.onload = () => {
|
||||||
|
const kante = Math.min(bild.width, bild.height)
|
||||||
|
|
||||||
|
// Sichtbarer Durchmesser des Kreises in Bildpunkten der Leinwand
|
||||||
|
const abstand = Math.hypot(seat.x, CAMERA.z - seat.z)
|
||||||
|
const durchmesser = (seat.radius * leinwandHoehe) / (abstand * Math.tan((CAMERA.fov / 2) * Math.PI / 180))
|
||||||
|
// Etwas Reserve für Fenster, die grösser sind als das aktuelle
|
||||||
|
const ziel = Math.round(Math.min(kante, Math.max(160, durchmesser * 1.35)))
|
||||||
|
|
||||||
|
const flaeche = document.createElement('canvas')
|
||||||
|
flaeche.width = ziel
|
||||||
|
flaeche.height = ziel
|
||||||
|
const g = flaeche.getContext('2d')
|
||||||
|
if (g) {
|
||||||
|
g.imageSmoothingEnabled = true
|
||||||
|
g.imageSmoothingQuality = 'high'
|
||||||
|
g.drawImage(bild, (bild.width - kante) / 2, (bild.height - kante) / 2, kante, kante, 0, 0, ziel, ziel)
|
||||||
|
schaerfeNach(g, flaeche, ziel, kante)
|
||||||
|
tex.image = flaeche
|
||||||
|
} else {
|
||||||
|
tex.image = bild
|
||||||
|
}
|
||||||
|
tex.needsUpdate = true
|
||||||
|
verwalter.itemEnd(url)
|
||||||
|
}
|
||||||
|
bild.onerror = () => {
|
||||||
|
verwalter.itemError(url)
|
||||||
|
verwalter.itemEnd(url)
|
||||||
|
}
|
||||||
|
bild.src = url
|
||||||
|
|
||||||
|
return tex
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Agent am Tisch (§3).
|
* Ein Agent am Tisch (§3).
|
||||||
*
|
*
|
||||||
@@ -286,21 +406,16 @@ function addTable(scene: THREE.Scene) {
|
|||||||
* keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind
|
* keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind
|
||||||
* ersatzlos entfallen (§3.1).
|
* ersatzlos entfallen (§3.1).
|
||||||
*
|
*
|
||||||
* Das Porträt hängt in einer Kreisfläche. Weil die Vorlagen unterschiedliche
|
|
||||||
* Seitenverhältnisse haben, wird die Textur auf einen mittigen quadratischen
|
|
||||||
* Ausschnitt begrenzt: ohne diese Beschneidung zieht die Kreisgeometrie ein
|
|
||||||
* liegendes Foto in die Breite, was Gesichter verzerrt und Schärfe kostet (§3.3).
|
|
||||||
*
|
|
||||||
* Das Porträt schaut zum Betrachter. Voll zur Tischmitte gedreht sähe man von
|
* Das Porträt schaut zum Betrachter. Voll zur Tischmitte gedreht sähe man von
|
||||||
* einem frontalen Porträtfoto nur die Kante; eine leichte Drehung zum Tisch
|
* einem frontalen Porträtfoto nur die Kante; eine leichte Drehung zum Tisch
|
||||||
* genügt, damit die Runde zusammengehört.
|
* genügt, damit die Runde zusammengehört.
|
||||||
*/
|
*/
|
||||||
function addSeatedAgent(
|
function addSeatedAgent(
|
||||||
scene: THREE.Scene,
|
scene: THREE.Scene,
|
||||||
loader: THREE.TextureLoader,
|
|
||||||
seat: MeetingSeat,
|
seat: MeetingSeat,
|
||||||
photoUrl: string | undefined,
|
photoUrl: string | undefined,
|
||||||
maxAnisotropy: number,
|
maxAnisotropy: number,
|
||||||
|
leinwandHoehe: number,
|
||||||
): SeatHandle {
|
): SeatHandle {
|
||||||
const group = new THREE.Group()
|
const group = new THREE.Group()
|
||||||
group.position.set(seat.x, 0, seat.z)
|
group.position.set(seat.x, 0, seat.z)
|
||||||
@@ -310,40 +425,27 @@ function addSeatedAgent(
|
|||||||
: seat.side === SeatSide.LEFT ? 0.42
|
: seat.side === SeatSide.LEFT ? 0.42
|
||||||
: -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()
|
const head = new THREE.Group()
|
||||||
head.position.y = SEATED.headY
|
head.position.y = kopfHoehe
|
||||||
head.rotation.y = yaw
|
head.rotation.y = yaw
|
||||||
|
|
||||||
const ring = new THREE.Mesh(
|
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 }),
|
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
|
||||||
)
|
)
|
||||||
ring.position.z = -0.004
|
ring.position.z = -0.006
|
||||||
head.add(ring)
|
head.add(ring)
|
||||||
|
|
||||||
if (photoUrl) {
|
if (photoUrl) {
|
||||||
const tex = loader.load(photoUrl, t => {
|
const tex = ladePortraet(photoUrl, seat, leinwandHoehe, maxAnisotropy)
|
||||||
// Mittigen quadratischen Ausschnitt wählen, sobald die Masse bekannt sind.
|
|
||||||
const { width, height } = t.image as { width: number; height: number }
|
|
||||||
if (width > height) {
|
|
||||||
t.repeat.set(height / width, 1)
|
|
||||||
t.offset.set((1 - height / width) / 2, 0)
|
|
||||||
} else if (height > width) {
|
|
||||||
t.repeat.set(1, width / height)
|
|
||||||
t.offset.set(0, (1 - width / height) / 2)
|
|
||||||
}
|
|
||||||
t.needsUpdate = true
|
|
||||||
})
|
|
||||||
tex.colorSpace = THREE.SRGBColorSpace
|
|
||||||
// Anisotrope Filterung: die Kreise stehen schräg im Raum, ohne sie werden
|
|
||||||
// die Porträts an den Rändern matschig.
|
|
||||||
tex.anisotropy = maxAnisotropy
|
|
||||||
tex.generateMipmaps = true
|
|
||||||
tex.minFilter = THREE.LinearMipmapLinearFilter
|
|
||||||
tex.magFilter = THREE.LinearFilter
|
|
||||||
|
|
||||||
const face = new THREE.Mesh(
|
const face = new THREE.Mesh(
|
||||||
new THREE.CircleGeometry(SEATED.headRadius, 48),
|
new THREE.CircleGeometry(radius, 96),
|
||||||
new THREE.MeshBasicMaterial({ map: tex }),
|
new THREE.MeshBasicMaterial({ map: tex }),
|
||||||
)
|
)
|
||||||
head.add(face)
|
head.add(face)
|
||||||
@@ -360,12 +462,12 @@ function addSeatedAgent(
|
|||||||
* Klick auf Livia bei Ferdi. Die Trefferfläche deckt sich jetzt mit dem, was
|
* Klick auf Livia bei Ferdi. Die Trefferfläche deckt sich jetzt mit dem, was
|
||||||
* man sieht.
|
* 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
|
// Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde
|
||||||
// das Objekt aus der Strahlprüfung nehmen.
|
// das Objekt aus der Strahlprüfung nehmen.
|
||||||
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
|
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
|
||||||
)
|
)
|
||||||
hitBox.position.y = SEATED.headY
|
hitBox.position.y = kopfHoehe
|
||||||
group.add(hitBox)
|
group.add(hitBox)
|
||||||
|
|
||||||
scene.add(group)
|
scene.add(group)
|
||||||
@@ -380,7 +482,7 @@ function addSeatedAgent(
|
|||||||
// die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
|
// die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
|
||||||
labelAnchor: new THREE.Vector3(
|
labelAnchor: new THREE.Vector3(
|
||||||
seat.x,
|
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,
|
seat.z,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -394,7 +496,15 @@ export function createMeetingRoomScene(
|
|||||||
photos: Record<string, string | undefined>,
|
photos: Record<string, string | undefined>,
|
||||||
): MeetingRoomScene {
|
): MeetingRoomScene {
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
/*
|
||||||
|
* Mindestens doppelte Auflösung, unabhängig vom Bildschirm.
|
||||||
|
*
|
||||||
|
* Wird intern feiner gerechnet als der Bildschirm darstellt, gewinnen alle
|
||||||
|
* feinen Kanten — Fensterprofile, Kulisse und vor allem die Porträtkreise.
|
||||||
|
* Das kostet hier wenig: die Szene wird nicht laufend, sondern nur bei
|
||||||
|
* Grössenänderung, Mausbewegung und Zustandswechsel neu gezeichnet.
|
||||||
|
*/
|
||||||
|
renderer.setPixelRatio(Math.min(Math.max(window.devicePixelRatio, 2), 3))
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||||
|
|
||||||
const scene = new THREE.Scene()
|
const scene = new THREE.Scene()
|
||||||
@@ -425,8 +535,18 @@ export function createMeetingRoomScene(
|
|||||||
addGlassFacades(scene)
|
addGlassFacades(scene)
|
||||||
addTable(scene)
|
addTable(scene)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Höhe der Leinwand in Bildpunkten, aus der die Porträts ihre Auflösung
|
||||||
|
* ableiten. Zum Zeitpunkt des Aufbaus steht sie noch nicht fest, weil die
|
||||||
|
* Grösse erst danach gesetzt wird. Der Ausgangswert deckt den üblichen
|
||||||
|
* Bereich ab; wächst das Fenster später deutlich, werden die Bilder etwas
|
||||||
|
* vergrössert dargestellt — weit weniger auffällig als eine zu grobe
|
||||||
|
* Zwischenfassung.
|
||||||
|
*/
|
||||||
|
const leinwandHoehe = Math.max(canvas.clientHeight, 700) * renderer.getPixelRatio()
|
||||||
|
|
||||||
const handles = seats.map(seat =>
|
const handles = seats.map(seat =>
|
||||||
addSeatedAgent(scene, loader, seat, photos[seat.id], maxAnisotropy),
|
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseCam = camera.position.clone()
|
const baseCam = camera.position.clone()
|
||||||
@@ -459,7 +579,15 @@ export function createMeetingRoomScene(
|
|||||||
// schneidet das hohe Format den Tisch links und rechts ab. Statt einer
|
// schneidet das hohe Format den Tisch links und rechts ab. Statt einer
|
||||||
// eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
|
// eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
|
||||||
// Kamera, bis der Raum wieder hineinpasst.
|
// 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()
|
camera.updateProjectionMatrix()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -39,34 +39,54 @@ export interface MeetingSeat {
|
|||||||
/** Sitzposition in Metern. */
|
/** Sitzposition in Metern. */
|
||||||
x: number
|
x: number
|
||||||
z: 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,
|
* Ferdi und Bruno sind die direkten Nachbarn des Betrachters — einer links,
|
||||||
* einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dadurch
|
* einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dahinter
|
||||||
* entsteht hinter ihnen Platz, in dem die übrigen drei gestaffelt Raum haben,
|
* staffeln sich die übrigen drei.
|
||||||
* ohne sich zu überlagern.
|
|
||||||
*
|
*
|
||||||
* 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<string, { side: SeatSide; x: number; z: number }> = {
|
const PLAETZE: Record<string, Platz> = {
|
||||||
ferdi: { side: SeatSide.LEFT, x: -1.14, z: -1.20 },
|
ferdi: { side: SeatSide.LEFT, x: -1.16, z: -0.88, radius: 0.268 },
|
||||||
bruno: { side: SeatSide.RIGHT, x: 1.14, z: -1.20 },
|
bruno: { side: SeatSide.RIGHT, x: 1.16, z: -0.88, radius: 0.268 },
|
||||||
livia: { side: SeatSide.LEFT, x: -1.42, z: -2.90 },
|
livia: { side: SeatSide.LEFT, x: -1.32, z: -3.35, radius: 0.345 },
|
||||||
nora: { side: SeatSide.RIGHT, x: 1.42, z: -2.90 },
|
nora: { side: SeatSide.RIGHT, x: 1.32, z: -3.35, radius: 0.345 },
|
||||||
sina: { side: SeatSide.LEFT, x: -1.30, z: -4.75 },
|
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
|
* Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt
|
||||||
* lautlos im Nichts.
|
* 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[] = [
|
export const MEETING_SEATS: MeetingSeat[] = [
|
||||||
...AGENT_WORKSPACES.map(agent => ({
|
...AGENT_WORKSPACES.map(agent => ({
|
||||||
@@ -81,8 +101,6 @@ export const MEETING_SEATS: MeetingSeat[] = [
|
|||||||
name: AGENT_SUPERVISOR.name,
|
name: AGENT_SUPERVISOR.name,
|
||||||
role: AGENT_SUPERVISOR.role,
|
role: AGENT_SUPERVISOR.role,
|
||||||
path: AGENT_SUPERVISOR.path,
|
path: AGENT_SUPERVISOR.path,
|
||||||
side: SeatSide.HEAD,
|
...KOPFENDE,
|
||||||
x: 0,
|
|
||||||
z: HEAD_Z,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user