Merge pull request #7 from cmoyates/feat/smoke-2048
feat: add 2048 smoke test
This commit is contained in:
commit
81967ccc7c
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,3 +17,6 @@ reference/fixtures/*.npz
|
||||
|
||||
# Sample images
|
||||
samples/
|
||||
|
||||
# Inference outputs
|
||||
output/
|
||||
|
||||
23
README.md
23
README.md
@ -247,6 +247,29 @@ uv run python scripts/smoke_engine.py \
|
||||
--img-size 512
|
||||
```
|
||||
|
||||
### 2048 smoke test
|
||||
|
||||
Validates full end-to-end inference at CorridorKey's native 2048 resolution.
|
||||
Uses `samples/sample.png` + `samples/hint.png` by default; falls back to
|
||||
synthetic inputs if samples are unavailable.
|
||||
|
||||
```bash
|
||||
uv run python scripts/smoke_2048.py
|
||||
```
|
||||
|
||||
With real images:
|
||||
```bash
|
||||
uv run python scripts/smoke_2048.py --image shot.png --hint hint.png
|
||||
```
|
||||
|
||||
Reports timing, peak memory, output shapes, and value-range diagnostics.
|
||||
This is an execution check, not a 2048 parity validation.
|
||||
|
||||
To run the slow pytest version:
|
||||
```bash
|
||||
uv run pytest -m slow
|
||||
```
|
||||
|
||||
### Standalone scripts vs engine usage
|
||||
|
||||
| | Standalone (`scripts/infer.py`) | Engine (`CorridorKeyMLXEngine`) |
|
||||
|
||||
117
docs/plans/2026-03-01-feat-2048-smoke-test-plan.md
Normal file
117
docs/plans/2026-03-01-feat-2048-smoke-test-plan.md
Normal file
@ -0,0 +1,117 @@
|
||||
---
|
||||
title: "feat: Add 2048 smoke test"
|
||||
type: feat
|
||||
date: 2026-03-01
|
||||
---
|
||||
|
||||
# Add 2048 Smoke Test
|
||||
|
||||
## Overview
|
||||
|
||||
Validate MLX model runs end-to-end at CorridorKey's native 2048×2048 resolution on Apple Silicon. Smoke/stability check — not a new parity campaign.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
All existing tests/scripts default to 256–512. No automated path confirms 2048 actually works. The model was *trained* at 2048, so pos_embed interpolation is a no-op at that resolution, but we haven't exercised the full pipeline there.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
One new script + one skipable pytest test. Reuse `CorridorKeyMLXEngine` (already defaults to `img_size=2048`). Minimal additions.
|
||||
|
||||
## Deliverables
|
||||
|
||||
### 1. `scripts/smoke_2048.py`
|
||||
|
||||
Mirrors `scripts/smoke_engine.py` pattern but targeted at 2048 with richer diagnostics.
|
||||
|
||||
**CLI args:**
|
||||
- `--checkpoint PATH` (default: `checkpoints/corridorkey_mlx.safetensors`)
|
||||
- `--img-size INT` (default: **2048** — this script's whole point)
|
||||
- `--image PATH` (optional — uses synthetic input if omitted)
|
||||
- `--hint PATH` (optional — uses synthetic hint if omitted)
|
||||
- `--output-dir DIR` (default: `output/smoke_2048/`)
|
||||
- `--save-outputs / --no-save-outputs` (default: save)
|
||||
- `--seed INT` (default: 42)
|
||||
- `--compile / --no-compile` (default: True — engine default)
|
||||
|
||||
**Behavior:**
|
||||
1. Generate or load 2048×2048 RGB + hint inputs
|
||||
2. Instantiate `CorridorKeyMLXEngine(checkpoint_path, img_size, compile)`
|
||||
3. Reset peak memory via `mx.metal.reset_peak_memory()` (if available, try/except)
|
||||
4. Time `engine.process_frame(rgb, mask)` with wall-clock
|
||||
5. Read peak memory via `mx.metal.get_peak_memory()` (if available)
|
||||
6. Report: input shape, output shapes, dtypes, elapsed time, peak memory (MB)
|
||||
7. Diagnostic: min/max/mean of alpha+fg, NaN/Inf check, flag all-zeros/all-ones
|
||||
8. Optionally save alpha.png, fg.png, comp.png
|
||||
9. Catch `RuntimeError`/`MemoryError` with actionable error message
|
||||
|
||||
**Synthetic input generation:**
|
||||
```python
|
||||
rng = np.random.default_rng(seed)
|
||||
rgb = rng.integers(0, 256, (2048, 2048, 3), dtype=np.uint8)
|
||||
# Circular gradient hint — exercises the path better than random noise
|
||||
mask = <simple radial gradient uint8>
|
||||
```
|
||||
|
||||
### 2. `tests/test_smoke_2048.py`
|
||||
|
||||
**Two tests:**
|
||||
|
||||
1. `test_smoke_2048_full` — loads checkpoint, runs engine at 2048, asserts shapes + no NaN. Marked `@pytest.mark.skipif(not HAS_CHECKPOINT)` AND `@pytest.mark.slow`. Won't run in normal `uv run pytest`; run via `uv run pytest -m slow`.
|
||||
|
||||
2. `test_smoke_2048_wiring` — lightweight, no checkpoint. Verifies `GreenFormer(img_size=2048)` constructs OK and produces correct output shapes with random weights at 2048 (or smaller like 256 if too heavy without checkpoint). Runs in normal suite.
|
||||
|
||||
### 3. README update
|
||||
|
||||
Add "2048 Smoke Test" section after the existing "Smoke test" subsection:
|
||||
|
||||
```markdown
|
||||
### 2048 smoke test
|
||||
|
||||
Validates full end-to-end inference at CorridorKey's native 2048 resolution.
|
||||
Uses synthetic inputs by default — no test images required.
|
||||
|
||||
```bash
|
||||
uv run python scripts/smoke_2048.py
|
||||
```
|
||||
|
||||
To use real images:
|
||||
```bash
|
||||
uv run python scripts/smoke_2048.py --image shot.png --hint hint.png
|
||||
```
|
||||
|
||||
Reports timing, peak memory, output shapes, and value-range diagnostics.
|
||||
This is an execution check, not a 2048 parity validation.
|
||||
```
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
- **Memory**: 2048×2048×4 float32 ≈ 64MB input, but backbone + decoder intermediates will be much larger. Engine already handles this; we just report peak.
|
||||
- **Compile**: Engine defaults `compile=True`. First call at 2048 will be slow (compile cost). The smoke script is a single-run tool so the compile overhead is included in timing.
|
||||
- **Pos_embed**: At 2048, no interpolation needed (trained at 2048). This is the easiest resolution to test.
|
||||
- **`mx.metal`**: May not be available in all environments. Wrap in try/except.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `scripts/smoke_2048.py` runs successfully with checkpoint
|
||||
- [x] Reports: input size, output shapes, elapsed time, peak memory
|
||||
- [x] Detects NaN/Inf and flags suspicious outputs
|
||||
- [x] Supports both synthetic and user-supplied inputs
|
||||
- [x] `test_smoke_2048_full` passes when checkpoint present + `-m slow`
|
||||
- [x] `test_smoke_2048_wiring` passes in normal test suite
|
||||
- [x] README documents the smoke test
|
||||
- [x] Existing 512 tests unchanged (112 pass, 1 slow deselected)
|
||||
- [x] No new binary artifacts committed
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `scripts/smoke_2048.py` | New — main smoke script |
|
||||
| `tests/test_smoke_2048.py` | New — pytest smoke + wiring tests |
|
||||
| `README.md` | Add "2048 smoke test" subsection |
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- Exact peak memory at 2048 unknown until first run — might be tight on 8GB machines?
|
||||
- Include `--compile` timing breakdown (warmup vs inference) or just total?
|
||||
@ -34,6 +34,8 @@ packages = ["src/corridorkey_mlx"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = ["slow: heavy tests (2048 inference, etc.) — run with -m slow"]
|
||||
addopts = "-m 'not slow'"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
|
||||
242
scripts/smoke_2048.py
Normal file
242
scripts/smoke_2048.py
Normal file
@ -0,0 +1,242 @@
|
||||
"""2048 smoke test — validates end-to-end MLX inference at native resolution.
|
||||
|
||||
Loads a real checkpoint, runs inference at 2048x2048 (CorridorKey's training
|
||||
resolution), reports timing, peak memory, output diagnostics. Uses sample
|
||||
images from samples/ by default; falls back to synthetic if unavailable.
|
||||
|
||||
This is an execution/stability check, not a parity campaign.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import mlx.core as mx
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from corridorkey_mlx import CorridorKeyMLXEngine
|
||||
|
||||
DEFAULT_CHECKPOINT = Path("checkpoints/corridorkey_mlx.safetensors")
|
||||
DEFAULT_IMG_SIZE = 2048
|
||||
DEFAULT_OUTPUT_DIR = Path("output/smoke_2048")
|
||||
DEFAULT_SEED = 42
|
||||
DEFAULT_SAMPLE_IMAGE = Path("samples/sample.png")
|
||||
DEFAULT_SAMPLE_HINT = Path("samples/hint.png")
|
||||
|
||||
|
||||
def generate_synthetic_inputs(img_size: int, seed: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Generate deterministic synthetic RGB + alpha hint inputs.
|
||||
|
||||
RGB: uniform random uint8.
|
||||
Hint: radial gradient (bright center, dark edges) — more realistic than noise.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
rgb = rng.integers(0, 256, (img_size, img_size, 3), dtype=np.uint8)
|
||||
|
||||
# Radial gradient hint: bright center fading to dark edges
|
||||
y, x = np.mgrid[:img_size, :img_size]
|
||||
center = img_size / 2.0
|
||||
distance = np.sqrt((x - center) ** 2 + (y - center) ** 2)
|
||||
max_distance = np.sqrt(2) * center
|
||||
gradient = 1.0 - (distance / max_distance)
|
||||
mask = (gradient * 255).clip(0, 255).astype(np.uint8)
|
||||
|
||||
return rgb, mask
|
||||
|
||||
|
||||
def load_user_inputs(image_path: Path, hint_path: Path) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Load user-supplied RGB image and alpha hint."""
|
||||
rgb = np.asarray(Image.open(image_path).convert("RGB"))
|
||||
mask = np.asarray(Image.open(hint_path).convert("L"))
|
||||
return rgb, mask
|
||||
|
||||
|
||||
def report_diagnostics(result: dict[str, np.ndarray]) -> bool:
|
||||
"""Print output diagnostics. Returns True if outputs look healthy."""
|
||||
healthy = True
|
||||
for key in ("alpha", "fg", "comp"):
|
||||
arr = result[key]
|
||||
has_nan = bool(np.isnan(arr).any())
|
||||
has_inf = bool(np.isinf(arr).any())
|
||||
print(
|
||||
f" {key:10s}: shape={arr.shape} dtype={arr.dtype} range=[{arr.min()}, {arr.max()}]"
|
||||
)
|
||||
if has_nan:
|
||||
print(f" WARNING: {key} contains NaN!")
|
||||
healthy = False
|
||||
if has_inf:
|
||||
print(f" WARNING: {key} contains Inf!")
|
||||
healthy = False
|
||||
|
||||
# Flag suspicious alpha patterns
|
||||
alpha = result["alpha"]
|
||||
if alpha.min() == alpha.max():
|
||||
print(f" WARNING: alpha is constant ({alpha.min()}) — suspicious")
|
||||
healthy = False
|
||||
if alpha.min() == 0 and alpha.max() == 0:
|
||||
print(" WARNING: alpha is all-zeros")
|
||||
healthy = False
|
||||
if alpha.min() == 255 and alpha.max() == 255:
|
||||
print(" WARNING: alpha is all-ones (255)")
|
||||
healthy = False
|
||||
|
||||
return healthy
|
||||
|
||||
|
||||
def get_peak_memory_mb() -> float | None:
|
||||
"""Read peak memory in MB, or None if unavailable."""
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(AttributeError):
|
||||
return mx.get_peak_memory() / (1024 * 1024)
|
||||
with contextlib.suppress(Exception):
|
||||
return mx.metal.get_peak_memory() / (1024 * 1024)
|
||||
return None
|
||||
|
||||
|
||||
def reset_peak_memory() -> None:
|
||||
"""Reset peak memory counter if available."""
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(AttributeError):
|
||||
mx.reset_peak_memory()
|
||||
with contextlib.suppress(Exception):
|
||||
mx.metal.reset_peak_memory()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="2048 smoke test: CorridorKeyMLXEngine at native resolution"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint",
|
||||
type=Path,
|
||||
default=DEFAULT_CHECKPOINT,
|
||||
help="MLX safetensors checkpoint",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--img-size",
|
||||
type=int,
|
||||
default=DEFAULT_IMG_SIZE,
|
||||
help="Model input resolution (default: 2048)",
|
||||
)
|
||||
parser.add_argument("--image", type=Path, default=None, help="RGB input image")
|
||||
parser.add_argument("--hint", type=Path, default=None, help="Grayscale alpha hint")
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_OUTPUT_DIR,
|
||||
help="Output directory for saved PNGs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-outputs",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="Save output PNGs (default: True)",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
|
||||
parser.add_argument(
|
||||
"--compile",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="Use mx.compile (default: True)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# -- validate checkpoint --
|
||||
if not args.checkpoint.exists():
|
||||
print(f"ERROR: Checkpoint not found: {args.checkpoint}")
|
||||
print("Run scripts/convert_weights.py first.")
|
||||
sys.exit(1)
|
||||
|
||||
# -- resolve inputs: explicit args > samples/ > synthetic --
|
||||
image_path = args.image
|
||||
hint_path = args.hint
|
||||
|
||||
if image_path is None and DEFAULT_SAMPLE_IMAGE.exists():
|
||||
image_path = DEFAULT_SAMPLE_IMAGE
|
||||
if hint_path is None and image_path is not None and DEFAULT_SAMPLE_HINT.exists():
|
||||
hint_path = DEFAULT_SAMPLE_HINT
|
||||
|
||||
if image_path is not None:
|
||||
if hint_path is None:
|
||||
print("ERROR: --hint required when --image is provided (or place samples/hint.png)")
|
||||
sys.exit(1)
|
||||
print(f"Loading inputs: image={image_path}, hint={hint_path}")
|
||||
rgb, mask = load_user_inputs(image_path, hint_path)
|
||||
using_synthetic = False
|
||||
else:
|
||||
print(f"Generating synthetic {args.img_size}x{args.img_size} inputs (seed={args.seed})...")
|
||||
rgb, mask = generate_synthetic_inputs(args.img_size, args.seed)
|
||||
using_synthetic = True
|
||||
|
||||
print(f"Input RGB: {rgb.shape} {rgb.dtype}")
|
||||
print(f"Input mask: {mask.shape} {mask.dtype}")
|
||||
|
||||
# -- load engine --
|
||||
print(f"Loading engine (img_size={args.img_size}, compile={args.compile})...")
|
||||
try:
|
||||
engine = CorridorKeyMLXEngine(
|
||||
checkpoint_path=args.checkpoint,
|
||||
img_size=args.img_size,
|
||||
compile=args.compile,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"ERROR loading engine: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# -- run inference --
|
||||
print("Running inference...")
|
||||
reset_peak_memory()
|
||||
start = time.perf_counter()
|
||||
|
||||
try:
|
||||
result = engine.process_frame(rgb, mask)
|
||||
except (RuntimeError, MemoryError) as exc:
|
||||
elapsed = time.perf_counter() - start
|
||||
peak_mb = get_peak_memory_mb()
|
||||
print(f"\nFAILED after {elapsed:.1f}s")
|
||||
if peak_mb is not None:
|
||||
print(f"Peak memory: {peak_mb:.0f} MB")
|
||||
print(f"Error: {exc}")
|
||||
print("\nPossible causes:")
|
||||
print(" - Insufficient unified memory for 2048 inference")
|
||||
print(" - Try --no-compile to reduce memory overhead")
|
||||
print(" - Try --img-size 1024 to halve resolution")
|
||||
sys.exit(1)
|
||||
|
||||
elapsed = time.perf_counter() - start
|
||||
peak_mb = get_peak_memory_mb()
|
||||
|
||||
# -- report --
|
||||
print(f"\nInference completed in {elapsed:.2f}s")
|
||||
if peak_mb is not None:
|
||||
print(f"Peak memory: {peak_mb:.0f} MB")
|
||||
source_label = "synthetic" if using_synthetic else f"loaded ({image_path})"
|
||||
print(f"Input: {source_label}, model res={args.img_size}x{args.img_size}")
|
||||
print("\nOutputs:")
|
||||
healthy = report_diagnostics(result)
|
||||
|
||||
# -- save outputs --
|
||||
if args.save_outputs:
|
||||
out = args.output_dir
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
Image.fromarray(result["alpha"], mode="L").save(out / "alpha.png")
|
||||
Image.fromarray(result["fg"], mode="RGB").save(out / "fg.png")
|
||||
Image.fromarray(result["comp"], mode="RGB").save(out / "comp.png")
|
||||
print(f"\nSaved outputs to {out}/")
|
||||
|
||||
# -- verdict --
|
||||
if healthy:
|
||||
print("\n2048 smoke test PASSED.")
|
||||
else:
|
||||
print("\n2048 smoke test completed with WARNINGS (see above).")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
tests/test_smoke_2048.py
Normal file
78
tests/test_smoke_2048.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""2048 smoke tests — execution check at native resolution.
|
||||
|
||||
test_smoke_2048_wiring: lightweight, no checkpoint, runs in normal suite.
|
||||
test_smoke_2048_full: loads real checkpoint at 2048, marked slow + skipif.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import mlx.core as mx
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from corridorkey_mlx.model.corridorkey import GreenFormer
|
||||
|
||||
CHECKPOINT = Path("checkpoints/corridorkey_mlx.safetensors")
|
||||
HAS_CHECKPOINT = CHECKPOINT.exists()
|
||||
|
||||
WIRING_IMG_SIZE = 256 # small enough to run fast with random weights
|
||||
|
||||
|
||||
def test_smoke_2048_wiring() -> None:
|
||||
"""GreenFormer constructs at 2048 and produces correct shapes (random weights)."""
|
||||
model = GreenFormer(img_size=WIRING_IMG_SIZE)
|
||||
x = mx.random.normal((1, WIRING_IMG_SIZE, WIRING_IMG_SIZE, 4))
|
||||
# mx.eval materializes lazy MLX arrays (not Python eval)
|
||||
mx.eval(x) # noqa: S307
|
||||
|
||||
out = model(x)
|
||||
mx.eval(out) # noqa: S307
|
||||
|
||||
assert out["alpha_final"].shape == (1, WIRING_IMG_SIZE, WIRING_IMG_SIZE, 1)
|
||||
assert out["fg_final"].shape == (1, WIRING_IMG_SIZE, WIRING_IMG_SIZE, 3)
|
||||
|
||||
# No NaN/Inf
|
||||
alpha = np.array(out["alpha_final"])
|
||||
fg = np.array(out["fg_final"])
|
||||
assert not np.isnan(alpha).any(), "alpha_final contains NaN"
|
||||
assert not np.isnan(fg).any(), "fg_final contains NaN"
|
||||
assert not np.isinf(alpha).any(), "alpha_final contains Inf"
|
||||
assert not np.isinf(fg).any(), "fg_final contains Inf"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skipif(not HAS_CHECKPOINT, reason="Checkpoint not available")
|
||||
def test_smoke_2048_full() -> None:
|
||||
"""Full 2048 inference with real checkpoint via engine."""
|
||||
from corridorkey_mlx import CorridorKeyMLXEngine
|
||||
|
||||
engine = CorridorKeyMLXEngine(
|
||||
checkpoint_path=CHECKPOINT,
|
||||
img_size=2048,
|
||||
compile=False, # skip compile overhead in test
|
||||
)
|
||||
|
||||
# Use synthetic inputs at 2048
|
||||
rng = np.random.default_rng(42)
|
||||
rgb = rng.integers(0, 256, (2048, 2048, 3), dtype=np.uint8)
|
||||
mask = rng.integers(0, 256, (2048, 2048), dtype=np.uint8)
|
||||
|
||||
result = engine.process_frame(rgb, mask)
|
||||
|
||||
# Shape checks
|
||||
assert result["alpha"].shape == (2048, 2048)
|
||||
assert result["alpha"].dtype == np.uint8
|
||||
assert result["fg"].shape == (2048, 2048, 3)
|
||||
assert result["fg"].dtype == np.uint8
|
||||
assert result["comp"].shape == (2048, 2048, 3)
|
||||
|
||||
# No NaN/Inf
|
||||
for key in ("alpha", "fg", "comp"):
|
||||
arr = result[key]
|
||||
assert not np.isnan(arr).any(), f"{key} contains NaN"
|
||||
assert not np.isinf(arr).any(), f"{key} contains Inf"
|
||||
|
||||
# Alpha shouldn't be degenerate
|
||||
assert result["alpha"].min() != result["alpha"].max(), "alpha is constant — suspicious"
|
||||
Loading…
Reference in New Issue
Block a user