fix(brand): logo composited at SVG-native res, then downsampled
Previous render composed directly at the target size (110×110 / 44×44), which produced thin text and lost the SVG's typographic intent. Now the composition runs at SVG-native 320×320 — same coordinates as webApp/frontend/public/logo.svg — and downsamples to the panel logo size with LANCZOS. Adds stroke_width=2 around the wordmark to fake font-weight 900 (DejaVuSans is only weight 700; no Black face is on the build host, so this is the best approximation without bundling a font binary into the firmware repo). The yellow V comes through, the wordmark is heavier, and the harbour background still palette-quantizes recognisably to Spectra-6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -122,33 +122,35 @@ LOGO_SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|||||||
"IMG_2524-square900.jpg")
|
"IMG_2524-square900.jpg")
|
||||||
|
|
||||||
def compose_logo(size):
|
def compose_logo(size):
|
||||||
"""Returns a size×size RGB PIL image — harbour photo + black-fade + wordmark."""
|
"""Build at SVG-native 320×320, downsample to `size`. See
|
||||||
bg = Image.open(LOGO_SRC).convert("RGB").resize((size, size), Image.LANCZOS)
|
gen_screens_13e6.py for the full rationale."""
|
||||||
overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
SRC = 320
|
||||||
|
bg = Image.open(LOGO_SRC).convert("RGB").resize((SRC, SRC), Image.LANCZOS)
|
||||||
|
overlay = Image.new("RGBA", (SRC, SRC), (0, 0, 0, 0))
|
||||||
od = ImageDraw.Draw(overlay)
|
od = ImageDraw.Draw(overlay)
|
||||||
for y in range(size):
|
for y in range(SRC):
|
||||||
t = y / max(1, size - 1)
|
t = y / (SRC - 1)
|
||||||
if t < 0.42:
|
if t < 0.42:
|
||||||
a = 0.45 * (t / 0.42)
|
a = 0.45 * (t / 0.42)
|
||||||
elif t < 0.58:
|
elif t < 0.58:
|
||||||
a = 0.45
|
a = 0.45
|
||||||
else:
|
else:
|
||||||
a = 0.45 * ((1 - t) / 0.42)
|
a = 0.45 * ((1 - t) / 0.42)
|
||||||
od.line([(0, y), (size, y)], fill=(0, 0, 0, int(a * 255)))
|
od.line([(0, y), (SRC, y)], fill=(0, 0, 0, int(a * 255)))
|
||||||
bg = Image.alpha_composite(bg.convert("RGBA"), overlay).convert("RGB")
|
bg = Image.alpha_composite(bg.convert("RGBA"), overlay).convert("RGB")
|
||||||
|
|
||||||
draw = ImageDraw.Draw(bg)
|
draw = ImageDraw.Draw(bg)
|
||||||
font_px = max(8, int(62 * size / 320))
|
font = ttf("DejaVuSans-Bold.ttf", 62)
|
||||||
font = ttf("DejaVuSans-Bold.ttf", font_px)
|
|
||||||
parts = [("We", WH), ("V", YL), ("isto", WH)]
|
parts = [("We", WH), ("V", YL), ("isto", WH)]
|
||||||
widths = [draw.textbbox((0, 0), t, font=font)[2] for t, _ in parts]
|
widths = [draw.textbbox((0, 0), t, font=font, stroke_width=2)[2] for t, _ in parts]
|
||||||
total_w = sum(widths)
|
total_w = sum(widths)
|
||||||
x = (size - total_w) // 2
|
x = (SRC - total_w) // 2
|
||||||
y = int(0.547 * size) - font_px // 2 - 2
|
y = 175 - 62 // 2 - 16
|
||||||
for (t, fill), w in zip(parts, widths):
|
for (t, fill), w in zip(parts, widths):
|
||||||
draw.text((x, y), t, font=font, fill=fill)
|
draw.text((x, y), t, font=font, fill=fill,
|
||||||
|
stroke_width=2, stroke_fill=fill)
|
||||||
x += w
|
x += w
|
||||||
return bg
|
return bg.resize((size, size), Image.LANCZOS)
|
||||||
|
|
||||||
def draw_logo_placeholder(img):
|
def draw_logo_placeholder(img):
|
||||||
img.paste(compose_logo(LOGO_SIDE), (LOGO_X, LOGO_Y))
|
img.paste(compose_logo(LOGO_SIDE), (LOGO_X, LOGO_Y))
|
||||||
|
|||||||
@@ -161,38 +161,44 @@ LOGO_SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|||||||
|
|
||||||
def compose_logo(size):
|
def compose_logo(size):
|
||||||
"""Returns a size×size RGB PIL image of the WeVisto wordmark over the
|
"""Returns a size×size RGB PIL image of the WeVisto wordmark over the
|
||||||
Camogli harbour photo. Matches the SVG layout in
|
Camogli harbour photo. Mirrors webApp/frontend/public/logo.svg (320×320
|
||||||
webApp/frontend/public/logo.svg (320×320 viewBox)."""
|
viewBox).
|
||||||
bg = Image.open(LOGO_SRC).convert("RGB").resize((size, size), Image.LANCZOS)
|
|
||||||
|
The composition is done at SVG-native 320×320 then LANCZOS-downsampled
|
||||||
|
to `size` — gets the same anti-aliasing the browser would produce, much
|
||||||
|
cleaner text than rendering directly at the small target size.
|
||||||
|
stroke_width=2 fakes font-weight 900 (DejaVuSans-Bold is only weight
|
||||||
|
700; no Black face is installed on the build host)."""
|
||||||
|
SRC = 320
|
||||||
|
bg = Image.open(LOGO_SRC).convert("RGB").resize((SRC, SRC), Image.LANCZOS)
|
||||||
|
|
||||||
# Black overlay band: 45% opacity peak at 42-58% height, fading at edges.
|
# Black overlay band: 45% opacity peak at 42-58% height, fading at edges.
|
||||||
# Matches the linearGradient stops in the SVG.
|
overlay = Image.new("RGBA", (SRC, SRC), (0, 0, 0, 0))
|
||||||
overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
||||||
od = ImageDraw.Draw(overlay)
|
od = ImageDraw.Draw(overlay)
|
||||||
for y in range(size):
|
for y in range(SRC):
|
||||||
t = y / max(1, size - 1)
|
t = y / (SRC - 1)
|
||||||
if t < 0.42:
|
if t < 0.42:
|
||||||
a = 0.45 * (t / 0.42)
|
a = 0.45 * (t / 0.42)
|
||||||
elif t < 0.58:
|
elif t < 0.58:
|
||||||
a = 0.45
|
a = 0.45
|
||||||
else:
|
else:
|
||||||
a = 0.45 * ((1 - t) / 0.42)
|
a = 0.45 * ((1 - t) / 0.42)
|
||||||
od.line([(0, y), (size, y)], fill=(0, 0, 0, int(a * 255)))
|
od.line([(0, y), (SRC, y)], fill=(0, 0, 0, int(a * 255)))
|
||||||
bg = Image.alpha_composite(bg.convert("RGBA"), overlay).convert("RGB")
|
bg = Image.alpha_composite(bg.convert("RGBA"), overlay).convert("RGB")
|
||||||
|
|
||||||
# Wordmark: "We" white + "V" yellow + "isto" white, centred at y≈0.547.
|
|
||||||
draw = ImageDraw.Draw(bg)
|
draw = ImageDraw.Draw(bg)
|
||||||
font_px = max(10, int(62 * size / 320))
|
font = ttf("DejaVuSans-Bold.ttf", 62)
|
||||||
font = ttf("DejaVuSans-Bold.ttf", font_px)
|
|
||||||
parts = [("We", WH), ("V", YL), ("isto", WH)]
|
parts = [("We", WH), ("V", YL), ("isto", WH)]
|
||||||
widths = [draw.textbbox((0, 0), t, font=font)[2] for t, _ in parts]
|
widths = [draw.textbbox((0, 0), t, font=font, stroke_width=2)[2] for t, _ in parts]
|
||||||
total_w = sum(widths)
|
total_w = sum(widths)
|
||||||
x = (size - total_w) // 2
|
x = (SRC - total_w) // 2
|
||||||
y = int(0.547 * size) - font_px // 2 - 4 # nudged up to match SVG
|
y = 175 - 62 // 2 - 16 # matches SVG translate(160 175) baseline
|
||||||
for (t, fill), w in zip(parts, widths):
|
for (t, fill), w in zip(parts, widths):
|
||||||
draw.text((x, y), t, font=font, fill=fill)
|
draw.text((x, y), t, font=font, fill=fill,
|
||||||
|
stroke_width=2, stroke_fill=fill)
|
||||||
x += w
|
x += w
|
||||||
return bg
|
|
||||||
|
return bg.resize((size, size), Image.LANCZOS)
|
||||||
|
|
||||||
def draw_logo_placeholder(img):
|
def draw_logo_placeholder(img):
|
||||||
"""Paste the composed WeVisto logo in the top-right of the header."""
|
"""Paste the composed WeVisto logo in the top-right of the header."""
|
||||||
|
|||||||