fix(startseite): saubere Skyline links, deutlich grössere Agentenbilder
Runde 7. §1 Hintergrund. Die Häuserzeile auf der linken Seite zeigte verwässerte Doppelstrukturen. Drei Ursachen, alle behoben: - Die Fortsetzung lief mit Massstab 2.45 und war damit gegenüber dem Hauptfoto um das 2.4-fache zu gross. Der Faktor ist jetzt aus den Fotos gemessen (Münsterhöhe über der Wasserlinie) und beträgt 1.06. - Die 320 Pixel breite Überblendung mischte zwei verschiedene Häuserreihen zu halbdurchsichtigen Geisterhäusern. In der Häuserzeile wird jetzt praktisch hart getrennt, in Himmel und Wasser weiterhin weich — dort gibt es nichts zu vermischen. - Die Umschaltlinie lag zwischen den Dächern, sodass die Gauben des Hauptfotos als Reihe schwebender Klötzchen im Himmel stehen blieben. Sie liegt jetzt über allen Dächern beider Aufnahmen. Dazu: Helligkeit zeilenweise statt global angeglichen, Himmel der Fortsetzung spaltenweise entlang der echten Dachkante entfernt, fehlende Bildbereiche über die Zeilenfarbe statt über gestreckte Randzeilen aufgefüllt, Häuserzeile nachgeschärft. §2 Agentenbilder. Alle Kreise deutlich grösser, tiefenlogisch gestaffelt und nur noch knapp über der Tischkante statt frei im Raum schwebend. Gegenüber vorher: Ferdi und Bruno 2.11×, Livia und Nora 2.22×, Sina 2.50×, Thomas 3.18×. Ferdi und Bruno rücken näher an die vordere Tischkante. Radius und Höhe stehen jetzt je Platz in der Sitzordnung; die Werte sind gemeinsam durchgerechnet, sodass sich im Bild kein einziges Kreispaar überschneidet. Nebenbei: waagrechter Bildwinkel nach oben begrenzt, damit flache Fenster den Raum nicht zerren; Sockelleiste vor das Wandpaneel gesetzt gegen die ausgefranste Kante. Geprüft bei 1600×900, 1366×768 und 1280×620: sechs Beschriftungen ohne Überlappung, kein Querbalken, Klick auf Ferdi führt auf seine Seite, Menü bündig am rechten Rand. 422 Tests, Lint und Build sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e825c04bf
commit
403161283c
5 files changed
+452
-104
No files matched your search
@@ -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`)
|
||||
Reference in new issue
Block a user