Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,15 @@ jobs:
python scripts\run_fixture_demo.py --experiment "experiments\ci-$protocol" --max-frames 2 --profile wide --protocol $protocol --blind-seed 12345 --dump-dir "release_dumps\$protocol"
}

- name: Upload smoke report
- name: Upload failed-run evidence
uses: actions/upload-artifact@v4
if: always()
if: failure()
with:
name: smoke-report-${{ matrix.python-version }}
path: experiments/ci-smoke/runs/**/report.html
name: failed-run-evidence-${{ matrix.python-version }}
path: |
experiments/ci-smoke/runs/**/report.html
experiments/ci-smoke/runs/**/report.json
experiments/ci-smoke/runs/**/results.json
experiments/ci-smoke/runs/**/processed/*.png
experiments/ci-smoke/runs/**/crops/*.png
if-no-files-found: ignore
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ Illinois Wesleyan/American Journal of Physics single-photon video set. The IWU
set is scientifically stronger but stays external/manual until redistribution
terms are confirmed.

See the [Fixture Catalog](docs/fixture_catalog.md) for expected detector behavior, limitations, licenses, and provenance rules.

## Capture Better Footage

- Capture laser and control footage under matched camera settings, exposure, focus, distance, and surface geometry.
Expand Down Expand Up @@ -193,10 +195,10 @@ Windows users can use the compatibility wrapper:
Release archives are named by version, operating system, and architecture:

```text
dist/LaserLab-v0.3.0-windows-x86_64.zip
dist/LaserLab-v0.3.0-linux-x86_64.zip
dist/LaserLab-v0.3.0-macos-x86_64.zip
dist/LaserLab-v0.3.0-macos-arm64.zip
dist/LaserLab-v<version>-windows-x86_64.zip
dist/LaserLab-v<version>-linux-x86_64.zip
dist/LaserLab-v<version>-macos-x86_64.zip
dist/LaserLab-v<version>-macos-arm64.zip
```

GitHub Actions builds and smoke-tests all four native bundles. Tesseract remains optional at runtime; OCR is reported as unavailable when the external Tesseract executable is not installed.
Expand Down
12 changes: 6 additions & 6 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ On Windows, the existing wrapper installs build dependencies and delegates to th
Outputs are target-specific:

```text
dist/LaserLab-v0.3.0-windows-x86_64.zip
dist/LaserLab-v0.3.0-linux-x86_64.zip
dist/LaserLab-v0.3.0-macos-x86_64.zip
dist/LaserLab-v0.3.0-macos-arm64.zip
dist/LaserLab-v<version>-windows-x86_64.zip
dist/LaserLab-v<version>-linux-x86_64.zip
dist/LaserLab-v<version>-macos-x86_64.zip
dist/LaserLab-v<version>-macos-arm64.zip
```

The zip includes:
Expand Down Expand Up @@ -76,11 +76,11 @@ To process every frame of a video, omit `--max-frames`:
Full-frame `wide` runs generate many processed images and candidate crops. Keep
them under `experiments\`, which is intentionally ignored by Git.

## v0.3 Release Checklist
## v0.3.x Release Checklist

- Run the bundled demo for `diffraction`, `speckle`, `ocr`, and `anomaly` with `-MaxFrames 2`.
- Confirm JSON/HTML reports include badges, q-values, protocol, detector-family summaries, and conservative interpretation text.
- Export a review bundle and inspect `manifest.json`, `report.json`, `results.json`, `environment.json`, `hashes.json`, top crops, and `README.txt`.
- Build all four native archives through the `Cross-platform Release` workflow.
- Verify each platform's GUI `--smoke` and CLI `--help` checks pass.
- Push a `v0.3.0` tag only after tests, executable smoke, and bundled fixture smoke pass.
- Push the matching `v0.3.x` tag only after tests, executable smoke, and bundled fixture smoke pass.
26 changes: 26 additions & 0 deletions docs/fixture_catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Fixture Catalog

LaserLab separates redistributable bundled fixtures from external scientific references. Bundled fixtures are small workflow and detector sanity checks; none is a matched control for a user's camera setup.

## Bundled Fixtures

| Fixture | Phenomena | License | Intended Use | Expected Behavior | Limitations |
| --- | --- | --- | --- | --- | --- |
| Double slit experiment | interference, simulation | CC BY-SA 4.0 | Structured control | Stable fringe-like spatial frequencies; no readable text expected | Rendered simulation, not physical camera footage |
| Young's double slit experiment clip | diffraction, interference | CC BY-SA 3.0 or GFDL | Optical demo input | FFT and fringe metrics should respond more strongly than OCR | Compressed educational video with unknown capture history |
| 3D interference through two pinholes | interference, diffraction, visualization | CC BY-SA 4.0 | Structured optical positive | Repeatable spectral structure expected; text recovery is not | Tomographic visualization, not raw sensor footage |
| Focused Laguerre-Gaussian beam | structured beam, diffraction, rendered control | CC BY-SA 4.0 | Non-text false-positive control | Ring and texture metrics may respond while OCR remains null | Rendered beam field, not a matched camera control |

Exact source URLs, attribution, filenames, SHA-256 hashes, and download URLs are stored in `sample_media/fixture_manifest.json`. Human-readable attribution is in `sample_media/ATTRIBUTION.md`.

## External Manual Fixture

The Illinois Wesleyan single-photon double-slit and ghost-imaging video set is listed in the in-app catalog but is not downloaded or redistributed. Its source page is <https://sun.iwu.edu/~gspaldin/SinglePhotonVideos.html>. Redistribution terms are not confirmed, so users must obtain it manually and document local provenance.

## Validation Rules

Every catalog entry must include an ID, title, media kind, analysis label, license, attribution, source page, optical phenomena, expected detector behavior, limitations, and redistribution status. Every redistributable entry must name a bundled file. Fixture demos stop before analysis if required metadata or a bundled file is missing.

## Interpretation

These fixtures prove that the software can process known optical structure and preserve provenance. They do not validate a claim about a user's footage. Evidence-bearing experiments still require controls recorded with the same camera and optical conditions as the laser capture.
16 changes: 13 additions & 3 deletions gui/lab_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,10 @@ def _compare_tab(self) -> QWidget:
def _fixtures_tab(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
self.fixture_table = QTableWidget(0, 5)
self.fixture_table.setHorizontalHeaderLabels(["ID", "Title", "Label", "License", "Source"])
self.fixture_table = QTableWidget(0, 7)
self.fixture_table.setHorizontalHeaderLabels(
["ID", "Title", "Label", "Phenomena", "License", "Expected behavior", "Source"]
)
self.fixture_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
layout.addWidget(self.fixture_table, 1)

Expand Down Expand Up @@ -847,7 +849,15 @@ def _load_fixtures(self) -> None:
fixtures = list_fixtures(include_restricted=True)
self.fixture_table.setRowCount(len(fixtures))
for row, item in enumerate(fixtures):
values = [item.get("id", ""), item.get("title", ""), item.get("label", ""), item.get("license", ""), item.get("source_page", "")]
values = [
item.get("id", ""),
item.get("title", ""),
item.get("label", ""),
", ".join(item.get("phenomena", [])),
item.get("license", ""),
item.get("expected_behavior", ""),
item.get("source_page", ""),
]
for column, value in enumerate(values):
self.fixture_table.setItem(row, column, QTableWidgetItem(str(value)))

Expand Down
2 changes: 1 addition & 1 deletion laserlab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

__all__ = ["__version__"]

__version__ = "0.3.0"
__version__ = "0.3.1"
2 changes: 2 additions & 0 deletions laserlab/app_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def add_capture(
all_frames: bool = False,
frame_interval: int = 5,
max_frames: int | None = None,
capture_metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Ingest a video or image set into an experiment."""
return init_experiment(
Expand All @@ -40,6 +41,7 @@ def add_capture(
experiment_dir=Path(experiment_dir),
frame_interval=1 if all_frames else frame_interval,
max_frames=max_frames,
capture_metadata=capture_metadata,
)


Expand Down
79 changes: 79 additions & 0 deletions laserlab/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@

from .artifacts import ensure_dir, sha256_file, write_json


REQUIRED_FIXTURE_FIELDS = {
"id",
"title",
"kind",
"label",
"license",
"attribution",
"source_page",
"phenomena",
"expected_behavior",
"limitations",
"redistributable",
}

FIXTURE_CATALOG: list[dict[str, Any]] = [
{
"id": "commons-double-slit-experiment",
Expand All @@ -22,6 +37,9 @@
"license": "CC BY-SA 4.0",
"attribution": "G. Mikaberidze",
"notes": "Small simulation useful for detector sanity checks, not physical laser footage.",
"phenomena": ["interference", "simulation"],
"expected_behavior": "Stable fringe-like spatial frequencies; no readable text is expected.",
"limitations": "Rendered simulation rather than camera footage; use only as a structured control.",
"redistributable": True,
},
{
Expand All @@ -36,6 +54,9 @@
"license": "CC BY-SA 3.0 or GFDL",
"attribution": "Cookatoo.ergo.ZooM",
"notes": "Educational double-slit video clip; useful as a small redistributable optical fixture.",
"phenomena": ["diffraction", "interference"],
"expected_behavior": "FFT and fringe metrics should respond more strongly than OCR metrics.",
"limitations": "Educational compressed video with unknown camera and processing history.",
"redistributable": True,
},
{
Expand All @@ -50,6 +71,9 @@
"license": "CC BY-SA 4.0",
"attribution": "BrendaEM",
"notes": "Tomographic visualization from monochromatic laser light through two pinholes.",
"phenomena": ["interference", "diffraction", "visualization"],
"expected_behavior": "Repeatable spectral structure is expected; text recovery is not.",
"limitations": "Tomographic visualization, not a raw sensor recording of an experiment.",
"redistributable": True,
},
{
Expand All @@ -64,6 +88,9 @@
"license": "CC BY-SA 4.0",
"attribution": "Jack Kingsley-Smith",
"notes": "Rendered optical beam fixture; useful for false-positive checks against structured non-text fields.",
"phenomena": ["structured_beam", "diffraction", "rendered_control"],
"expected_behavior": "Ring and texture metrics may respond while OCR should remain null.",
"limitations": "Rendered beam field; not physical footage and not a matched camera control.",
"redistributable": True,
},
{
Expand All @@ -77,6 +104,9 @@
"license": "Classroom-use page; redistribution not confirmed",
"attribution": "R. S. Aspden, M. J. Padgett, G. C. Spalding",
"notes": "Scientifically strongest candidate footage. Keep external/manual until redistribution terms are confirmed.",
"phenomena": ["single_photon", "interference", "diffraction"],
"expected_behavior": "Accumulating interference structure should emerge over time.",
"limitations": "External manual source; redistribution and automated download are not permitted by LaserLab.",
"redistributable": False,
},
]
Expand All @@ -88,11 +118,60 @@ def list_fixtures(include_restricted: bool = False) -> list[dict[str, Any]]:
return [item for item in FIXTURE_CATALOG if item["redistributable"]]


def get_fixture(fixture_id: str) -> dict[str, Any]:
for item in FIXTURE_CATALOG:
if item["id"] == fixture_id:
return item.copy()
raise KeyError(f"Unknown fixture: {fixture_id}")


def fixture_metadata(fixture_id: str) -> dict[str, Any]:
item = get_fixture(fixture_id)
return {
"fixture_id": item["id"],
"fixture_title": item["title"],
"source_page": item["source_page"],
"license": item["license"],
"attribution": item["attribution"],
"phenomena": item["phenomena"],
"expected_behavior": item["expected_behavior"],
"limitations": item["limitations"],
"redistributable": item["redistributable"],
}


def validate_fixture_catalog(media_dir: Path | None = None, require_bundled_files: bool = False) -> list[str]:
errors: list[str] = []
seen_ids: set[str] = set()
media_dir = Path(media_dir) if media_dir is not None else None
for item in FIXTURE_CATALOG:
fixture_id = str(item.get("id") or "<missing-id>")
missing = sorted(field for field in REQUIRED_FIXTURE_FIELDS if item.get(field) in (None, "", []))
if missing:
errors.append(f"{fixture_id}: missing {', '.join(missing)}")
if fixture_id in seen_ids:
errors.append(f"{fixture_id}: duplicate fixture id")
seen_ids.add(fixture_id)
if item.get("redistributable") and not item.get("filename"):
errors.append(f"{fixture_id}: redistributable fixture has no filename")
if require_bundled_files and item.get("redistributable"):
if media_dir is None or not (media_dir / str(item.get("filename"))).is_file():
errors.append(f"{fixture_id}: bundled media file is missing")
return errors


def require_valid_fixture_catalog(media_dir: Path | None = None, require_bundled_files: bool = False) -> None:
errors = validate_fixture_catalog(media_dir, require_bundled_files=require_bundled_files)
if errors:
raise ValueError("Invalid fixture catalog:\n- " + "\n- ".join(errors))


def fetch_fixtures(
output_dir: Path,
fixture_ids: list[str] | None = None,
include_restricted: bool = False,
) -> list[dict[str, Any]]:
require_valid_fixture_catalog()
ensure_dir(output_dir)
selected = list_fixtures(include_restricted=include_restricted)
if fixture_ids:
Expand Down
3 changes: 3 additions & 0 deletions laserlab/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def init_experiment(
experiment_dir: Path,
frame_interval: int = 5,
max_frames: int | None = None,
capture_metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
if kind not in {"video", "image-set"}:
raise ValueError("kind must be 'video' or 'image-set'")
Expand Down Expand Up @@ -72,6 +73,7 @@ def init_experiment(
"label": label,
"sha256": source_hash,
"created_at": utc_now_iso(),
"metadata": dict(capture_metadata or {}),
}
capture_record = {
"capture_id": capture_id,
Expand All @@ -80,6 +82,7 @@ def init_experiment(
"label": label,
"frames": frame_records,
"created_at": utc_now_iso(),
"metadata": dict(capture_metadata or {}),
}

manifest["sources"].append(source_record)
Expand Down
2 changes: 2 additions & 0 deletions laserlab/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def run_experiment(
"primary_metric_score": 0.0,
"q_value": None,
"persistence_score": 0.0,
"capture_metadata": sample.get("capture_metadata", {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve fixture metadata for generated controls

With the default control_generation setting, laser captures produce matched control samples via generate_matched_controls, but those records do not carry capture_metadata from the parent capture. As a result this newly added result field becomes {} for every generated control, so if a derived control is part of results.json or ranks as a top candidate, the fixture provenance is lost even though the parent laser capture had it.

Useful? React with 👍 / 👎.

}
result_record["primary_metric_score"] = round(float(metric_score(result_record, primary_metric)), 6)
results.append(result_record)
Expand Down Expand Up @@ -192,6 +193,7 @@ def _build_samples(
"timestamp_ms": frame.get("timestamp_ms"),
"unblinded_label": capture["label"],
"synthetic": False,
"capture_metadata": capture.get("metadata", {}),
}
)

Expand Down
Loading
Loading