Michael Rabinovich commited on
Commit ·
0f9004d
1
Parent(s): 85bb21f
Accept mesh candidates in leaderboard submissions
Browse files- README.md +1 -1
- app.py +8 -5
- metrics_page.py +11 -5
- submit.py +52 -27
- tests/test_submit.py +20 -3
README.md
CHANGED
|
@@ -30,6 +30,6 @@ textual or visual description of a mechanical part into a valid,
|
|
| 30 |
geometrically correct 3D model?
|
| 31 |
|
| 32 |
Browse the **Leaderboard** tab for ranked submissions, or upload your
|
| 33 |
-
own STEP files on the **Submit** tab: the Space runs the eval and
|
| 34 |
publishes a row with a per-submission report. The **About** tab covers
|
| 35 |
the scoring axes and links to the benchmark code and datasets.
|
|
|
|
| 30 |
geometrically correct 3D model?
|
| 31 |
|
| 32 |
Browse the **Leaderboard** tab for ranked submissions, or upload your
|
| 33 |
+
own STEP/BREP or mesh candidate files on the **Submit** tab: the Space runs the eval and
|
| 34 |
publishes a row with a per-submission report. The **About** tab covers
|
| 35 |
the scoring axes and links to the benchmark code and datasets.
|
app.py
CHANGED
|
@@ -107,8 +107,9 @@ ABOUT_MD = f"""## About
|
|
| 107 |
turn a description of a mechanical part into a valid, geometrically
|
| 108 |
correct 3D model.
|
| 109 |
|
| 110 |
-
- **Reference baseline**: an iterative AI agent that writes
|
| 111 |
-
- **Submission flow**: upload a zip of per-fixture
|
|
|
|
| 112 |
runs the eval and appends a row to the submissions dataset.
|
| 113 |
- **Datasets**: fixture inputs in
|
| 114 |
[`{HF_DATA_REPO}`](https://huggingface.co/datasets/{HF_DATA_REPO});
|
|
@@ -1151,9 +1152,11 @@ with gr.Blocks(
|
|
| 1151 |
f"""
|
| 1152 |
**Submission format.** A single zip with:
|
| 1153 |
|
| 1154 |
-
- one folder per sample in `{HF_DATA_REPO}`; include
|
| 1155 |
-
samples where your system produced a
|
| 1156 |
-
|
|
|
|
|
|
|
| 1157 |
- a top-level `meta.json`:
|
| 1158 |
|
| 1159 |
```json
|
|
|
|
| 107 |
turn a description of a mechanical part into a valid, geometrically
|
| 108 |
correct 3D model.
|
| 109 |
|
| 110 |
+
- **Reference baseline**: an iterative AI agent that writes CAD code.
|
| 111 |
+
- **Submission flow**: upload a zip of per-fixture candidate files (STEP/BREP
|
| 112 |
+
or mesh); the Space
|
| 113 |
runs the eval and appends a row to the submissions dataset.
|
| 114 |
- **Datasets**: fixture inputs in
|
| 115 |
[`{HF_DATA_REPO}`](https://huggingface.co/datasets/{HF_DATA_REPO});
|
|
|
|
| 1152 |
f"""
|
| 1153 |
**Submission format.** A single zip with:
|
| 1154 |
|
| 1155 |
+
- one folder per sample in `{HF_DATA_REPO}`; include one accepted candidate
|
| 1156 |
+
file for samples where your system produced a result. Accepted names:
|
| 1157 |
+
`output.step`, `output.stp`, `output.stl`, `output.obj`, `output.off`,
|
| 1158 |
+
`output.3mf`, or `output.ply`. Missing `output.*` scores zero for that
|
| 1159 |
+
sample;
|
| 1160 |
- a top-level `meta.json`:
|
| 1161 |
|
| 1162 |
```json
|
metrics_page.py
CHANGED
|
@@ -15,7 +15,7 @@
|
|
| 15 |
"""Self-contained "Metrics" explainer page for the Space.
|
| 16 |
|
| 17 |
Builds one static, dependency-free HTML document explaining how a
|
| 18 |
-
candidate
|
| 19 |
axes (shape / topology / interface), and the editing renormalization.
|
| 20 |
|
| 21 |
It is curated (a Space-tailored summary, deliberately a little
|
|
@@ -147,8 +147,8 @@ def build_metrics_page() -> str:
|
|
| 147 |
tag="CAD Score",
|
| 148 |
title="How one part is scored",
|
| 149 |
body=(
|
| 150 |
-
"<p>CADGenBench scores a generated part (
|
| 151 |
-
"ground-truth STEP. First a hard <b>validity gate</b>; if it "
|
| 152 |
"passes, the <b>CAD Score</b> is a weighted mean of three "
|
| 153 |
"independent metrics, each in [0, 1].</p>"
|
| 154 |
'<pre class="formula">'
|
|
@@ -190,10 +190,11 @@ def build_metrics_page() -> str:
|
|
| 190 |
tag="Gate",
|
| 191 |
title="CAD Validity",
|
| 192 |
body=(
|
| 193 |
-
"<p>Runs before every other metric on the raw candidate
|
| 194 |
"failure sets <code>is_valid = False</code> and forces "
|
| 195 |
"<code>cad_score = 0</code>, so an invalid solid never beats a worse "
|
| 196 |
-
"but valid one.
|
|
|
|
| 197 |
"<ol>"
|
| 198 |
"<li><b>Well-formed BREP</b>: no per-face / edge / vertex errors "
|
| 199 |
"(self-intersecting wires, edges off their surface, etc.).</li>"
|
|
@@ -203,6 +204,11 @@ def build_metrics_page() -> str:
|
|
| 203 |
"to a manifold, closed (3F = 2E), orientation-consistent triangle "
|
| 204 |
"mesh.</li>"
|
| 205 |
"</ol>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
),
|
| 207 |
)
|
| 208 |
|
|
|
|
| 15 |
"""Self-contained "Metrics" explainer page for the Space.
|
| 16 |
|
| 17 |
Builds one static, dependency-free HTML document explaining how a
|
| 18 |
+
candidate file is scored: the validity gate, the three orthogonal
|
| 19 |
axes (shape / topology / interface), and the editing renormalization.
|
| 20 |
|
| 21 |
It is curated (a Space-tailored summary, deliberately a little
|
|
|
|
| 147 |
tag="CAD Score",
|
| 148 |
title="How one part is scored",
|
| 149 |
body=(
|
| 150 |
+
"<p>CADGenBench scores a generated part (STEP/BREP or mesh) "
|
| 151 |
+
"against one ground-truth STEP. First a hard <b>validity gate</b>; if it "
|
| 152 |
"passes, the <b>CAD Score</b> is a weighted mean of three "
|
| 153 |
"independent metrics, each in [0, 1].</p>"
|
| 154 |
'<pre class="formula">'
|
|
|
|
| 190 |
tag="Gate",
|
| 191 |
title="CAD Validity",
|
| 192 |
body=(
|
| 193 |
+
"<p>Runs before every other metric on the raw candidate. Any "
|
| 194 |
"failure sets <code>is_valid = False</code> and forces "
|
| 195 |
"<code>cad_score = 0</code>, so an invalid solid never beats a worse "
|
| 196 |
+
"but valid one.</p>"
|
| 197 |
+
"<p><b>STEP/BREP candidates</b> must pass all of:</p>"
|
| 198 |
"<ol>"
|
| 199 |
"<li><b>Well-formed BREP</b>: no per-face / edge / vertex errors "
|
| 200 |
"(self-intersecting wires, edges off their surface, etc.).</li>"
|
|
|
|
| 204 |
"to a manifold, closed (3F = 2E), orientation-consistent triangle "
|
| 205 |
"mesh.</li>"
|
| 206 |
"</ol>"
|
| 207 |
+
"<p><b>Mesh candidates</b> (<code>output.stl</code>, "
|
| 208 |
+
"<code>output.obj</code>, <code>output.off</code>, "
|
| 209 |
+
"<code>output.3mf</code>, or <code>output.ply</code>) skip BREP "
|
| 210 |
+
"checks and must directly satisfy the mesh gate: manifold, closed, "
|
| 211 |
+
"and orientation-consistent.</p>"
|
| 212 |
),
|
| 213 |
)
|
| 214 |
|
submit.py
CHANGED
|
@@ -44,11 +44,12 @@ Validation gates, in order:
|
|
| 44 |
4. Fixture-set match: the set of folders inside the zip equals the
|
| 45 |
set of fixture directories in :func:`cadgenbench.common.paths.data_inputs_dir`
|
| 46 |
(no missing, no extras).
|
| 47 |
-
5.
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
| 52 |
|
| 53 |
Hub-write ordering (after validation passes):
|
| 54 |
|
|
@@ -103,6 +104,7 @@ from typing import Any
|
|
| 103 |
import cadgenbench
|
| 104 |
import gradio as gr
|
| 105 |
from cadgenbench.common.paths import data_inputs_dir
|
|
|
|
| 106 |
from cadgenbench.common.validity import parse_step
|
| 107 |
from huggingface_hub import (
|
| 108 |
CommitOperationAdd,
|
|
@@ -443,13 +445,13 @@ def handle_submit(
|
|
| 443 |
yield _submit_status(
|
| 444 |
"working",
|
| 445 |
"Validating submission (unpacking the zip, checking the sample set "
|
| 446 |
-
"and
|
| 447 |
)
|
| 448 |
|
| 449 |
zip_path = Path(zip_file.name)
|
| 450 |
|
| 451 |
# The tempdir lives only for the cheap-sync validation pass
|
| 452 |
-
# (unpack zip, validate meta + fixture set +
|
| 453 |
# The Job downloads the zip itself from the Hub, so the
|
| 454 |
# Space-local unpack is throwaway and the tempdir gets cleaned
|
| 455 |
# up unconditionally in the outer finally.
|
|
@@ -461,7 +463,7 @@ def handle_submit(
|
|
| 461 |
_extract_zip(zip_path, run_dir)
|
| 462 |
meta = _load_and_validate_meta(run_dir)
|
| 463 |
fixture_names = _validate_fixture_set(run_dir)
|
| 464 |
-
|
| 465 |
except _ValidationError as e:
|
| 466 |
msg = f"Submission rejected: {e}"
|
| 467 |
yield _submit_status("error", msg)
|
|
@@ -691,9 +693,10 @@ def _validate_fixture_set(unpacked: Path) -> set[str]:
|
|
| 691 |
return expected
|
| 692 |
|
| 693 |
|
| 694 |
-
def
|
| 695 |
# Threads (not processes): OCC's parse_step releases the GIL during
|
| 696 |
-
# the C++ STEP read, and
|
|
|
|
| 697 |
# (which is the only piece in the eval pipeline that needs the
|
| 698 |
# ProcessPoolExecutor + spawn dance). Per-fixture I/O + OCC load is
|
| 699 |
# 1-5s, so fanning out a 5+ fixture set across cpu-upgrade vCPUs
|
|
@@ -701,36 +704,58 @@ def _validate_steps_parseable(unpacked: Path, fixture_names: set[str]) -> None:
|
|
| 701 |
# exception when its iterator is consumed, so wrapping in list()
|
| 702 |
# preserves the same `Sample <name>` rejection text as the
|
| 703 |
# sequential loop did.
|
| 704 |
-
def
|
| 705 |
-
|
| 706 |
-
if
|
| 707 |
# Missing output is a valid benchmark outcome: the evaluator writes
|
| 708 |
# status="missing" and the fixture contributes cad_score=0.
|
| 709 |
return
|
| 710 |
-
if
|
| 711 |
raise _ValidationError(
|
| 712 |
-
f"Sample `{name}` has an empty `{
|
| 713 |
)
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
raise _ValidationError(
|
| 718 |
-
f"Sample `{name}`
|
| 719 |
-
|
| 720 |
-
) from e
|
| 721 |
|
| 722 |
with ThreadPoolExecutor(
|
| 723 |
max_workers=min(8, os.cpu_count() or 1),
|
| 724 |
) as ex:
|
| 725 |
-
list(ex.map(
|
|
|
|
| 726 |
|
|
|
|
|
|
|
| 727 |
|
| 728 |
-
|
| 729 |
-
|
|
|
|
| 730 |
for name in ("output.step", "output.stp"):
|
| 731 |
-
|
| 732 |
-
if
|
| 733 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
return None
|
| 735 |
|
| 736 |
|
|
|
|
| 44 |
4. Fixture-set match: the set of folders inside the zip equals the
|
| 45 |
set of fixture directories in :func:`cadgenbench.common.paths.data_inputs_dir`
|
| 46 |
(no missing, no extras).
|
| 47 |
+
5. Candidate parseability: any present ``<fixture>/output.*`` candidate
|
| 48 |
+
(STEP/BREP or accepted mesh format) can be cheaply loaded. A missing
|
| 49 |
+
candidate is allowed and scores zero via the evaluator's
|
| 50 |
+
``status="missing"`` path. Per-fixture validity (watertight, manifold,
|
| 51 |
+
etc.) is *not* checked here; this gate only rejects files that are present
|
| 52 |
+
but not actually loadable as their declared candidate kind.
|
| 53 |
|
| 54 |
Hub-write ordering (after validation passes):
|
| 55 |
|
|
|
|
| 104 |
import cadgenbench
|
| 105 |
import gradio as gr
|
| 106 |
from cadgenbench.common.paths import data_inputs_dir
|
| 107 |
+
from cadgenbench.common.mesh import MESH_FILE_SUFFIXES, mesh_from_file
|
| 108 |
from cadgenbench.common.validity import parse_step
|
| 109 |
from huggingface_hub import (
|
| 110 |
CommitOperationAdd,
|
|
|
|
| 445 |
yield _submit_status(
|
| 446 |
"working",
|
| 447 |
"Validating submission (unpacking the zip, checking the sample set "
|
| 448 |
+
"and candidate files)…",
|
| 449 |
)
|
| 450 |
|
| 451 |
zip_path = Path(zip_file.name)
|
| 452 |
|
| 453 |
# The tempdir lives only for the cheap-sync validation pass
|
| 454 |
+
# (unpack zip, validate meta + fixture set + candidate parseability).
|
| 455 |
# The Job downloads the zip itself from the Hub, so the
|
| 456 |
# Space-local unpack is throwaway and the tempdir gets cleaned
|
| 457 |
# up unconditionally in the outer finally.
|
|
|
|
| 463 |
_extract_zip(zip_path, run_dir)
|
| 464 |
meta = _load_and_validate_meta(run_dir)
|
| 465 |
fixture_names = _validate_fixture_set(run_dir)
|
| 466 |
+
_validate_candidates_parseable(run_dir, fixture_names)
|
| 467 |
except _ValidationError as e:
|
| 468 |
msg = f"Submission rejected: {e}"
|
| 469 |
yield _submit_status("error", msg)
|
|
|
|
| 693 |
return expected
|
| 694 |
|
| 695 |
|
| 696 |
+
def _validate_candidates_parseable(unpacked: Path, fixture_names: set[str]) -> None:
|
| 697 |
# Threads (not processes): OCC's parse_step releases the GIL during
|
| 698 |
+
# the C++ STEP read, and mesh loading is I/O + numpy/trimesh work. This
|
| 699 |
+
# gate doesn't touch the VTK renderer
|
| 700 |
# (which is the only piece in the eval pipeline that needs the
|
| 701 |
# ProcessPoolExecutor + spawn dance). Per-fixture I/O + OCC load is
|
| 702 |
# 1-5s, so fanning out a 5+ fixture set across cpu-upgrade vCPUs
|
|
|
|
| 704 |
# exception when its iterator is consumed, so wrapping in list()
|
| 705 |
# preserves the same `Sample <name>` rejection text as the
|
| 706 |
# sequential loop did.
|
| 707 |
+
def _check_one_candidate(name: str) -> None:
|
| 708 |
+
candidate = _candidate_path(unpacked / name)
|
| 709 |
+
if candidate is None:
|
| 710 |
# Missing output is a valid benchmark outcome: the evaluator writes
|
| 711 |
# status="missing" and the fixture contributes cad_score=0.
|
| 712 |
return
|
| 713 |
+
if candidate.stat().st_size == 0:
|
| 714 |
raise _ValidationError(
|
| 715 |
+
f"Sample `{name}` has an empty `{candidate.name}`."
|
| 716 |
)
|
| 717 |
+
suffix = candidate.suffix.lower()
|
| 718 |
+
if suffix in {".step", ".stp"}:
|
| 719 |
+
try:
|
| 720 |
+
parse_step(candidate)
|
| 721 |
+
except RuntimeError as e:
|
| 722 |
+
raise _ValidationError(
|
| 723 |
+
f"Sample `{name}` has an `{candidate.name}` that is not "
|
| 724 |
+
f"loadable as STEP geometry: {e}"
|
| 725 |
+
) from e
|
| 726 |
+
elif suffix in MESH_FILE_SUFFIXES:
|
| 727 |
+
try:
|
| 728 |
+
mesh_from_file(candidate)
|
| 729 |
+
except Exception as e: # noqa: BLE001 - normalize user-facing error
|
| 730 |
+
raise _ValidationError(
|
| 731 |
+
f"Sample `{name}` has an `{candidate.name}` that is not "
|
| 732 |
+
f"loadable as a triangle mesh: {e}"
|
| 733 |
+
) from e
|
| 734 |
+
else: # pragma: no cover - _candidate_path constrains suffixes
|
| 735 |
raise _ValidationError(
|
| 736 |
+
f"Sample `{name}` uses unsupported candidate file `{candidate.name}`."
|
| 737 |
+
)
|
|
|
|
| 738 |
|
| 739 |
with ThreadPoolExecutor(
|
| 740 |
max_workers=min(8, os.cpu_count() or 1),
|
| 741 |
) as ex:
|
| 742 |
+
list(ex.map(_check_one_candidate, sorted(fixture_names)))
|
| 743 |
+
|
| 744 |
|
| 745 |
+
def _candidate_path(fixture_dir: Path) -> Path | None:
|
| 746 |
+
"""Return the submitted candidate for *fixture_dir*, if present.
|
| 747 |
|
| 748 |
+
STEP/BREP wins when both a STEP and a mesh are present, matching the
|
| 749 |
+
evaluator's candidate discovery policy.
|
| 750 |
+
"""
|
| 751 |
for name in ("output.step", "output.stp"):
|
| 752 |
+
candidate = fixture_dir / name
|
| 753 |
+
if candidate.is_file():
|
| 754 |
+
return candidate
|
| 755 |
+
for suffix in sorted(MESH_FILE_SUFFIXES):
|
| 756 |
+
candidate = fixture_dir / f"output{suffix}"
|
| 757 |
+
if candidate.is_file():
|
| 758 |
+
return candidate
|
| 759 |
return None
|
| 760 |
|
| 761 |
|
tests/test_submit.py
CHANGED
|
@@ -392,14 +392,14 @@ def test_pending_row_preserves_existing_metadata(monkeypatch):
|
|
| 392 |
assert row[k] is None
|
| 393 |
|
| 394 |
|
| 395 |
-
def
|
| 396 |
"""Missing fixture outputs are accepted; evaluator scores them as missing."""
|
| 397 |
(tmp_path / "101").mkdir()
|
| 398 |
(tmp_path / "102").mkdir()
|
| 399 |
calls: list[Path] = []
|
| 400 |
monkeypatch.setattr(submit, "parse_step", lambda p: calls.append(p))
|
| 401 |
|
| 402 |
-
submit.
|
| 403 |
|
| 404 |
assert calls == []
|
| 405 |
|
|
@@ -413,6 +413,23 @@ def test_validate_steps_checks_present_output_stp(tmp_path: Path, monkeypatch):
|
|
| 413 |
calls: list[Path] = []
|
| 414 |
monkeypatch.setattr(submit, "parse_step", lambda p: calls.append(p))
|
| 415 |
|
| 416 |
-
submit.
|
| 417 |
|
| 418 |
assert calls == [candidate]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
assert row[k] is None
|
| 393 |
|
| 394 |
|
| 395 |
+
def test_validate_steps_allows_missing_output_candidate(tmp_path: Path, monkeypatch):
|
| 396 |
"""Missing fixture outputs are accepted; evaluator scores them as missing."""
|
| 397 |
(tmp_path / "101").mkdir()
|
| 398 |
(tmp_path / "102").mkdir()
|
| 399 |
calls: list[Path] = []
|
| 400 |
monkeypatch.setattr(submit, "parse_step", lambda p: calls.append(p))
|
| 401 |
|
| 402 |
+
submit._validate_candidates_parseable(tmp_path, {"101", "102"})
|
| 403 |
|
| 404 |
assert calls == []
|
| 405 |
|
|
|
|
| 413 |
calls: list[Path] = []
|
| 414 |
monkeypatch.setattr(submit, "parse_step", lambda p: calls.append(p))
|
| 415 |
|
| 416 |
+
submit._validate_candidates_parseable(tmp_path, {"101"})
|
| 417 |
|
| 418 |
assert calls == [candidate]
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def test_validate_steps_checks_present_output_mesh(tmp_path: Path, monkeypatch):
|
| 422 |
+
"""Mesh candidates are cheap-load checked instead of STEP-parsed."""
|
| 423 |
+
fixture = tmp_path / "101"
|
| 424 |
+
fixture.mkdir()
|
| 425 |
+
candidate = fixture / "output.stl"
|
| 426 |
+
candidate.write_text("solid empty\nendsolid empty\n")
|
| 427 |
+
step_calls: list[Path] = []
|
| 428 |
+
mesh_calls: list[Path] = []
|
| 429 |
+
monkeypatch.setattr(submit, "parse_step", lambda p: step_calls.append(p))
|
| 430 |
+
monkeypatch.setattr(submit, "mesh_from_file", lambda p: mesh_calls.append(p))
|
| 431 |
+
|
| 432 |
+
submit._validate_candidates_parseable(tmp_path, {"101"})
|
| 433 |
+
|
| 434 |
+
assert step_calls == []
|
| 435 |
+
assert mesh_calls == [candidate]
|