|
| 1 | +"""Web hero figures for the Atera collection page, from the primer's outputs. |
| 2 | +
|
| 3 | + python export_hero.py ATERA_OUT DEST -> DEST/*.webp (<= 1600 px wide, < 400 KB, white background) and DEST/primer.json |
| 4 | +""" |
| 5 | +import io |
| 6 | +import csv |
| 7 | +import json |
| 8 | +import re |
| 9 | +import sys |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +import numpy as np |
| 13 | +from PIL import Image |
| 14 | + |
| 15 | +CREDIT = "Data: 10x Genomics, CC BY 4.0." |
| 16 | +FIGS = [ # (source png, web name, alt text, caption), in the page's order |
| 17 | + ("fig_qc.png", "atera-qc.webp", "Violin plots of counts and genes per cell for each Atera section", |
| 18 | + f"Counts and detected genes per cell in each of the six Atera sections, after the same quality filters. {CREDIT}"), |
| 19 | + ("fig_umap_dataset.png", "atera-umap-dataset.webp", "UMAP of all cells coloured by section, before and after Harmony", |
| 20 | + f"All cells from the six sections in one UMAP, before and after Harmony integration across sections. {CREDIT}"), |
| 21 | + ("fig_cell_types.png", "atera-cell-types.webp", "UMAP coloured by broad cell type, with the cell-type share per tissue", |
| 22 | + f"Broad cell types from marker-gene scores per cluster, an automated and cursory annotation, and their share in each tissue. {CREDIT}"), |
| 23 | + ("fig_cell_types_space.png", "atera-cell-types-space.webp", "Each Atera section with its cells coloured by broad cell type", |
| 24 | + f"The same automated, cursory cell types drawn on each section with spatialdata-plot. {CREDIT}"), |
| 25 | +] |
| 26 | + |
| 27 | + |
| 28 | +def squeeze_blank_rows(im, keep=30): |
| 29 | + """Shorten every run of all-white rows (e.g. between the last panel row and the credit footer) to `keep` px.""" |
| 30 | + a = np.asarray(im) |
| 31 | + blank = (a > 250).all(axis=(1, 2)) |
| 32 | + rows, run = [], 0 |
| 33 | + for i, b in enumerate(blank): |
| 34 | + run = run + 1 if b else 0 |
| 35 | + if run <= keep: |
| 36 | + rows.append(i) |
| 37 | + return Image.fromarray(a[rows]) |
| 38 | + |
| 39 | + |
| 40 | +def save_webp(src, dest, max_w=1600, max_kb=400): |
| 41 | + im = Image.open(src).convert("RGBA") |
| 42 | + bg = Image.new("RGBA", im.size, "white") |
| 43 | + im = Image.alpha_composite(bg, im).convert("RGB") |
| 44 | + im = squeeze_blank_rows(im) |
| 45 | + if im.width > max_w: |
| 46 | + im = im.resize((max_w, round(im.height * max_w / im.width)), Image.LANCZOS) |
| 47 | + for q in (90, 80, 70, 60, 50, 40): # highest quality under the size cap |
| 48 | + buf = io.BytesIO() |
| 49 | + im.save(buf, "WEBP", quality=q, method=6) |
| 50 | + if buf.tell() < max_kb * 1024: |
| 51 | + break |
| 52 | + else: |
| 53 | + raise ValueError(f"{src}: still {buf.tell() // 1024} KB at quality 40") |
| 54 | + dest.write_bytes(buf.getvalue()) |
| 55 | + return im.width, im.height, buf.tell() |
| 56 | + |
| 57 | + |
| 58 | +def main(src_dir, dest): |
| 59 | + src_dir, dest = Path(src_dir), Path(dest) |
| 60 | + dest.mkdir(parents=True, exist_ok=True) |
| 61 | + figures = [] |
| 62 | + for src, name, alt, cap in FIGS: |
| 63 | + w, h, size = save_webp(src_dir / src, dest / name) |
| 64 | + figures.append({"src": name, "alt": alt, "caption": cap, "width": w, "height": h}) |
| 65 | + print(name, w, h, size // 1024, "KB") |
| 66 | + summary = ("spatialdata-db holds all six 10x Atera whole-transcriptome sections, about 2.3 million cells, in one format. " |
| 67 | + "Here the same quality control, Harmony integration and a cursory cell-type annotation run on all of them on one GPU. " |
| 68 | + + CREDIT) |
| 69 | + # quality control per section (qc_per_dataset.csv of the same run): low-quality cells are counted on the page |
| 70 | + qc = list(csv.DictReader((src_dir / "qc_per_dataset.csv").open())) |
| 71 | + qc = [{"section": f"{r['tissue']} ({r['preservation']})", "cells": int(r["cells"]), "low_quality": int(r["low_quality"]), |
| 72 | + "cells_kept": int(r["cells_kept"]), "median_counts": float(r["median_counts"])} for r in qc] |
| 73 | + # the page story: the numbered paragraphs of STORY_DRAFT.md next to this script, in order |
| 74 | + story = [m.group(1) for m in re.finditer(r"^\d+\. (.+)$", (Path(__file__).with_name("STORY_DRAFT.md")).read_text(), re.M)] |
| 75 | + (dest / "primer.json").write_text(json.dumps({"summary": summary, "figures": figures, "qc": qc, "story": story}, indent=1)) |
| 76 | + |
| 77 | + |
| 78 | +if __name__ == "__main__": |
| 79 | + main(sys.argv[1], sys.argv[2]) |
0 commit comments