github-actions[bot] commited on
Commit
61c0f3d
·
0 Parent(s):

Sync from OpenLithoHub@c84a0fc

Browse files
README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: OpenLithoHub Playground
3
+ emoji: 🔬
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: 4.44.1
8
+ python_version: 3.11
9
+ app_file: app.py
10
+ pinned: false
11
+ license: apache-2.0
12
+ tags:
13
+ - computational-lithography
14
+ - semiconductor
15
+ - OPC
16
+ - lithography
17
+ - EUV
18
+ - mask-optimization
19
+ - EPE
20
+ - MRC
21
+ ---
22
+
23
+ # OpenLithoHub Playground
24
+
25
+ Interactive web playground for computational lithography evaluation.
26
+
27
+ **Features:**
28
+ - Upload or generate synthetic mask designs
29
+ - Compute lithography metrics (EPE, PV Band, MRC/DRC)
30
+ - Visualize predicted vs. target contours
app.py ADDED
@@ -0,0 +1,632 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenLithoHub Playground — Interactive web demo for computational lithography evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ # Upper bound on the longest side of an uploaded mask. EPE uses a distance
10
+ # transform on the GPU/CPU tensor, so memory grows with W*H. 1024 keeps a
11
+ # single evaluation comfortably under 1 GB on the HF free 16 GB container.
12
+ MAX_UPLOAD_DIM = int(os.environ.get("OPENLITHOHUB_MAX_UPLOAD_DIM", "1024"))
13
+
14
+ # Monkeypatch gradio_client.utils to handle bool schemas (Gradio 4.44 bug)
15
+ # https://github.com/gradio-app/gradio/issues/10662
16
+ # E402 is unavoidable here: the patch must run before `import gradio` so that
17
+ # gradio_client.utils is replaced before gradio caches its references.
18
+ import gradio_client.utils as _gc_utils # noqa: E402
19
+
20
+ _orig_json_schema_to_python_type = _gc_utils._json_schema_to_python_type
21
+ _orig_get_type = _gc_utils.get_type
22
+
23
+
24
+ def _patched_json_schema_to_python_type(schema, defs=None):
25
+ if isinstance(schema, bool):
26
+ return "Any"
27
+ return _orig_json_schema_to_python_type(schema, defs)
28
+
29
+
30
+ def _patched_get_type(schema):
31
+ if not isinstance(schema, dict):
32
+ return "Any"
33
+ return _orig_get_type(schema)
34
+
35
+
36
+ _gc_utils._json_schema_to_python_type = _patched_json_schema_to_python_type
37
+ _gc_utils.get_type = _patched_get_type
38
+
39
+ import gradio as gr # noqa: E402
40
+ import matplotlib.pyplot as plt # noqa: E402
41
+ import numpy as np # noqa: E402
42
+ import torch # noqa: E402
43
+
44
+ from openlithohub.benchmark.compliance.mrc import check_mrc as _olh_check_mrc # noqa: E402
45
+ from openlithohub.benchmark.metrics.epe import _extract_edges as _olh_extract_edges # noqa: E402
46
+ from openlithohub.benchmark.metrics.epe import compute_epe as _olh_compute_epe # noqa: E402
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Metric adapters — thin numpy → torch wrappers around the canonical
50
+ # openlithohub implementations so the Space and the CLI/leaderboard always
51
+ # report identical numbers.
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def _extract_edges(binary: np.ndarray) -> np.ndarray:
56
+ edges = _olh_extract_edges(torch.from_numpy(binary.astype(np.float32)))
57
+ return edges.numpy().astype(np.float32)
58
+
59
+
60
+ def compute_epe(predicted: np.ndarray, target: np.ndarray, pixel_size_nm: float = 1.0) -> dict:
61
+ return _olh_compute_epe(
62
+ torch.from_numpy(predicted.astype(np.float32)),
63
+ torch.from_numpy(target.astype(np.float32)),
64
+ pixel_size_nm=pixel_size_nm,
65
+ )
66
+
67
+
68
+ def check_mrc(
69
+ mask: np.ndarray,
70
+ min_width_nm: float = 40.0,
71
+ min_spacing_nm: float = 40.0,
72
+ pixel_size_nm: float = 1.0,
73
+ ) -> dict:
74
+ result = _olh_check_mrc(
75
+ torch.from_numpy(mask.astype(np.float32)),
76
+ min_width_nm=min_width_nm,
77
+ min_spacing_nm=min_spacing_nm,
78
+ pixel_size_nm=pixel_size_nm,
79
+ )
80
+ return {
81
+ "passed": result.passed,
82
+ "violation_count": result.violation_count,
83
+ "violation_rate": result.violation_rate,
84
+ "width_violations": result.width_violation_count,
85
+ "spacing_violations": result.spacing_violation_count,
86
+ }
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Pattern generators
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def generate_line_space(size: int = 256, pitch_px: int = 20, duty: float = 0.5) -> np.ndarray:
95
+ """Generate a line/space pattern."""
96
+ mask = np.zeros((size, size), dtype=np.float32)
97
+ line_width = int(pitch_px * duty)
98
+ for x in range(0, size, pitch_px):
99
+ mask[:, x : x + line_width] = 1.0
100
+ return mask
101
+
102
+
103
+ def generate_contact_holes(size: int = 256, hole_size: int = 10, pitch: int = 40) -> np.ndarray:
104
+ """Generate a contact hole array pattern."""
105
+ mask = np.ones((size, size), dtype=np.float32)
106
+ for y in range(pitch // 2, size, pitch):
107
+ for x in range(pitch // 2, size, pitch):
108
+ y0, y1 = max(0, y - hole_size // 2), min(size, y + hole_size // 2)
109
+ x0, x1 = max(0, x - hole_size // 2), min(size, x + hole_size // 2)
110
+ mask[y0:y1, x0:x1] = 0.0
111
+ return mask
112
+
113
+
114
+ def generate_sram(size: int = 256) -> np.ndarray:
115
+ """Generate an SRAM-like pattern with varied features."""
116
+ mask = np.zeros((size, size), dtype=np.float32)
117
+ # Horizontal lines
118
+ for y in range(20, size - 20, 40):
119
+ mask[y : y + 8, 10 : size - 10] = 1.0
120
+ # Vertical connections
121
+ for x in range(30, size - 30, 60):
122
+ for y in range(20, size - 40, 80):
123
+ mask[y : y + 40, x : x + 6] = 1.0
124
+ # Contact pads
125
+ for y in range(40, size - 40, 80):
126
+ for x in range(50, size - 50, 80):
127
+ mask[y - 5 : y + 5, x - 5 : x + 5] = 1.0
128
+ return mask
129
+
130
+
131
+ def generate_random_logic(size: int = 256, *, seed: int = 7) -> np.ndarray:
132
+ """Manhattan random-logic routing on a coarse grid (back-end-of-line look)."""
133
+ rng = np.random.default_rng(seed)
134
+ mask = np.zeros((size, size), dtype=np.float32)
135
+ grid = 16
136
+ for gy in range(grid // 2, size, grid):
137
+ for gx in range(grid // 2, size, grid):
138
+ roll = rng.random()
139
+ if roll < 0.35:
140
+ length = rng.integers(8, 28)
141
+ width = rng.integers(2, 5)
142
+ x0 = max(0, gx - length // 2)
143
+ x1 = min(size, gx + length // 2)
144
+ y0 = max(0, gy - width // 2)
145
+ y1 = min(size, gy + width // 2)
146
+ mask[y0:y1, x0:x1] = 1.0
147
+ elif roll < 0.65:
148
+ length = rng.integers(8, 28)
149
+ width = rng.integers(2, 5)
150
+ y0 = max(0, gy - length // 2)
151
+ y1 = min(size, gy + length // 2)
152
+ x0 = max(0, gx - width // 2)
153
+ x1 = min(size, gx + width // 2)
154
+ mask[y0:y1, x0:x1] = 1.0
155
+ elif roll < 0.72:
156
+ via = 4
157
+ y0 = max(0, gy - via // 2)
158
+ y1 = min(size, gy + via // 2)
159
+ x0 = max(0, gx - via // 2)
160
+ x1 = min(size, gx + via // 2)
161
+ mask[y0:y1, x0:x1] = 1.0
162
+ return mask
163
+
164
+
165
+ PATTERN_GENERATORS = {
166
+ "Line/Space": generate_line_space,
167
+ "Contact Holes": generate_contact_holes,
168
+ "SRAM-like": generate_sram,
169
+ "Random Logic": generate_random_logic,
170
+ }
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Visualization
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ def visualize_masks(
179
+ predicted: np.ndarray,
180
+ target: np.ndarray,
181
+ *,
182
+ pixel_size_nm: float = 1.0,
183
+ min_width_nm: float = 40.0,
184
+ min_spacing_nm: float = 40.0,
185
+ ) -> plt.Figure:
186
+ """5-panel visualization: target, predicted, edge overlay, EPE heatmap, MRC overlay."""
187
+ from openlithohub.vis import plot_epe_heatmap, plot_mrc_overlay
188
+
189
+ fig, axes = plt.subplots(1, 5, figsize=(22, 4.6))
190
+
191
+ axes[0].imshow(target, cmap="gray", interpolation="nearest")
192
+ axes[0].set_title("Target (Design)")
193
+ axes[0].axis("off")
194
+
195
+ axes[1].imshow(predicted, cmap="gray", interpolation="nearest")
196
+ axes[1].set_title("Predicted (Mask)")
197
+ axes[1].axis("off")
198
+
199
+ # Edge overlay
200
+ pred_edges = _extract_edges(predicted)
201
+ tgt_edges = _extract_edges(target)
202
+ overlay = np.zeros((*target.shape, 3), dtype=np.float32)
203
+ overlay[tgt_edges > 0] = [0.0, 1.0, 0.0] # green = target edges
204
+ overlay[pred_edges > 0] = [1.0, 0.0, 0.0] # red = predicted edges
205
+ both = (pred_edges > 0) & (tgt_edges > 0)
206
+ overlay[both] = [1.0, 1.0, 0.0] # yellow = overlap
207
+
208
+ axes[2].imshow(overlay, interpolation="nearest")
209
+ axes[2].set_title("Edge Overlay (G=Tgt, R=Pred)")
210
+ axes[2].axis("off")
211
+
212
+ plot_epe_heatmap(predicted, target, pixel_size_nm=pixel_size_nm, ax=axes[3])
213
+ plot_mrc_overlay(
214
+ predicted,
215
+ min_width_nm=min_width_nm,
216
+ min_spacing_nm=min_spacing_nm,
217
+ pixel_size_nm=pixel_size_nm,
218
+ ax=axes[4],
219
+ )
220
+
221
+ plt.tight_layout()
222
+ return fig
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Gradio interface functions
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ def evaluate_pattern(
231
+ pattern_type: str,
232
+ noise_level: float,
233
+ pixel_size_nm: float,
234
+ min_width_nm: float,
235
+ min_spacing_nm: float,
236
+ ):
237
+ """Generate pattern, add noise as 'predicted', compute metrics."""
238
+ generator = PATTERN_GENERATORS[pattern_type]
239
+ target = generator(size=256)
240
+
241
+ # Simulate an imperfect prediction by adding noise
242
+ rng = np.random.default_rng(42)
243
+ noise = rng.normal(0, noise_level, target.shape).astype(np.float32)
244
+ predicted = np.clip(target + noise, 0, 1)
245
+ predicted = (predicted > 0.5).astype(np.float32)
246
+
247
+ # Compute metrics
248
+ epe = compute_epe(predicted, target, pixel_size_nm=pixel_size_nm)
249
+ mrc = check_mrc(
250
+ predicted,
251
+ min_width_nm=min_width_nm,
252
+ min_spacing_nm=min_spacing_nm,
253
+ pixel_size_nm=pixel_size_nm,
254
+ )
255
+
256
+ # Visualization
257
+ fig = visualize_masks(
258
+ predicted,
259
+ target,
260
+ pixel_size_nm=pixel_size_nm,
261
+ min_width_nm=min_width_nm,
262
+ min_spacing_nm=min_spacing_nm,
263
+ )
264
+
265
+ metrics_text = (
266
+ f"## Evaluation Results\n\n"
267
+ f"| Metric | Value |\n"
268
+ f"|--------|-------|\n"
269
+ f"| EPE Mean | {epe['epe_mean_nm']:.3f} nm |\n"
270
+ f"| EPE Max | {epe['epe_max_nm']:.3f} nm |\n"
271
+ f"| EPE Std | {epe['epe_std_nm']:.3f} nm |\n"
272
+ f"| MRC Passed | {'Yes' if mrc['passed'] else 'No'} |\n"
273
+ f"| Width Violations | {mrc['width_violations']} |\n"
274
+ f"| Spacing Violations | {mrc['spacing_violations']} |\n"
275
+ f"| Violation Rate | {mrc['violation_rate']:.6f} |\n"
276
+ )
277
+
278
+ return fig, metrics_text
279
+
280
+
281
+ def evaluate_uploaded(
282
+ pred_file,
283
+ target_file,
284
+ pixel_size_nm: float,
285
+ min_width_nm: float,
286
+ min_spacing_nm: float,
287
+ ):
288
+ """Evaluate uploaded mask images."""
289
+ from PIL import Image
290
+
291
+ from openlithohub._utils.auto_crop import auto_crop
292
+
293
+ if pred_file is None or target_file is None:
294
+ return None, "Please upload both predicted and target mask images."
295
+
296
+ with Image.open(pred_file) as pred_img_raw, Image.open(target_file) as tgt_img_raw:
297
+ src_w, src_h = pred_img_raw.size
298
+ pred_img = pred_img_raw.convert("L")
299
+ tgt_img = tgt_img_raw.convert("L")
300
+
301
+ # Resize to match if different
302
+ if pred_img.size != tgt_img.size:
303
+ tgt_img = tgt_img.resize(pred_img.size, Image.NEAREST)
304
+
305
+ predicted = (np.array(pred_img, dtype=np.float32) / 255.0 > 0.5).astype(np.float32)
306
+ target = (np.array(tgt_img, dtype=np.float32) / 255.0 > 0.5).astype(np.float32)
307
+
308
+ # Auto-Crop: if either axis exceeds MAX_UPLOAD_DIM, locate the densest
309
+ # MAX_UPLOAD_DIM-square window on the predicted mask and crop both tensors
310
+ # at the same bbox. Keeps EPE on the user's actual area of interest
311
+ # instead of bailing out, and stays within the HF free-tier memory budget.
312
+ crop_notice = ""
313
+ if max(predicted.shape) > MAX_UPLOAD_DIM:
314
+ pred_t = torch.from_numpy(predicted)
315
+ _, bbox = auto_crop(pred_t, target_size=MAX_UPLOAD_DIM)
316
+ y0, x0, y1, x1 = bbox
317
+ predicted = predicted[y0:y1, x0:x1]
318
+ target = target[y0:y1, x0:x1]
319
+ crop_notice = (
320
+ f"\n\n*Auto-cropped from {src_w}×{src_h} to "
321
+ f"{x1 - x0}×{y1 - y0} at bbox y={y0}..{y1}, x={x0}..{x1} "
322
+ f"(densest window).*"
323
+ )
324
+
325
+ epe = compute_epe(predicted, target, pixel_size_nm=pixel_size_nm)
326
+ mrc = check_mrc(
327
+ predicted,
328
+ min_width_nm=min_width_nm,
329
+ min_spacing_nm=min_spacing_nm,
330
+ pixel_size_nm=pixel_size_nm,
331
+ )
332
+
333
+ fig = visualize_masks(
334
+ predicted,
335
+ target,
336
+ pixel_size_nm=pixel_size_nm,
337
+ min_width_nm=min_width_nm,
338
+ min_spacing_nm=min_spacing_nm,
339
+ )
340
+
341
+ metrics_text = (
342
+ f"## Evaluation Results\n\n"
343
+ f"| Metric | Value |\n"
344
+ f"|--------|-------|\n"
345
+ f"| EPE Mean | {epe['epe_mean_nm']:.3f} nm |\n"
346
+ f"| EPE Max | {epe['epe_max_nm']:.3f} nm |\n"
347
+ f"| EPE Std | {epe['epe_std_nm']:.3f} nm |\n"
348
+ f"| MRC Passed | {'Yes' if mrc['passed'] else 'No'} |\n"
349
+ f"| Width Violations | {mrc['width_violations']} |\n"
350
+ f"| Spacing Violations | {mrc['spacing_violations']} |\n"
351
+ f"| Violation Rate | {mrc['violation_rate']:.6f} |\n" + crop_notice
352
+ )
353
+
354
+ return fig, metrics_text
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Leaderboard view
359
+ # ---------------------------------------------------------------------------
360
+
361
+
362
+ def _leaderboard_path() -> Path:
363
+ here = Path(__file__).parent.resolve()
364
+ home_dir = (Path.home() / ".openlithohub").resolve()
365
+ env = os.environ.get("OPENLITHOHUB_LEADERBOARD_PATH")
366
+ if env:
367
+ # Restrict the env-var override to absolute paths under the Space
368
+ # directory or the user's ~/.openlithohub/ — operator-controlled,
369
+ # but the same code path runs locally where a stray env var should
370
+ # not point at /etc/shadow or similar.
371
+ candidate = Path(env).resolve()
372
+ try:
373
+ candidate.relative_to(here)
374
+ return candidate
375
+ except ValueError:
376
+ pass
377
+ try:
378
+ candidate.relative_to(home_dir)
379
+ return candidate
380
+ except ValueError:
381
+ pass
382
+ # Silently fall through to the default candidates rather than
383
+ # crashing the Space at import time on a misconfigured env var.
384
+ candidates = [
385
+ here / "leaderboard.json",
386
+ home_dir / "leaderboard.json",
387
+ ]
388
+ for c in candidates:
389
+ if c.exists():
390
+ return c
391
+ return candidates[0]
392
+
393
+
394
+ def load_leaderboard():
395
+ """Read the JSON leaderboard. Returns ``(rows, status_md)``."""
396
+ path = _leaderboard_path()
397
+ if not path.exists():
398
+ return [], (
399
+ "_No leaderboard entries yet. Submit your model via "
400
+ "`openlithohub submit` — see the [submission guide]"
401
+ "(https://github.com/OpenLithoHub/OpenLithoHub#leaderboard)._"
402
+ )
403
+ try:
404
+ data = json.loads(path.read_text(encoding="utf-8"))
405
+ except json.JSONDecodeError as exc:
406
+ return [], f"_Failed to parse leaderboard: {exc}_"
407
+
408
+ entries = data.get("entries", [])
409
+ rows = []
410
+ for e in entries:
411
+ rows.append(
412
+ [
413
+ e.get("model_name", ""),
414
+ e.get("dataset", ""),
415
+ e.get("process_node", ""),
416
+ e.get("mask_topology", ""),
417
+ e.get("l2_error_pixels"),
418
+ e.get("epe_mean_nm"),
419
+ e.get("epe_max_nm"),
420
+ e.get("pvband_mean_nm"),
421
+ e.get("pvband_max_nm"),
422
+ e.get("shot_count"),
423
+ e.get("paper_url") or e.get("code_url") or "",
424
+ ]
425
+ )
426
+ rows.sort(key=lambda r: (r[4] is None, r[4]))
427
+ status = f"_{len(rows)} submission(s) — sorted by L2 wafer error (lower is better)._"
428
+ return rows, status
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # Built-in preset examples (committed to spaces/examples/)
433
+ # ---------------------------------------------------------------------------
434
+
435
+ # Source of truth for the demo PNGs is ``scripts/generate_demo_samples.py``.
436
+ # Shipping them under spaces/examples/ avoids the prior tempdir-on-cold-start
437
+ # fragility on HF Space and gives users browseable inputs in the repo.
438
+ _EXAMPLES_DIR = Path(__file__).resolve().parent / "examples"
439
+
440
+ _PRESET_SAMPLES: list[tuple[str, str, float, float, float]] = [
441
+ ("line_space", "Line/Space", 1.0, 10.0, 10.0),
442
+ ("contact_holes", "Contact Holes", 1.0, 10.0, 10.0),
443
+ ("sram_like", "SRAM-like", 1.0, 10.0, 10.0),
444
+ ("random_logic", "Random Logic", 1.0, 10.0, 10.0),
445
+ ]
446
+
447
+
448
+ def _get_upload_examples() -> list[list[str | float]]:
449
+ """Return Upload-tab examples as [pred, target, px_nm, mw_nm, ms_nm] rows.
450
+
451
+ Missing PNGs (e.g., a checkout without scripts/generate_demo_samples.py
452
+ output) are silently skipped — the Space stays up.
453
+ """
454
+ rows: list[list[str | float]] = []
455
+ for slug, _label, px, mw, ms in _PRESET_SAMPLES:
456
+ pred = _EXAMPLES_DIR / f"{slug}_pred.png"
457
+ tgt = _EXAMPLES_DIR / f"{slug}_target.png"
458
+ if pred.exists() and tgt.exists():
459
+ rows.append([str(pred), str(tgt), px, mw, ms])
460
+ return rows
461
+
462
+
463
+ def _get_pattern_examples() -> list[list[str | float]]:
464
+ """Return Synthetic-tab examples as [pattern, noise, px_nm, mw_nm, ms_nm] rows."""
465
+ return [[label, 0.10, px, mw, ms] for _slug, label, px, mw, ms in _PRESET_SAMPLES]
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # Gradio App
470
+ # ---------------------------------------------------------------------------
471
+
472
+
473
+ # Tab bar contrast fix — Gradio Soft theme renders unselected tabs in a pale
474
+ # gray that fails WCAG AA on light backgrounds. Darken unselected labels and
475
+ # mark the selected tab with the OpenLithoHub brand blue used on the website.
476
+ _TAB_CSS = """
477
+ .tab-nav { border-bottom: 1px solid #c6c6cd; }
478
+ .tab-nav button {
479
+ color: #45464d;
480
+ font-weight: 600;
481
+ opacity: 1;
482
+ }
483
+ .tab-nav button:hover { color: #0058be; }
484
+ .tab-nav button.selected {
485
+ color: #0058be;
486
+ border-bottom: 2px solid #0058be;
487
+ }
488
+ """
489
+
490
+ with gr.Blocks(
491
+ title="OpenLithoHub Playground",
492
+ theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="cyan"),
493
+ css=_TAB_CSS,
494
+ ) as demo:
495
+ gr.Markdown(
496
+ """
497
+ # OpenLithoHub Playground
498
+ **Interactive evaluation for computational lithography models**
499
+
500
+ Compute Edge Placement Error (EPE), MRC compliance, and visualize mask quality.
501
+ """
502
+ )
503
+
504
+ with gr.Tabs():
505
+ # Tab 1: Synthetic pattern evaluation
506
+ with gr.TabItem("Synthetic Patterns"):
507
+ gr.Markdown("Generate synthetic test patterns and evaluate with simulated noise.")
508
+ with gr.Row():
509
+ with gr.Column(scale=1):
510
+ pattern_type = gr.Dropdown(
511
+ choices=list(PATTERN_GENERATORS.keys()),
512
+ value="Line/Space",
513
+ label="Pattern Type",
514
+ )
515
+ noise_level = gr.Slider(0.0, 0.5, value=0.1, step=0.01, label="Noise Level")
516
+ pixel_size = gr.Number(value=1.0, label="Pixel Size (nm)")
517
+ min_width = gr.Number(value=10.0, label="Min Width (nm)")
518
+ min_spacing = gr.Number(value=10.0, label="Min Spacing (nm)")
519
+ eval_btn = gr.Button("Evaluate", variant="primary")
520
+
521
+ with gr.Column(scale=2):
522
+ plot_output = gr.Plot(label="Visualization")
523
+ metrics_output = gr.Markdown()
524
+
525
+ eval_btn.click(
526
+ fn=evaluate_pattern,
527
+ inputs=[pattern_type, noise_level, pixel_size, min_width, min_spacing],
528
+ outputs=[plot_output, metrics_output],
529
+ )
530
+
531
+ gr.Examples(
532
+ examples=_get_pattern_examples(),
533
+ inputs=[pattern_type, noise_level, pixel_size, min_width, min_spacing],
534
+ label="Try a preset",
535
+ examples_per_page=4,
536
+ )
537
+
538
+ # Tab 2: Upload evaluation
539
+ with gr.TabItem("Upload Masks"):
540
+ gr.Markdown(
541
+ "Upload your own predicted and target mask images (grayscale, thresholded at 50%)."
542
+ )
543
+ with gr.Row():
544
+ with gr.Column(scale=1):
545
+ pred_upload = gr.Image(type="filepath", label="Predicted Mask")
546
+ tgt_upload = gr.Image(type="filepath", label="Target Mask")
547
+ px_size_upload = gr.Number(value=1.0, label="Pixel Size (nm)")
548
+ mw_upload = gr.Number(value=40.0, label="Min Width (nm)")
549
+ ms_upload = gr.Number(value=40.0, label="Min Spacing (nm)")
550
+ upload_btn = gr.Button("Evaluate", variant="primary")
551
+
552
+ with gr.Column(scale=2):
553
+ upload_plot = gr.Plot(label="Visualization")
554
+ upload_metrics = gr.Markdown()
555
+
556
+ upload_btn.click(
557
+ fn=evaluate_uploaded,
558
+ inputs=[pred_upload, tgt_upload, px_size_upload, mw_upload, ms_upload],
559
+ outputs=[upload_plot, upload_metrics],
560
+ )
561
+
562
+ gr.Examples(
563
+ examples=_get_upload_examples(),
564
+ inputs=[pred_upload, tgt_upload, px_size_upload, mw_upload, ms_upload],
565
+ label="Try a preset",
566
+ examples_per_page=4,
567
+ )
568
+
569
+ # Tab 3: Leaderboard
570
+ with gr.TabItem("Leaderboard"):
571
+ gr.Markdown(
572
+ """
573
+ ## Community SOTA Leaderboard
574
+
575
+ Snapshot of community-submitted benchmark results, sorted by L2 wafer error.
576
+ Submissions go through `openlithohub submit` against the published
577
+ LithoBench / LithoSim splits — see the
578
+ [submission guide](https://github.com/OpenLithoHub/OpenLithoHub#leaderboard).
579
+ """
580
+ )
581
+ lb_status = gr.Markdown()
582
+ lb_table = gr.Dataframe(
583
+ headers=[
584
+ "Model",
585
+ "Dataset",
586
+ "Node",
587
+ "Topology",
588
+ "L2 error (px)",
589
+ "EPE mean (nm)",
590
+ "EPE max (nm)",
591
+ "PV band mean (nm)",
592
+ "PV band max (nm)",
593
+ "Shot count",
594
+ "Reference",
595
+ ],
596
+ datatype=[
597
+ "str",
598
+ "str",
599
+ "str",
600
+ "str",
601
+ "number",
602
+ "number",
603
+ "number",
604
+ "number",
605
+ "number",
606
+ "number",
607
+ "str",
608
+ ],
609
+ interactive=False,
610
+ wrap=True,
611
+ )
612
+ refresh_btn = gr.Button("Refresh", variant="secondary")
613
+
614
+ def _load():
615
+ rows, status = load_leaderboard()
616
+ return rows, status
617
+
618
+ demo.load(fn=_load, inputs=None, outputs=[lb_table, lb_status])
619
+ refresh_btn.click(fn=_load, inputs=None, outputs=[lb_table, lb_status])
620
+
621
+ gr.Markdown(
622
+ """
623
+ ---
624
+ **OpenLithoHub** | [GitHub](https://github.com/OpenLithoHub/OpenLithoHub) |
625
+ [Docs](https://docs.openlithohub.com) |
626
+ [Leaderboard](https://openlithohub.com/leaderboard) |
627
+ Apache 2.0 License
628
+ """
629
+ )
630
+
631
+ if __name__ == "__main__":
632
+ demo.launch()
examples/README.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Built-in demo samples
2
+
3
+ These PNGs power the HF Playground "Try a preset" examples in both the
4
+ Synthetic Patterns tab and the Upload Masks tab. They are deterministic
5
+ outputs of `scripts/generate_demo_samples.py` (run from the repo root) and
6
+ are committed so the HF Space cold-start has no runtime tempdir dependency.
7
+
8
+ | Slug | Pattern | Source |
9
+ | --------------- | --------------- | -------------------------------------- |
10
+ | `line_space` | Line/Space grid | Synthetic, Apache-2.0 (this repo) |
11
+ | `contact_holes` | Contact array | Synthetic, Apache-2.0 (this repo) |
12
+ | `sram_like` | SRAM-like cell | Synthetic, Apache-2.0 (this repo) |
13
+ | `random_logic` | Manhattan logic | Synthetic, Apache-2.0 (this repo) |
14
+
15
+ Each preset ships as two files: `<slug>_target.png` (clean design) and
16
+ `<slug>_pred.png` (a perturbed prediction with intentional EPE > 0).
17
+
18
+ To regenerate after changing the script:
19
+
20
+ ```bash
21
+ .venv/bin/python scripts/generate_demo_samples.py
22
+ ```
examples/contact_holes_pred.png ADDED
examples/contact_holes_target.png ADDED
examples/line_space_pred.png ADDED
examples/line_space_target.png ADDED
examples/random_logic_pred.png ADDED
examples/random_logic_target.png ADDED
examples/sram_like_pred.png ADDED
examples/sram_like_target.png ADDED
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ numpy>=1.24
2
+ torch>=2.0
3
+ gradio==4.44.1
4
+ gradio_client>=1.3.0
5
+ huggingface_hub>=0.20,<1.0
6
+ starlette>=0.40,<0.46
7
+ fastapi>=0.115,<0.116
8
+ matplotlib>=3.7
9
+ Pillow>=10.0
10
+ pydantic>=2.0
11
+ scipy>=1.10
12
+ openlithohub @ git+https://github.com/OpenLithoHub/OpenLithoHub.git@c84a0fc661fafaa0b9d82e554ccc1bd059b8298a