"""SimurgArray brand kit: marks, wordmark, lockups, icons, templates and checks → branding/. Needs Pillow and fontTools (for the wordmark outlines), rsvg-convert and ImageMagick: python -m venv /tmp/b && /tmp/b/bin/pip install pillow fonttools && /tmp/b/bin/python scripts/brand/make_brand.py The mark is drawn on a dot grid in grid units (capsules and circles tested at each cell centre), so every export is the same set of dots: a frontal simurg with raised wings, a three-dot crown and three tail plumes (red channel, gold, green channel) ending in peacock eyes. """ from __future__ import annotations import subprocess import sys from pathlib import Path from fontTools.pens.svgPathPen import SVGPathPen from fontTools.pens.transformPen import TransformPen from fontTools.ttLib import TTFont from PIL import Image, ImageDraw, ImageFont ROOT = Path(__file__).resolve().parents[2] OUT = ROOT / "branding" FONTS = Path("/usr/share/fonts/truetype/noto") C = { "indigo": "#1E2240", "indigo_deep": "#12142A", "paper": "#F7F5EF", "red": "#C8453A", "green": "#2F8F5B", "gold": "#D4A017", "gold_deep": "#A87A00", "gold_ink": "#8C6500", "muted_dark": "#A3A7C9", "muted_light": "#5A5E7E", "black": "#111111", "white": "#FFFFFF", } # fmt: skip CHART = {"AA": ("#D55E00", "circle"), "AB": ("#009E73", "triangle"), "BB": ("#0072B2", "square")} # Okabe and Ito SCHEMES = { # layer colours per version "dark": {"gold": C["gold"], "red": C["red"], "green": C["green"], "bg": C["indigo"], "text": C["paper"]}, "light": {"gold": C["gold_deep"], "red": C["red"], "green": C["green"], "bg": C["paper"], "text": C["indigo"]}, "gold": {"gold": C["gold"], "red": C["gold"], "green": C["gold"], "bg": None, "text": C["gold"]}, "black": {"gold": C["black"], "red": C["black"], "green": C["black"], "bg": None, "text": C["black"]}, "white": {"gold": C["white"], "red": C["white"], "green": C["white"], "bg": None, "text": C["white"]}, } ACCENT = {"dark": C["gold"], "light": C["gold_ink"], "gold": C["gold"], "black": C["black"], "white": C["white"]} # ---------------------------------------------------------------- the mark (geometry in simurg_mark.py) sys.path.insert(0, str(Path(__file__).resolve().parent)) from simurg_mark import MICRO, SIMPLE, Grid, art, detailed # noqa: E402 MARKS = {"mark": detailed(), "mark_simple": art(SIMPLE), "mark_micro": art(MICRO)} def mark_svg_body(grid: Grid, scheme: str, x0: float = 0, y0: float = 0, cell: float = 1.0, rad: float = 0.4) -> str: col = SCHEMES[scheme] out = [] for layer in ("gold", "red", "green"): cells = sorted((rc for rc, lay in grid.items() if lay == layer), key=lambda k: (k[1], k[0])) dots = [f'' for c, r in cells] # fmt: skip if dots: out.append(f'' + "".join(dots) + "") return "".join(out) def svg(w: float, h: float, body: str, bg: str | None = None, title: str = "SimurgArray") -> str: rect = f'' if bg else "" return (f'{title}{rect}{body}\n') # fmt: skip # ---------------------------------------------------------------- the wordmark (outlined Noto Sans SemiBold) class Wordmark: def __init__(self, path: Path) -> None: self.font = TTFont(path) self.gs = self.font.getGlyphSet() self.cmap = self.font.getBestCmap() self.upm = self.font["head"].unitsPerEm self.hmtx = self.font["hmtx"] def paths(self, text: str, size: float, x: float, baseline: float, tracking: float = 0.0) -> tuple[str, float]: """SVG path data for ``text`` at ``size`` px, starting at x; returns (d, advance).""" s = size / self.upm ds, pen_x = [], x for ch in text: gname = self.cmap[ord(ch)] pen = SVGPathPen(self.gs) self.gs[gname].draw(TransformPen(pen, (s, 0, 0, -s, pen_x, baseline))) ds.append(pen.getCommands()) pen_x += self.hmtx[gname][0] * s + tracking * size return " ".join(ds), pen_x - x - tracking * size def cap_height(self, size: float) -> float: return float(getattr(self.font["OS/2"], "sCapHeight", 714)) * size / self.upm WM = Wordmark(FONTS / "NotoSans-SemiBold.ttf") def wordmark_body(scheme: str, x: float, baseline: float, size: float) -> tuple[str, float]: d1, w1 = WM.paths("Simurg", size, x, baseline, tracking=0.005) d2, w2 = WM.paths("Array", size, x + w1 + size * 0.03, baseline, tracking=0.005) body = f'' return body, w1 + size * 0.03 + w2 # ---------------------------------------------------------------- files def write(path: Path, text: str) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text) return path def png(svg_path: Path, out: Path, width: int) -> None: out.parent.mkdir(parents=True, exist_ok=True) subprocess.run(["rsvg-convert", "-w", str(width), "-o", str(out), str(svg_path)], check=True) def logos() -> None: for name, (g, cols, rows) in MARKS.items(): pad = 1 for scheme in SCHEMES: p = write(OUT / "logo" / f"{name}_{scheme}.svg", svg(cols + 2 * pad, rows + 2 * pad, mark_svg_body(g, scheme, pad, pad))) # fmt: skip png(p, OUT / "logo" / "png" / f"{name}_{scheme}.png", 512 if name == "mark" else 256) size = 100.0 for scheme in SCHEMES: body, w = wordmark_body(scheme, 0, size * 0.78, size) p = write(OUT / "logo" / f"wordmark_{scheme}.svg", svg(w + 4, size, body)) png(p, OUT / "logo" / "png" / f"wordmark_{scheme}.png", 900) g, cols, rows = MARKS["mark"] for scheme in ("dark", "light", "black", "white"): bg = SCHEMES[scheme]["bg"] # horizontal: mark 120 px high, wordmark cap height ~ 40 % of it cell = 120 / rows ms = mark_svg_body(g, scheme, 40, 40, cell) wsize = 64.0 wb, ww = wordmark_body(scheme, 40 + cols * cell + 36, 40 + 60 + WM.cap_height(wsize) / 2, wsize) W, H = 40 + cols * cell + 36 + ww + 48, 200 p = write(OUT / "logo" / f"lockup_horizontal_{scheme}.svg", svg(W, H, ms + wb, bg)) png(p, OUT / "logo" / "png" / f"lockup_horizontal_{scheme}.png", 1200) # stacked cell = 200 / rows wsize = 60.0 _, ww = wordmark_body(scheme, 0, 0, wsize) W = max(cols * cell, ww) + 80 ms = mark_svg_body(g, scheme, (W - cols * cell) / 2, 40, cell) wb, _ = wordmark_body(scheme, (W - ww) / 2, 40 + rows * cell + 30 + WM.cap_height(wsize), wsize) p = write(OUT / "logo" / f"lockup_stacked_{scheme}.svg", svg(W, 40 + rows * cell + 30 + 90, ms + wb, bg)) png(p, OUT / "logo" / "png" / f"lockup_stacked_{scheme}.png", 800) def icons() -> None: """Rounded indigo tile; micro mark at 16, simple at 32/48, detailed from 180 up.""" d = OUT / "icons" for size, name in ((16, "mark_micro"), (32, "mark_simple"), (48, "mark_simple"), (180, "mark"), (512, "mark")): g, cols, rows = MARKS[name] n = max(cols, rows) + (1 if size <= 48 else 3) rx = n * 0.18 body = f'' body += mark_svg_body(g, "dark", (n - cols) / 2, (n - rows) / 2, 1.0, 0.42 if size <= 48 else 0.4) p = write(d / f"icon_{size}.svg", svg(n, n, body)) png(p, d / f"icon_{size}.png", size) subprocess.run(["magick", str(d / "icon_16.png"), str(d / "icon_32.png"), str(d / "icon_48.png"), str(d / "favicon.ico")], check=True) # fmt: skip for f in d.glob("icon_*.svg"): if f.stem not in ("icon_16", "icon_512"): f.unlink() # ---------------------------------------------------------------- raster templates (Pillow) def rgb(h: str) -> tuple[int, int, int]: return int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16) def canvas(w: int, h: int, ss: int = 2) -> Image.Image: img = Image.new("RGB", (w * ss, h * ss), rgb(C["indigo"])) dr = ImageDraw.Draw(img) a, b = rgb(C["indigo"]), rgb(C["indigo_deep"]) for y in range(h * ss): t = y / (h * ss) dr.line([(0, y), (w * ss, y)], fill=tuple(int(x + (z - x) * t) for x, z in zip(a, b, strict=True))) step, k = 18 * ss, 0 for gy in range(step // 2, h * ss, step): # faint bead grid, alternating channels for gx in range(step // 2, w * ss, step): k += 1 col = rgb(C["red"] if (gx // step + gy // step) % 2 else C["green"]) c = tuple(int(bg + (cc - bg) * 0.14) for bg, cc in zip(a, col, strict=True)) r = 1.6 * ss dr.ellipse([gx - r, gy - r, gx + r, gy + r], fill=c) return img def draw_mark(img: Image.Image, grid: Grid, x: float, y: float, cell: float, scheme: str = "dark", rad: float = 0.4) -> None: # fmt: skip dr = ImageDraw.Draw(img) col = SCHEMES[scheme] for (c, r), layer in grid.items(): cx, cy = x + (c + 0.5) * cell, y + (r + 0.5) * cell dr.ellipse([cx - rad * cell, cy - rad * cell, cx + rad * cell, cy + rad * cell], fill=rgb(col[layer])) def font(name: str, px: float) -> ImageFont.FreeTypeFont: return ImageFont.truetype(str(FONTS / name), int(px)) def text_wordmark(img: Image.Image, x: float, y: float, px: float) -> float: dr = ImageDraw.Draw(img) f = font("NotoSans-SemiBold.ttf", px) dr.text((x, y), "Simurg", font=f, fill=rgb(C["paper"])) w = dr.textlength("Simurg", font=f) + px * 0.03 dr.text((x + w, y), "Array", font=f, fill=rgb(C["gold"])) return w + dr.textlength("Array", font=f) def save(img: Image.Image, w: int, h: int, path: Path, quality: int = 86) -> None: path.parent.mkdir(parents=True, exist_ok=True) img = img.resize((w, h), Image.Resampling.LANCZOS) if path.suffix == ".jpg": img.save(path, "JPEG", quality=quality, optimize=True, progressive=True) else: img.save(path, "PNG", optimize=True) def templates() -> None: ss = 2 g, cols, rows = MARKS["mark"] t = OUT / "templates" # README banner 1280 x 320 img = canvas(1280, 320, ss) cell = 230 * ss / rows draw_mark(img, g, 70 * ss, 45 * ss, cell) x = 70 * ss + cols * cell + 50 * ss text_wordmark(img, x, 88 * ss, 76 * ss) ImageDraw.Draw(img).text((x + 4 * ss, 196 * ss), "Thirty birds, one picture.", font=font("NotoSans-Regular.ttf", 30 * ss), fill=rgb(C["muted_dark"])) # fmt: skip save(img, 1280, 320, t / "readme_banner_1280x320.png") # repository social preview 1280 x 640 img = canvas(1280, 640, ss) cell = 380 * ss / rows draw_mark(img, g, 90 * ss, 110 * ss, cell) x = 90 * ss + cols * cell + 60 * ss text_wordmark(img, x, 200 * ss, 84 * ss) dr = ImageDraw.Draw(img) dr.text((x + 4 * ss, 320 * ss), "Thirty birds, one picture.", font=font("NotoSans-Regular.ttf", 34 * ss), fill=rgb(C["muted_dark"])) # fmt: skip dr.text((x + 4 * ss, 400 * ss), "Open Infinium array analysis:\nIDAT to genotypes, QC, CN and PGx", font=font("NotoSansMono-Regular.ttf", 24 * ss), fill=rgb(C["gold"]), spacing=10 * ss) # fmt: skip save(img, 1280, 640, t / "social_preview_1280x640.png") # avatar 512, safe inside a circle (mark within 70 % of the diameter) img = Image.new("RGB", (512 * ss, 512 * ss), rgb(C["indigo"])) cell = 512 * ss * 0.62 / max(cols, rows) draw_mark(img, g, (512 * ss - cols * cell) / 2, (512 * ss - rows * cell) / 2 + 6 * ss, cell) save(img, 512, 512, t / "avatar_512.png") # WordPress featured image template: title area left, mark right img = canvas(1200, 630, ss) cell = 400 * ss / rows draw_mark(img, g, 1200 * ss - cols * cell - 70 * ss, 100 * ss, cell) dr = ImageDraw.Draw(img) dr.text((70 * ss, 88 * ss), "PART N", font=font("NotoSansMono-Regular.ttf", 26 * ss), fill=rgb(C["gold"])) dr.multiline_text((70 * ss, 140 * ss), "Post title in\ntwo or three lines", font=font("NotoSans-Bold.ttf", 58 * ss), fill=rgb(C["paper"]), spacing=14 * ss) # fmt: skip footer(img, 70 * ss, 530 * ss, ss) save(img, 1200, 630, t / "featured_template_1200x630.jpg") def footer(img: Image.Image, x: float, y: float, ss: int) -> None: dr = ImageDraw.Draw(img) for i, k in enumerate(("red", "green", "gold")): dr.ellipse([x + i * 18 * ss, y + 10 * ss, x + i * 18 * ss + 12 * ss, y + 22 * ss], fill=rgb(C[k])) f = font("NotoSans-Regular.ttf", 24 * ss) dr.text((x + 66 * ss, y), "SimurgArray", font=f, fill=rgb(C["paper"])) dr.text((x + 66 * ss + dr.textlength("SimurgArray ", font=f), y), "Thirty birds, one picture.", font=f, fill=rgb(C["muted_dark"])) # fmt: skip def report_mark() -> None: g, cols, rows = MARKS["mark_simple"] body = mark_svg_body(g, "dark", 1, 1) write(OUT / "templates" / "report_corner_mark.svg", svg(cols + 2, rows + 2, body)) style = f"display:flex;align-items:center;gap:8px;font:12px 'Noto Sans Mono',monospace;color:{C['muted_dark']}" write(OUT / "templates" / "report_corner_mark.html", f"""
SimurgArray SimurgArray {{version}} · run {{manifest_sha256[:12]}}
""") # fmt: skip CLI_BANNER = r""" o o SimurgArray {version} oo o oo Thirty birds, one picture. ooooooo ooo IDAT to genotypes, QC, CN, PGx / | \ research use only * o * """ # ---------------------------------------------------------------- checks def _lin(c: float) -> float: return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 def _unlin(c: float) -> int: c = max(0.0, min(1.0, c)) return round(255 * (12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055)) def contrast(a: str, b: str) -> float: def lum(h: str) -> float: r, g, bb = (_lin(v / 255) for v in rgb(h)) return 0.2126 * r + 0.7152 * g + 0.0722 * bb la, lb = sorted((lum(a), lum(b)), reverse=True) return (la + 0.05) / (lb + 0.05) MACHADO = { # Machado, Oliveira and Fernandes 2009, severity 1.0 (linear RGB) "protanopia": ((0.152286, 1.052583, -0.204868), (0.114503, 0.786281, 0.099216), (-0.003882, -0.048116, 1.051998)), "deuteranopia": ((0.367322, 0.860646, -0.227968), (0.280085, 0.672501, 0.047413), (-0.011820, 0.042940, 0.968881)), } # fmt: skip def simulate(img: Image.Image, kind: str) -> Image.Image: m = MACHADO[kind] lut = [_lin(i / 255) for i in range(256)] px = img.convert("RGB").load() out = img.convert("RGB").copy() po = out.load() cache: dict[tuple[int, int, int], tuple[int, int, int]] = {} for y in range(img.height): for x in range(img.width): v = px[x, y] if v not in cache: r, g, b = (lut[c] for c in v) cache[v] = tuple(_unlin(row[0] * r + row[1] * g + row[2] * b) for row in m) # type: ignore[assignment] po[x, y] = cache[v] return out def chart_example(ss: int = 2) -> Image.Image: """A genotype cluster plot in the chart palette: colour plus marker shape.""" import random rng = random.Random(7) w, h = 420, 300 img = Image.new("RGB", (w * ss, h * ss), rgb(C["paper"])) dr = ImageDraw.Draw(img) dr.line([(40 * ss, 260 * ss), (400 * ss, 260 * ss)], fill=rgb(C["muted_light"]), width=ss) dr.line([(40 * ss, 20 * ss), (40 * ss, 260 * ss)], fill=rgb(C["muted_light"]), width=ss) for gt, cx in (("AA", 90), ("AB", 220), ("BB", 350)): col, shape = CHART[gt] for _ in range(40): x, y = (cx + rng.gauss(0, 14)) * ss, (140 + rng.gauss(0, 30)) * ss r = 5 * ss if shape == "circle": dr.ellipse([x - r, y - r, x + r, y + r], fill=rgb(col)) elif shape == "square": dr.rectangle([x - r * 0.9, y - r * 0.9, x + r * 0.9, y + r * 0.9], fill=rgb(col)) else: dr.polygon([(x, y - r * 1.1), (x - r, y + r * 0.8), (x + r, y + r * 0.8)], fill=rgb(col)) dr.text((cx * ss - 12 * ss, 268 * ss), gt, font=font("NotoSansMono-Regular.ttf", 16 * ss), fill=rgb(C["indigo"])) # fmt: skip return img.resize((w, h), Image.Resampling.LANCZOS) def checks() -> list[tuple[str, str, str, float]]: d = OUT / "checks" d.mkdir(parents=True, exist_ok=True) g, cols, rows = MARKS["mark"] logo = Image.new("RGB", (cols * 12 + 24, rows * 12 + 24), rgb(C["indigo"])) draw_mark(logo, g, 12, 12, 12) chart = chart_example() panels = [("original", logo, chart)] + [(k, simulate(logo, k), simulate(chart, k)) for k in MACHADO] pw = logo.width + chart.width + 30 ph = max(logo.height, chart.height) + 50 sheet = Image.new("RGB", (pw, ph * 3), rgb(C["paper"])) dr = ImageDraw.Draw(sheet) for i, (name, lg, ch) in enumerate(panels): y = i * ph dr.text((10, y + 8), name, font=font("NotoSansMono-Regular.ttf", 18), fill=rgb(C["indigo"])) sheet.paste(lg, (10, y + 36)) sheet.paste(ch, (logo.width + 20, y + 36)) sheet.save(d / "colour_vision_check.png", optimize=True) # small sizes side by side row = Image.new("RGB", (520, 90), rgb(C["paper"])) x = 10 for s in (16, 32, 48, 180): ic = Image.open(OUT / "icons" / f"icon_{s}.png").convert("RGB") if s == 180: ic = ic.resize((72, 72), Image.Resampling.LANCZOS) row.paste(ic, (x, 10)) x += ic.width + 30 row.save(d / "icon_sizes.png", optimize=True) pairs = [("paper", "indigo", "text on dark"), ("muted_dark", "indigo", "secondary text on dark"), ("gold", "indigo", "accent text on dark"), ("indigo", "paper", "text on light"), ("muted_light", "paper", "secondary text on light"), ("gold_ink", "paper", "accent text on light"), ("red", "indigo", "graphics on dark"), ("green", "indigo", "graphics on dark"), ("gold_deep", "paper", "graphics on light"), ("red", "paper", "graphics on light"), ("green", "paper", "graphics on light"), ("gold", "paper", "never for text or small marks")] # fmt: skip return [(a, b, use, contrast(C[a], C[b])) for a, b, use in pairs] def family(featured: list[Path]) -> None: """Featured images and templates side by side (acceptance check 4).""" tiles = [Image.open(p).convert("RGB") for p in featured] + [ Image.open(OUT / "templates" / "featured_template_1200x630.jpg").convert("RGB"), Image.open(OUT / "templates" / "social_preview_1280x640.png").convert("RGB"), Image.open(OUT / "templates" / "readme_banner_1280x320.png").convert("RGB"), Image.open(OUT / "templates" / "avatar_512.png").convert("RGB"), ] # fmt: skip th = 200 tiles = [t.resize((int(t.width * th / t.height), th), Image.Resampling.LANCZOS) for t in tiles] per_row, gap = 3, 16 rows_ = [tiles[i : i + per_row] for i in range(0, len(tiles), per_row)] W = max(sum(t.width for t in r) + gap * (len(r) + 1) for r in rows_) sheet = Image.new("RGB", (W, len(rows_) * (th + gap) + gap), rgb(C["paper"])) y = gap for r in rows_: x = gap for t in r: sheet.paste(t, (x, y)) x += t.width + gap y += th + gap sheet.save(OUT / "checks" / "family.jpg", quality=76, optimize=True) def main() -> None: logos() icons() templates() report_mark() write(OUT / "templates" / "cli_banner.txt", CLI_BANNER.lstrip("\n")) table = checks() featured = sorted((ROOT / "blog" / "images").glob("*.jpg")) family(featured) lines = ["| colour | on | use | contrast | AA text (4.5) | graphics (3.0) |", "|---|---|---|---|---|---|"] for a, b, use, cr in table: lines.append(f"| {a} {C[a]} | {b} {C[b]} | {use} | {cr:.2f} | {'yes' if cr >= 4.5 else 'no'} | " f"{'yes' if cr >= 3 else 'no'} |") # fmt: skip write(OUT / "checks" / "contrast.md", "\n".join(lines) + "\n") print("\n".join(lines)) if __name__ == "__main__": main()