playground / app.py
github-actions[bot]
Sync from OpenLithoHub@0e4852c
f935ca3
"""OpenLithoHub Playground — Interactive web demo for computational lithography evaluation."""
from __future__ import annotations
import json
import os
from pathlib import Path
# Upper bound on the longest side of an uploaded mask. EPE uses a distance
# transform on the GPU/CPU tensor, so memory grows with W*H. 1024 keeps a
# single evaluation comfortably under 1 GB on the HF free 16 GB container.
MAX_UPLOAD_DIM = int(os.environ.get("OPENLITHOHUB_MAX_UPLOAD_DIM", "1024"))
# Monkeypatch gradio_client.utils to handle bool schemas (Gradio 4.44 bug)
# https://github.com/gradio-app/gradio/issues/10662
# E402 is unavoidable here: the patch must run before `import gradio` so that
# gradio_client.utils is replaced before gradio caches its references.
import gradio_client.utils as _gc_utils # noqa: E402
_orig_json_schema_to_python_type = _gc_utils._json_schema_to_python_type
_orig_get_type = _gc_utils.get_type
def _patched_json_schema_to_python_type(schema, defs=None):
if isinstance(schema, bool):
return "Any"
return _orig_json_schema_to_python_type(schema, defs)
def _patched_get_type(schema):
if not isinstance(schema, dict):
return "Any"
return _orig_get_type(schema)
_gc_utils._json_schema_to_python_type = _patched_json_schema_to_python_type
_gc_utils.get_type = _patched_get_type
import gradio as gr # noqa: E402
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import torch # noqa: E402
from openlithohub.benchmark.compliance.mrc import check_mrc as _olh_check_mrc # noqa: E402
from openlithohub.benchmark.metrics.epe import _extract_edges as _olh_extract_edges # noqa: E402
from openlithohub.benchmark.metrics.epe import compute_epe as _olh_compute_epe # noqa: E402
# ---------------------------------------------------------------------------
# Metric adapters — thin numpy → torch wrappers around the canonical
# openlithohub implementations so the Space and the CLI/leaderboard always
# report identical numbers.
# ---------------------------------------------------------------------------
def _extract_edges(binary: np.ndarray) -> np.ndarray:
edges = _olh_extract_edges(torch.from_numpy(binary.astype(np.float32)))
return edges.numpy().astype(np.float32)
def compute_epe(predicted: np.ndarray, target: np.ndarray, pixel_size_nm: float = 1.0) -> dict:
return _olh_compute_epe(
torch.from_numpy(predicted.astype(np.float32)),
torch.from_numpy(target.astype(np.float32)),
pixel_size_nm=pixel_size_nm,
)
def check_mrc(
mask: np.ndarray,
min_width_nm: float = 40.0,
min_spacing_nm: float = 40.0,
pixel_size_nm: float = 1.0,
) -> dict:
result = _olh_check_mrc(
torch.from_numpy(mask.astype(np.float32)),
min_width_nm=min_width_nm,
min_spacing_nm=min_spacing_nm,
pixel_size_nm=pixel_size_nm,
)
return {
"passed": result.passed,
"violation_count": result.violation_count,
"violation_rate": result.violation_rate,
"width_violations": result.width_violation_count,
"spacing_violations": result.spacing_violation_count,
}
# ---------------------------------------------------------------------------
# Pattern generators
# ---------------------------------------------------------------------------
def generate_line_space(size: int = 256, pitch_px: int = 20, duty: float = 0.5) -> np.ndarray:
"""Generate a line/space pattern."""
mask = np.zeros((size, size), dtype=np.float32)
line_width = int(pitch_px * duty)
for x in range(0, size, pitch_px):
mask[:, x : x + line_width] = 1.0
return mask
def generate_contact_holes(size: int = 256, hole_size: int = 10, pitch: int = 40) -> np.ndarray:
"""Generate a contact hole array pattern."""
mask = np.ones((size, size), dtype=np.float32)
for y in range(pitch // 2, size, pitch):
for x in range(pitch // 2, size, pitch):
y0, y1 = max(0, y - hole_size // 2), min(size, y + hole_size // 2)
x0, x1 = max(0, x - hole_size // 2), min(size, x + hole_size // 2)
mask[y0:y1, x0:x1] = 0.0
return mask
def generate_sram(size: int = 256) -> np.ndarray:
"""Generate an SRAM-like pattern with varied features."""
mask = np.zeros((size, size), dtype=np.float32)
# Horizontal lines
for y in range(20, size - 20, 40):
mask[y : y + 8, 10 : size - 10] = 1.0
# Vertical connections
for x in range(30, size - 30, 60):
for y in range(20, size - 40, 80):
mask[y : y + 40, x : x + 6] = 1.0
# Contact pads
for y in range(40, size - 40, 80):
for x in range(50, size - 50, 80):
mask[y - 5 : y + 5, x - 5 : x + 5] = 1.0
return mask
def generate_random_logic(size: int = 256, *, seed: int = 7) -> np.ndarray:
"""Manhattan random-logic routing on a coarse grid (back-end-of-line look)."""
rng = np.random.default_rng(seed)
mask = np.zeros((size, size), dtype=np.float32)
grid = 16
for gy in range(grid // 2, size, grid):
for gx in range(grid // 2, size, grid):
roll = rng.random()
if roll < 0.35:
length = rng.integers(8, 28)
width = rng.integers(2, 5)
x0 = max(0, gx - length // 2)
x1 = min(size, gx + length // 2)
y0 = max(0, gy - width // 2)
y1 = min(size, gy + width // 2)
mask[y0:y1, x0:x1] = 1.0
elif roll < 0.65:
length = rng.integers(8, 28)
width = rng.integers(2, 5)
y0 = max(0, gy - length // 2)
y1 = min(size, gy + length // 2)
x0 = max(0, gx - width // 2)
x1 = min(size, gx + width // 2)
mask[y0:y1, x0:x1] = 1.0
elif roll < 0.72:
via = 4
y0 = max(0, gy - via // 2)
y1 = min(size, gy + via // 2)
x0 = max(0, gx - via // 2)
x1 = min(size, gx + via // 2)
mask[y0:y1, x0:x1] = 1.0
return mask
PATTERN_GENERATORS = {
"Line/Space": generate_line_space,
"Contact Holes": generate_contact_holes,
"SRAM-like": generate_sram,
"Random Logic": generate_random_logic,
}
# ---------------------------------------------------------------------------
# Visualization
# ---------------------------------------------------------------------------
def visualize_masks(
predicted: np.ndarray,
target: np.ndarray,
*,
pixel_size_nm: float = 1.0,
min_width_nm: float = 40.0,
min_spacing_nm: float = 40.0,
) -> plt.Figure:
"""5-panel visualization: target, predicted, edge overlay, EPE heatmap, MRC overlay."""
from openlithohub.vis import plot_epe_heatmap, plot_mrc_overlay
fig, axes = plt.subplots(1, 5, figsize=(22, 4.6))
axes[0].imshow(target, cmap="gray", interpolation="nearest")
axes[0].set_title("Target (Design)")
axes[0].axis("off")
axes[1].imshow(predicted, cmap="gray", interpolation="nearest")
axes[1].set_title("Predicted (Mask)")
axes[1].axis("off")
# Edge overlay
pred_edges = _extract_edges(predicted)
tgt_edges = _extract_edges(target)
overlay = np.zeros((*target.shape, 3), dtype=np.float32)
overlay[tgt_edges > 0] = [0.0, 1.0, 0.0] # green = target edges
overlay[pred_edges > 0] = [1.0, 0.0, 0.0] # red = predicted edges
both = (pred_edges > 0) & (tgt_edges > 0)
overlay[both] = [1.0, 1.0, 0.0] # yellow = overlap
axes[2].imshow(overlay, interpolation="nearest")
axes[2].set_title("Edge Overlay (G=Tgt, R=Pred)")
axes[2].axis("off")
plot_epe_heatmap(predicted, target, pixel_size_nm=pixel_size_nm, ax=axes[3])
plot_mrc_overlay(
predicted,
min_width_nm=min_width_nm,
min_spacing_nm=min_spacing_nm,
pixel_size_nm=pixel_size_nm,
ax=axes[4],
)
plt.tight_layout()
return fig
# ---------------------------------------------------------------------------
# Gradio interface functions
# ---------------------------------------------------------------------------
def evaluate_pattern(
pattern_type: str,
noise_level: float,
pixel_size_nm: float,
min_width_nm: float,
min_spacing_nm: float,
):
"""Generate pattern, add noise as 'predicted', compute metrics."""
generator = PATTERN_GENERATORS[pattern_type]
target = generator(size=256)
# Simulate an imperfect prediction by adding noise
rng = np.random.default_rng(42)
noise = rng.normal(0, noise_level, target.shape).astype(np.float32)
predicted = np.clip(target + noise, 0, 1)
predicted = (predicted > 0.5).astype(np.float32)
# Compute metrics
epe = compute_epe(predicted, target, pixel_size_nm=pixel_size_nm)
mrc = check_mrc(
predicted,
min_width_nm=min_width_nm,
min_spacing_nm=min_spacing_nm,
pixel_size_nm=pixel_size_nm,
)
# Visualization
fig = visualize_masks(
predicted,
target,
pixel_size_nm=pixel_size_nm,
min_width_nm=min_width_nm,
min_spacing_nm=min_spacing_nm,
)
metrics_text = (
f"## Evaluation Results\n\n"
f"| Metric | Value |\n"
f"|--------|-------|\n"
f"| EPE Mean | {epe['epe_mean_nm']:.3f} nm |\n"
f"| EPE Max | {epe['epe_max_nm']:.3f} nm |\n"
f"| EPE Std | {epe['epe_std_nm']:.3f} nm |\n"
f"| MRC Passed | {'Yes' if mrc['passed'] else 'No'} |\n"
f"| Width Violations | {mrc['width_violations']} |\n"
f"| Spacing Violations | {mrc['spacing_violations']} |\n"
f"| Violation Rate | {mrc['violation_rate']:.6f} |\n"
)
return fig, metrics_text
def evaluate_uploaded(
pred_file,
target_file,
pixel_size_nm: float,
min_width_nm: float,
min_spacing_nm: float,
):
"""Evaluate uploaded mask images."""
from PIL import Image
from openlithohub._utils.auto_crop import auto_crop
if pred_file is None or target_file is None:
return None, "Please upload both predicted and target mask images."
with Image.open(pred_file) as pred_img_raw, Image.open(target_file) as tgt_img_raw:
src_w, src_h = pred_img_raw.size
pred_img = pred_img_raw.convert("L")
tgt_img = tgt_img_raw.convert("L")
# Resize to match if different
if pred_img.size != tgt_img.size:
tgt_img = tgt_img.resize(pred_img.size, Image.NEAREST)
predicted = (np.array(pred_img, dtype=np.float32) / 255.0 > 0.5).astype(np.float32)
target = (np.array(tgt_img, dtype=np.float32) / 255.0 > 0.5).astype(np.float32)
# Auto-Crop: if either axis exceeds MAX_UPLOAD_DIM, locate the densest
# MAX_UPLOAD_DIM-square window on the predicted mask and crop both tensors
# at the same bbox. Keeps EPE on the user's actual area of interest
# instead of bailing out, and stays within the HF free-tier memory budget.
crop_notice = ""
if max(predicted.shape) > MAX_UPLOAD_DIM:
pred_t = torch.from_numpy(predicted)
_, bbox = auto_crop(pred_t, target_size=MAX_UPLOAD_DIM)
y0, x0, y1, x1 = bbox
predicted = predicted[y0:y1, x0:x1]
target = target[y0:y1, x0:x1]
crop_notice = (
f"\n\n*Auto-cropped from {src_w}×{src_h} to "
f"{x1 - x0}×{y1 - y0} at bbox y={y0}..{y1}, x={x0}..{x1} "
f"(densest window).*"
)
epe = compute_epe(predicted, target, pixel_size_nm=pixel_size_nm)
mrc = check_mrc(
predicted,
min_width_nm=min_width_nm,
min_spacing_nm=min_spacing_nm,
pixel_size_nm=pixel_size_nm,
)
fig = visualize_masks(
predicted,
target,
pixel_size_nm=pixel_size_nm,
min_width_nm=min_width_nm,
min_spacing_nm=min_spacing_nm,
)
metrics_text = (
f"## Evaluation Results\n\n"
f"| Metric | Value |\n"
f"|--------|-------|\n"
f"| EPE Mean | {epe['epe_mean_nm']:.3f} nm |\n"
f"| EPE Max | {epe['epe_max_nm']:.3f} nm |\n"
f"| EPE Std | {epe['epe_std_nm']:.3f} nm |\n"
f"| MRC Passed | {'Yes' if mrc['passed'] else 'No'} |\n"
f"| Width Violations | {mrc['width_violations']} |\n"
f"| Spacing Violations | {mrc['spacing_violations']} |\n"
f"| Violation Rate | {mrc['violation_rate']:.6f} |\n" + crop_notice
)
return fig, metrics_text
# ---------------------------------------------------------------------------
# Leaderboard view
# ---------------------------------------------------------------------------
def _leaderboard_path() -> Path:
here = Path(__file__).parent.resolve()
home_dir = (Path.home() / ".openlithohub").resolve()
env = os.environ.get("OPENLITHOHUB_LEADERBOARD_PATH")
if env:
# Restrict the env-var override to absolute paths under the Space
# directory or the user's ~/.openlithohub/ — operator-controlled,
# but the same code path runs locally where a stray env var should
# not point at /etc/shadow or similar.
candidate = Path(env).resolve()
try:
candidate.relative_to(here)
return candidate
except ValueError:
pass
try:
candidate.relative_to(home_dir)
return candidate
except ValueError:
pass
# Silently fall through to the default candidates rather than
# crashing the Space at import time on a misconfigured env var.
candidates = [
here / "leaderboard.json",
home_dir / "leaderboard.json",
]
for c in candidates:
if c.exists():
return c
return candidates[0]
def load_leaderboard():
"""Read the JSON leaderboard. Returns ``(rows, status_md)``."""
path = _leaderboard_path()
if not path.exists():
return [], (
"_No leaderboard entries yet. Submit your model via "
"`openlithohub submit` — see the [submission guide]"
"(https://github.com/OpenLithoHub/OpenLithoHub#leaderboard)._"
)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return [], f"_Failed to parse leaderboard: {exc}_"
entries = data.get("entries", [])
rows = []
for e in entries:
rows.append(
[
e.get("model_name", ""),
e.get("dataset", ""),
e.get("process_node", ""),
e.get("mask_topology", ""),
e.get("epe_mean_nm"),
e.get("epe_max_nm"),
e.get("pvband_mean_nm"),
e.get("pvband_max_nm"),
e.get("shot_count"),
e.get("paper_url") or e.get("code_url") or "",
]
)
rows.sort(key=lambda r: (r[4] is None, r[4]))
status = f"_{len(rows)} submission(s) — sorted by EPE mean (lower is better)._"
return rows, status
# ---------------------------------------------------------------------------
# Built-in preset examples (committed to spaces/examples/)
# ---------------------------------------------------------------------------
# Source of truth for the demo PNGs is ``scripts/generate_demo_samples.py``.
# Shipping them under spaces/examples/ avoids the prior tempdir-on-cold-start
# fragility on HF Space and gives users browseable inputs in the repo.
_EXAMPLES_DIR = Path(__file__).resolve().parent / "examples"
_PRESET_SAMPLES: list[tuple[str, str, float, float, float]] = [
("line_space", "Line/Space", 1.0, 10.0, 10.0),
("contact_holes", "Contact Holes", 1.0, 10.0, 10.0),
("sram_like", "SRAM-like", 1.0, 10.0, 10.0),
("random_logic", "Random Logic", 1.0, 10.0, 10.0),
]
def _get_upload_examples() -> list[list[str | float]]:
"""Return Upload-tab examples as [pred, target, px_nm, mw_nm, ms_nm] rows.
Missing PNGs (e.g., a checkout without scripts/generate_demo_samples.py
output) are silently skipped — the Space stays up.
"""
rows: list[list[str | float]] = []
for slug, _label, px, mw, ms in _PRESET_SAMPLES:
pred = _EXAMPLES_DIR / f"{slug}_pred.png"
tgt = _EXAMPLES_DIR / f"{slug}_target.png"
if pred.exists() and tgt.exists():
rows.append([str(pred), str(tgt), px, mw, ms])
return rows
def _get_pattern_examples() -> list[list[str | float]]:
"""Return Synthetic-tab examples as [pattern, noise, px_nm, mw_nm, ms_nm] rows."""
return [[label, 0.10, px, mw, ms] for _slug, label, px, mw, ms in _PRESET_SAMPLES]
# ---------------------------------------------------------------------------
# Gradio App
# ---------------------------------------------------------------------------
# Tab bar contrast fix — Gradio Soft theme renders unselected tabs in a pale
# gray that fails WCAG AA on light backgrounds. Darken unselected labels and
# mark the selected tab with the OpenLithoHub brand blue used on the website.
_TAB_CSS = """
.tab-nav { border-bottom: 1px solid #c6c6cd; }
.tab-nav button {
color: #45464d;
font-weight: 600;
opacity: 1;
}
.tab-nav button:hover { color: #0058be; }
.tab-nav button.selected {
color: #0058be;
border-bottom: 2px solid #0058be;
}
"""
with gr.Blocks(
title="OpenLithoHub Playground",
theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="cyan"),
css=_TAB_CSS,
) as demo:
gr.Markdown(
"""
# OpenLithoHub Playground
**Interactive evaluation for computational lithography models**
Compute Edge Placement Error (EPE), MRC compliance, and visualize mask quality.
"""
)
with gr.Tabs():
# Tab 1: Synthetic pattern evaluation
with gr.TabItem("Synthetic Patterns"):
gr.Markdown("Generate synthetic test patterns and evaluate with simulated noise.")
with gr.Row():
with gr.Column(scale=1):
pattern_type = gr.Dropdown(
choices=list(PATTERN_GENERATORS.keys()),
value="Line/Space",
label="Pattern Type",
)
noise_level = gr.Slider(0.0, 0.5, value=0.1, step=0.01, label="Noise Level")
pixel_size = gr.Number(value=1.0, label="Pixel Size (nm)")
min_width = gr.Number(value=10.0, label="Min Width (nm)")
min_spacing = gr.Number(value=10.0, label="Min Spacing (nm)")
eval_btn = gr.Button("Evaluate", variant="primary")
with gr.Column(scale=2):
plot_output = gr.Plot(label="Visualization")
metrics_output = gr.Markdown()
eval_btn.click(
fn=evaluate_pattern,
inputs=[pattern_type, noise_level, pixel_size, min_width, min_spacing],
outputs=[plot_output, metrics_output],
)
gr.Examples(
examples=_get_pattern_examples(),
inputs=[pattern_type, noise_level, pixel_size, min_width, min_spacing],
label="Try a preset",
examples_per_page=4,
)
# Tab 2: Upload evaluation
with gr.TabItem("Upload Masks"):
gr.Markdown(
"Upload your own predicted and target mask images (grayscale, thresholded at 50%)."
)
with gr.Row():
with gr.Column(scale=1):
pred_upload = gr.Image(type="filepath", label="Predicted Mask")
tgt_upload = gr.Image(type="filepath", label="Target Mask")
px_size_upload = gr.Number(value=1.0, label="Pixel Size (nm)")
mw_upload = gr.Number(value=40.0, label="Min Width (nm)")
ms_upload = gr.Number(value=40.0, label="Min Spacing (nm)")
upload_btn = gr.Button("Evaluate", variant="primary")
with gr.Column(scale=2):
upload_plot = gr.Plot(label="Visualization")
upload_metrics = gr.Markdown()
upload_btn.click(
fn=evaluate_uploaded,
inputs=[pred_upload, tgt_upload, px_size_upload, mw_upload, ms_upload],
outputs=[upload_plot, upload_metrics],
)
gr.Examples(
examples=_get_upload_examples(),
inputs=[pred_upload, tgt_upload, px_size_upload, mw_upload, ms_upload],
label="Try a preset",
examples_per_page=4,
)
# Tab 3: Leaderboard
with gr.TabItem("Leaderboard"):
gr.Markdown(
"""
## Community SOTA Leaderboard
Snapshot of community-submitted benchmark results, sorted by mean EPE.
Submissions go through `openlithohub submit` against the published
LithoBench / LithoSim splits — see the
[submission guide](https://github.com/OpenLithoHub/OpenLithoHub#leaderboard).
"""
)
lb_status = gr.Markdown()
lb_table = gr.Dataframe(
headers=[
"Model",
"Dataset",
"Node",
"Topology",
"EPE mean (nm)",
"EPE max (nm)",
"PV band mean (nm)",
"PV band max (nm)",
"Shot count",
"Reference",
],
datatype=[
"str",
"str",
"str",
"str",
"number",
"number",
"number",
"number",
"number",
"str",
],
interactive=False,
wrap=True,
)
refresh_btn = gr.Button("Refresh", variant="secondary")
def _load():
rows, status = load_leaderboard()
return rows, status
demo.load(fn=_load, inputs=None, outputs=[lb_table, lb_status])
refresh_btn.click(fn=_load, inputs=None, outputs=[lb_table, lb_status])
gr.Markdown(
"""
---
**OpenLithoHub** | [GitHub](https://github.com/OpenLithoHub/OpenLithoHub) |
[Docs](https://docs.openlithohub.com) |
[Leaderboard](https://openlithohub.com/leaderboard) |
Apache 2.0 License
"""
)
if __name__ == "__main__":
demo.launch()