# Cell Generator > A Spore-style cell editor that extends the Petri Dish catalogue — trace a specimen's radial outline, mark a section, pick an adaptation, and use the Gemini image API to grow a brand-new silhouette. --- .. toc:: ### Evolve a new specimen Pick a **base specimen** from the catalogue and the `RadialLineChart` traces its **outline** as a polar curve. Drag the **MARK SECTION** range to highlight an angular slice of the outline (the orange wedge), choose an **adaptation** — a flagellum, cilia, an eyespot, spikes, a jaw… — then either: - **Export SVG** — download the marked outline as a vector, or - **Generate (Gemini)** — send the marked silhouette to Google's **Gemini 2.5 Flash Image** model with an instruction to grow that part at the marked spot. It returns a new solid-black silhouette (works in light *and* dark mode) that lands in **My Specimens**. .. admonition::Groundwork :color: blue This is the first slice of a larger system: generated specimens are saved as playable cells for the upcoming **Petri Dish Game** (Spore Cell-Stage style), where adaptations unlock new outline sections to evolve. The Gemini call runs **server-side** (the `GEMINI_IMAGE_API_KEY` never reaches the browser). .. exec::docs.cell_silhouette_generator.cell_silhouette_generator :code: false ### How it works The outline trace, section marking, SVG export and the marked-PNG rasterisation all run **client-side** (canvas ray-casting, same as the Silhouette Tracer). Only the image generation is a **server-side Dash callback**: it forwards the marked PNG + a prompt describing the chosen part to the Gemini `generateContent` endpoint and returns the `inlineData` PNG as a data URL. ### Source ```python # File: docs/cell_silhouette_generator/cell_silhouette_generator.py import base64 import json import os from pathlib import Path import requests from dash import (dcc, html, clientside_callback, callback, no_update, Input, Output, State, ALL) import dash_mantine_components as dmc from dash_iconify import DashIconify import dash_mui_scheduler as dms # /cell-silhouette-generator — a Spore-Cell-Stage-style EDITOR that extends the # Petri Dish SPECIMEN CATALOG. Pick a base specimen, the RadialLineChart traces # its OUTLINE, you MARK an angular section of that outline, pick an ADAPTATION # (eyes, flagella, spikes, …), then either EXPORT the marked SVG or send it to # the **Gemini image API** (GEMINI_IMAGE_API_KEY) to generate a brand-new black # silhouette of the organism with that part grafted on at the marked location. # Generated specimens are stored in "MY SPECIMENS" — the groundwork for the # /petri-dish-game player-cell + unlock system. # # The Gemini call is a SERVER-SIDE callback so the API key never reaches the # browser. Everything else (tracing, marking, rasterizing) is clientside. ACCENT = "#3399ff" ACCENT2 = "#e8590c" # highlight / marked-section colour _LICENSE = os.environ.get("MUI_X_LICENSE_KEY", "") _GEMINI_MODEL = os.environ.get("GEMINI_IMAGE_MODEL", "gemini-2.5-flash-image") # ---- base specimens: the Petri Dish silhouettes (the catalogue we extend) ----- _PETRI = Path(__file__).resolve().parents[2] / "assets" / "petri" _NAMES = { "navicula": "Navicula", "closterium": "Closterium", "spirogyra": "Spirogyra", "volvox": "Volvox", "euglena": "Euglena", "amoeba": "Amoeba proteus", "paramecium": "Paramecium", "stentor": "Stentor", "vorticella": "Vorticella", "actinosphaerium": "Actinosphaerium", "philodina": "Philodina", "daphnia": "Daphnia", "cyclops": "Cyclops", "celegans": "C. elegans", "_generic": "Generic cell", } BASES = [] for _svg in sorted(_PETRI.glob("*.svg")): slug = _svg.stem BASES.append({"slug": slug, "name": _NAMES.get(slug, slug.title()), "src": f"/assets/petri/{slug}.svg"}) # ---- adaptation parts (Spore-inspired but realistic cell organelles) ---------- # desc feeds the Gemini prompt; role/diet are groundwork for the game economy. PARTS = [ {"id": "flagellum", "label": "Flagellum", "icon": "tabler:wave-sine", "desc": "a single long whip-like flagellum tail for fast propulsion", "role": "speed", "diet": None}, {"id": "cilia", "label": "Cilia", "icon": "tabler:line-dashed", "desc": "a fringe of many short hair-like cilia for steady swimming", "role": "speed", "diet": None}, {"id": "eye", "label": "Eyespot", "icon": "tabler:eye", "desc": "a rounded photoreceptor eyespot bulge for sensing light", "role": "sense", "diet": None}, {"id": "spike", "label": "Spike", "icon": "tabler:triangle", "desc": "sharp defensive spikes / spines projecting from the membrane", "role": "defense", "diet": None}, {"id": "jaw", "label": "Jaw", "icon": "mdi:tooth-outline", "desc": "a toothed predatory jaw / mouth opening for eating other cells", "role": "attack", "diet": "carnivore"}, {"id": "filter", "label": "Filter mouth", "icon": "tabler:circle-dotted", "desc": "a wide filter-feeding mouth groove for grazing on plant matter", "role": "feed", "diet": "herbivore"}, {"id": "proboscis", "label": "Proboscis", "icon": "tabler:needle", "desc": "a slender extendable proboscis feeding tube", "role": "feed", "diet": "omnivore"}, {"id": "pseudopod", "label": "Pseudopod", "icon": "tabler:hand-finger", "desc": "a flowing amoeboid pseudopod arm for crawling and engulfing", "role": "grip", "diet": None}, {"id": "membrane", "label": "Membrane ruffle", "icon": "tabler:wave-saw-tool", "desc": "a rippling undulating membrane ruffle along the edge", "role": "speed", "diet": None}, {"id": "stinger", "label": "Stinger", "icon": "mdi:needle", "desc": "a venomous stinger / nematocyst barb for attack and defense", "role": "attack", "diet": None}, ] _PART_BY_ID = {p["id"]: p for p in PARTS} TRACE_SX = { "& .MuiChartsRadialGrid-line": {"stroke": "rgba(51, 153, 255, 0.16)"}, "& .MuiChartsRadialAxis-line": {"stroke": "rgba(51, 153, 255, 0.30)"}, "& .MuiChartsRadialAxis-tick": {"stroke": "rgba(51, 153, 255, 0.30)"}, "& text": {"fill": "light-dark(rgba(40, 90, 150, 0.7), rgba(150, 195, 255, 0.78))", "fontFamily": "monospace", "fontSize": "10px"}, } # ------------------------------------------------------------- base picker ----- def _base_thumb(i, item): return dmc.UnstyledButton( dmc.Stack( [html.Img(src=item["src"], className="petri-silhouette", style={"width": "100%", "height": 44, "objectFit": "contain"}), dmc.Text(item["name"], size="9px", ta="center", lineClamp=1, style={"width": "100%"})], gap=2, align="center", ), id={"type": "csg-base", "index": i}, p=6, className="pp-thumb", style={"border": "1px solid var(--mantine-color-default-border)", "borderRadius": "8px", "width": "100%", "background": "var(--mantine-color-body)"}, ) def _part_chip(p): return dmc.Chip( dmc.Group([DashIconify(icon=p["icon"], width=14), html.Span(p["label"])], gap=4, wrap="nowrap"), value=p["id"], variant="outline", size="sm", ) _builder = dmc.Paper( [ dmc.Group( [dmc.Text("BASE SPECIMEN", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Badge("editor", color="brand", variant="light", styles={"root": {"fontFamily": "monospace"}})], justify="space-between", mb="xs", ), html.Div( html.Img(id="csg-preview", src=BASES[0]["src"], className="petri-silhouette", style={"maxWidth": "100%", "maxHeight": 150, "objectFit": "contain"}), style={"height": 160, "display": "flex", "alignItems": "center", "justifyContent": "center", "background": "light-dark(radial-gradient(circle at 50% 45%, #ffffff 0%, #eef4fb 70%, #dde8f5 100%)," " radial-gradient(circle at 50% 45%, #16222f 0%, #101a26 70%, #0b121b 100%))", "borderRadius": "12px"}, ), dmc.Text(BASES[0]["name"], id="csg-base-name", fw=700, ta="center", mt=6, mb="xs"), dmc.Text("ADAPTATION", fw=700, size="xs", c="dimmed", style={"letterSpacing": "1px", "fontFamily": "monospace"}), dmc.ChipGroup( dmc.Group([_part_chip(p) for p in PARTS], gap=6, mt=4, mb="sm"), id="csg-part", value="flagellum", multiple=False, ), dmc.Text("MARK SECTION (angle °)", fw=700, size="xs", c="dimmed", style={"letterSpacing": "1px", "fontFamily": "monospace"}), dmc.RangeSlider(id="csg-section", min=0, max=360, step=5, value=[30, 90], minRange=10, marks=[{"value": v, "label": str(v)} for v in (0, 90, 180, 270, 360)], color="brand", mt=4, mb="sm"), dmc.Group( [dmc.Button("Export SVG", id="csg-export", leftSection=DashIconify(icon="tabler:download"), variant="default", size="sm"), dmc.Button("Generate (Gemini)", id="csg-generate", leftSection=DashIconify(icon="tabler:sparkles"), color="brand", size="sm")], gap="sm", ), ], p="md", radius="md", withBorder=True, style={"flex": "1 1 320px", "maxWidth": 360}, ) # ----------------------------------------------------------- radial outline ---- _outline = dmc.Paper( [ dmc.Group( [dmc.Text("RADIAL OUTLINE", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Text("", id="csg-section-readout", size="xs", c="dimmed", style={"fontFamily": "monospace"})], justify="space-between", mb="xs", ), dmc.Text("The specimen as a polar curve. The orange wedge is the section you've marked " "for a new part — export it, or send it to Gemini to grow the adaptation.", size="xs", c="dimmed", mb="xs"), dms.RadialLineChart( id="csg-chart", height=360, licenseKey=_LICENSE, series=[], rotationAxis=[{"data": [0, 360], "min": 0, "max": 360, "tickInterval": [0, 45, 90, 135, 180, 225, 270, 315]}], radiusAxis=[{"min": 0, "max": 100, "position": "none"}], grid={"rotation": True, "radius": True}, hideLegend=True, skipAnimation=True, margin=14, sx=TRACE_SX, ), ], p="md", radius="md", withBorder=True, style={"flex": "1 1 360px", "maxWidth": 500}, ) # --------------------------------------------------------------- gemini lab ---- _lab = dmc.Paper( [ dmc.Group( [dmc.Text("GENESIS LAB", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Badge("Gemini 2.5 Flash Image", id="csg-model", color="grape", variant="light", styles={"root": {"fontFamily": "monospace"}})], justify="space-between", mb="xs", ), dmc.Group( [ dmc.Stack( [dmc.Text("MARKED INPUT", size="xs", c="dimmed", style={"letterSpacing": "1px", "fontFamily": "monospace"}), html.Div(html.Img(id="csg-marked-preview", style={"maxWidth": "100%", "maxHeight": 150}), style={"width": 160, "height": 160, "display": "flex", "alignItems": "center", "justifyContent": "center", "background": "var(--mantine-color-default)", "borderRadius": "10px"})], gap=4), DashIconify(icon="tabler:arrow-right", width=26, color=ACCENT), dmc.Stack( [dmc.Text("NEW SPECIMEN", size="xs", c="dimmed", style={"letterSpacing": "1px", "fontFamily": "monospace"}), dmc.Box( html.Img(id="csg-result", className="petri-silhouette", style={"maxWidth": "100%", "maxHeight": 150}), pos="relative", style={"width": 160, "height": 160, "display": "flex", "alignItems": "center", "justifyContent": "center", "background": "var(--mantine-color-default)", "borderRadius": "10px"}), dmc.Button("Add to My Specimens", id="csg-save", size="xs", variant="light", color="brand", leftSection=DashIconify(icon="tabler:plus"), disabled=True)], gap=4), ], gap="md", align="flex-start", wrap="wrap", ), dmc.Text("", id="csg-status", size="xs", c="dimmed", mt="xs"), dmc.Divider(my="sm"), dmc.Text("MY SPECIMENS", fw=700, size="xs", c="dimmed", style={"letterSpacing": "1px", "fontFamily": "monospace"}), html.Div(id="csg-mine", style={"display": "flex", "gap": "8px", "flexWrap": "wrap", "marginTop": "6px"}), dmc.Text("Generated specimens are saved here (this session). They become playable cells in the " "Petri Dish Game.", id="csg-mine-hint", size="xs", c="dimmed", mt=4), ], p="md", radius="md", withBorder=True, mt="md", ) # ----------------------------------------------------------------- gallery ----- _gallery = dmc.Paper( [ dmc.Text("SPECIMEN CATALOG", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}, mb="xs"), dmc.SimpleGrid(cols={"base": 4, "sm": 6, "md": 8}, spacing="xs", children=[_base_thumb(i, it) for i, it in enumerate(BASES)]), ], p="md", radius="md", withBorder=True, mt="md", ) component = dmc.Stack( [ dmc.Group([_builder, _outline], align="flex-start", gap="xl", wrap="wrap"), _lab, _gallery, dcc.Store(id="csg-bases", data=BASES), dcc.Store(id="csg-parts", data=PARTS), dcc.Store(id="csg-selected", data={"index": 0}), dcc.Store(id="csg-outline"), # {angles, radii, src, name} dcc.Store(id="csg-marked-png"), # base64 png (no prefix) for Gemini dcc.Store(id="csg-result-store"), # raw Gemini output (black-on-white) dcc.Store(id="csg-clean-store"), # cleaned black-on-transparent silhouette dcc.Store(id="csg-mine-store", data=[]), dcc.Download(id="csg-download"), ], gap="md", ) # ========================== client-side callbacks ============================= # 1) base gallery click -> selected index clientside_callback( """ function(clicks) { const ctx = window.dash_clientside.callback_context; const tid = ctx.triggered_id; if (!tid || tid.index == null) { return window.dash_clientside.no_update; } return {index: tid.index}; } """, Output("csg-selected", "data"), Input({"type": "csg-base", "index": ALL}, "n_clicks"), prevent_initial_call=True, ) # 2) trace the selected base's outline (async: image -> canvas -> ray-cast) clientside_callback( """ async function(sel, bases) { const nu = window.dash_clientside.no_update; if (!sel || sel.index == null || !bases) { return [nu, nu, nu]; } const item = bases[sel.index]; if (!item) { return [nu, nu, nu]; } const N = 180; const img = new Image(); await new Promise(res => { img.onload = res; img.onerror = res; img.src = item.src; }); const S = 320; const cv = document.createElement('canvas'); cv.width = S; cv.height = S; const cx2 = cv.getContext('2d'); let iw = img.naturalWidth || S, ih = img.naturalHeight || S; const scale = Math.min(S / iw, S / ih) * 0.9, w = iw * scale, h = ih * scale; cx2.drawImage(img, (S - w) / 2, (S - h) / 2, w, h); let data; try { data = cx2.getImageData(0, 0, S, S).data; } catch (e) { return [{angles: [], radii: [], src: item.src, name: item.name}, item.src, item.name]; } const al = (x, y) => (x < 0 || y < 0 || x >= S || y >= S) ? 0 : data[(y * S + x) * 4 + 3]; let cx = 0, cy = 0, nn = 0; for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) if (al(x, y) > 40) { cx += x; cy += y; nn++; } if (!nn) { return [{angles: [], radii: [], src: item.src, name: item.name}, item.src, item.name]; } cx /= nn; cy /= nn; const angles = [], radii = []; for (let k = 0; k < N; k++) { const v = k / N * 360, a = v * Math.PI / 180, dx = Math.sin(a), dy = -Math.cos(a); let last = 0; for (let r = 2; r < S; r += 1) { if (al(Math.round(cx + dx * r), Math.round(cy + dy * r)) > 40) { last = r; } } angles.push(v); radii.push(last); } let mx = 1; for (let i = 0; i < N; i++) { if (radii[i] > mx) { mx = radii[i]; } } const norm = radii.map(r => Math.round(r / mx * 1000) / 10); // light smoothing const out = norm.slice(); for (let i = 0; i < N; i++) { out[i] = (norm[(i - 1 + N) % N] + norm[i] + norm[(i + 1) % N]) / 3; } return [{angles: angles, radii: out, src: item.src, name: item.name}, item.src, item.name]; } """, Output("csg-outline", "data"), Output("csg-preview", "src"), Output("csg-base-name", "children"), Input("csg-selected", "data"), State("csg-bases", "data"), ) # 3) outline + section -> chart series (outline line + marked wedge) + readout clientside_callback( """ function(outline, section) { const nu = window.dash_clientside.no_update; if (!outline || !outline.radii || !outline.radii.length) { return [[], nu, '']; } const ang = outline.angles, rad = outline.radii, N = rad.length; let a0 = section ? section[0] : 0, a1 = section ? section[1] : 0; const inSec = v => (a0 <= a1) ? (v >= a0 && v <= a1) : (v >= a0 || v <= a1); const wedge = ang.map((v, i) => inSec(v) ? rad[i] : null); const series = [ {data: rad, closePath: true, showMark: false, area: true, curve: 'catmullRom', color: '#3399ff', label: outline.name || 'outline'}, {data: wedge, closePath: false, showMark: false, area: true, curve: 'catmullRom', color: '#e8590c', label: 'marked section'}, ]; const rot = [{data: ang, min: 0, max: 360, tickInterval: [0, 45, 90, 135, 180, 225, 270, 315]}]; const readout = 'section ' + a0 + '°–' + a1 + '° (' + ((a0 <= a1 ? a1 - a0 : 360 - a0 + a1)) + '° arc)'; return [series, rot, readout]; } """, Output("csg-chart", "series"), Output("csg-chart", "rotationAxis"), Output("csg-section-readout", "children"), Input("csg-outline", "data"), Input("csg-section", "value"), ) # 4) rasterize the marked image (base silhouette + highlighted wedge) for Gemini, # and build the live "marked input" preview. Recomputes on base/section change. clientside_callback( """ async function(outline, section, sel, bases) { const nu = window.dash_clientside.no_update; if (!outline || !bases || sel == null) { return [nu, nu]; } const item = bases[sel.index]; if (!item) { return [nu, nu]; } const a0 = section ? section[0] : 0, a1 = section ? section[1] : 0; const img = new Image(); await new Promise(res => { img.onload = res; img.onerror = res; img.src = item.src; }); const S = 384; const cv = document.createElement('canvas'); cv.width = S; cv.height = S; const g = cv.getContext('2d'); // light backdrop so the black silhouette reads in the Gemini input g.fillStyle = '#ffffff'; g.fillRect(0, 0, S, S); let iw = img.naturalWidth || S, ih = img.naturalHeight || S; const sc = Math.min(S / iw, S / ih) * 0.82, w = iw * sc, h = ih * sc; g.drawImage(img, (S - w) / 2, (S - h) / 2, w, h); // highlight wedge over the marked angular section (0=up, clockwise) const cx = S / 2, cy = S / 2, R = S * 0.5; const toRad = d => (d - 90) * Math.PI / 180; // canvas 0=east; our 0=up => -90 g.beginPath(); g.moveTo(cx, cy); const start = toRad(a0), end = toRad(a1 >= a0 ? a1 : a1 + 360); g.arc(cx, cy, R, start, end, false); g.closePath(); g.fillStyle = 'rgba(232,89,12,0.32)'; g.fill(); g.strokeStyle = 'rgba(232,89,12,0.9)'; g.lineWidth = 3; g.stroke(); let url; try { url = cv.toDataURL('image/png'); } catch (e) { return [nu, nu]; } return [url, url.split(',')[1]]; } """, Output("csg-marked-preview", "src"), Output("csg-marked-png", "data"), Input("csg-outline", "data"), Input("csg-section", "value"), State("csg-selected", "data"), State("csg-bases", "data"), ) # 5) export the marked outline as a downloadable SVG (vector polygon + wedge) clientside_callback( """ function(n, outline, section) { if (!n || !outline || !outline.radii || !outline.radii.length) { return window.dash_clientside.no_update; } const ang = outline.angles, rad = outline.radii, N = rad.length; const C = 256, K = 230; const pt = i => { const a = ang[i] * Math.PI / 180, r = rad[i] / 100 * K; return [(C + Math.sin(a) * r).toFixed(1), (C - Math.cos(a) * r).toFixed(1)]; }; let d = 'M' + pt(0).join(','); for (let i = 1; i < N; i++) { d += ' L' + pt(i).join(','); } d += ' Z'; // wedge path for the marked section let a0 = section ? section[0] : 0, a1 = section ? section[1] : 0; const inSec = v => (a0 <= a1) ? (v >= a0 && v <= a1) : (v >= a0 || v <= a1); let wd = '', started = false; for (let i = 0; i < N; i++) { if (inSec(ang[i])) { const p = pt(i); wd += (started ? ' L' : 'M' + C + ',' + C + ' L') + p.join(','); started = true; } } if (started) { wd += ' Z'; } const svg = ''; return {content: svg, filename: (outline.name || 'specimen').replace(/\\s+/g, '_') + '_marked.svg', type: 'image/svg+xml'}; } """, Output("csg-download", "data"), Input("csg-export", "n_clicks"), State("csg-outline", "data"), State("csg-section", "value"), prevent_initial_call=True, ) # 6) GENERATE — SERVER-SIDE so the API key stays on the server. Sends the marked # PNG + the adaptation instruction to Gemini and returns a new black silhouette. # Ask for black-on-WHITE (which the model produces reliably) — the browser then # thresholds the white away to get a clean black-on-transparent, tintable silhouette. _PROMPT = ( "This image shows a single-celled microorganism as a solid black silhouette on " "a white background, with a translucent ORANGE wedge marking WHERE a new body " "part should grow. Generate a NEW image of the SAME organism as one connected, " "solid BLACK silhouette centered on a plain SOLID WHITE background, with {desc} " "added and integrated naturally into the body at the marked location. Keep the " "rest of the body shape recognisable. Flat 2D top-down scientific icon " "(PhyloPic style): pure black shape on pure white, no orange, no other colour, " "no shading, no gradient, no outline stroke, no text. Fill most of the frame." ) @callback( Output("csg-result-store", "data"), Output("csg-status", "children"), Output("csg-save", "disabled"), Input("csg-generate", "n_clicks"), State("csg-marked-png", "data"), State("csg-part", "value"), State("csg-outline", "data"), State("csg-section", "value"), prevent_initial_call=True, running=[(Output("csg-generate", "loading"), True, False)], ) def _generate(n, png_b64, part_id, outline, section): if not n: return no_update, no_update, no_update key = os.environ.get("GEMINI_IMAGE_API_KEY", "") if not key: return no_update, "⚠ GEMINI_IMAGE_API_KEY is not set on the server.", True if not png_b64: return no_update, "⚠ Pick a base specimen first so there's an outline to adapt.", True part = _PART_BY_ID.get(part_id, PARTS[0]) base_name = (outline or {}).get("name", "cell") prompt = _PROMPT.format(desc=part["desc"]) url = f"https://generativelanguage.googleapis.com/v1beta/models/{_GEMINI_MODEL}:generateContent" body = {"contents": [{"parts": [ {"text": prompt}, {"inline_data": {"mime_type": "image/png", "data": png_b64}}, ]}]} try: r = requests.post(url, params={"key": key}, json=body, timeout=120) except requests.RequestException as exc: return no_update, f"⚠ Network error reaching Gemini: {exc}", True if r.status_code != 200: detail = "" try: detail = r.json().get("error", {}).get("message", "") except Exception: detail = r.text[:160] return no_update, f"⚠ Gemini error {r.status_code}: {detail}", True parts = r.json().get("candidates", [{}])[0].get("content", {}).get("parts", []) img = next((p for p in parts if "inlineData" in p or "inline_data" in p), None) if not img: txt = next((p.get("text", "") for p in parts if "text" in p), "no image returned") return no_update, f"⚠ Gemini returned no image: {txt[:160]}", True d = img.get("inlineData") or img.get("inline_data") mime = d.get("mimeType") or d.get("mime_type") or "image/png" data_url = f"data:{mime};base64,{d['data']}" status = f"✓ Generated {base_name} + {part['label']} ({section[0]}°–{section[1]}°) — cleaning…" return ({"src": data_url, "name": f"{base_name} + {part['label']}", "part": part_id, "role": part["role"], "diet": part["diet"]}, status, False) # 6b) CLEAN — turn Gemini's black-on-WHITE output into a black-on-TRANSPARENT # silhouette (threshold luminance -> alpha). This is what makes it tintable # and lets the dark-mode invert filter show it correctly. Runs in the browser. clientside_callback( """ async function(raw) { const nu = window.dash_clientside.no_update; if (!raw || !raw.src) { return [nu, nu, nu]; } const img = new Image(); await new Promise(res => { img.onload = res; img.onerror = res; img.src = raw.src; }); const S = 512; const cv = document.createElement('canvas'); cv.width = S; cv.height = S; const g = cv.getContext('2d'); let iw = img.naturalWidth || S, ih = img.naturalHeight || S; const sc = Math.min(S / iw, S / ih), w = iw * sc, h = ih * sc; g.drawImage(img, (S - w) / 2, (S - h) / 2, w, h); let im; try { im = g.getImageData(0, 0, S, S); } catch (e) { return [raw.src, raw, '✓ ' + (raw.name || 'specimen') + ' (raw)']; } const px = im.data, T = 150, SOFT = 45; for (let i = 0; i < px.length; i += 4) { const lum = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2]; const a = lum >= T + SOFT ? 0 : lum <= T - SOFT ? 255 : Math.round(255 * (T + SOFT - lum) / (2 * SOFT)); px[i] = 0; px[i + 1] = 0; px[i + 2] = 0; px[i + 3] = a; } g.putImageData(im, 0, 0); const clean = cv.toDataURL('image/png'); const out = Object.assign({}, raw, {src: clean}); return [clean, out, '✓ ' + (raw.name || 'specimen') + ' — transparent silhouette ready']; } """, Output("csg-result", "src"), Output("csg-clean-store", "data"), Output("csg-status", "children", allow_duplicate=True), Input("csg-result-store", "data"), prevent_initial_call=True, ) # 7) save generated specimen into MY SPECIMENS clientside_callback( """ function(n, result, mine) { if (!n || !result) { return window.dash_clientside.no_update; } mine = (mine || []).slice(); mine.push(result); return mine; } """, Output("csg-mine-store", "data"), Input("csg-save", "n_clicks"), State("csg-clean-store", "data"), State("csg-mine-store", "data"), prevent_initial_call=True, ) # 8) render MY SPECIMENS gallery clientside_callback( """ function(mine) { const host = document.getElementById('csg-mine'); if (!host) { return window.dash_clientside.no_update; } mine = mine || []; if (!mine.length) { host.innerHTML = '— none yet —'; return mine.length; } host.innerHTML = mine.map(m => '