diff --git a/benchmarks/README.md b/benchmarks/README.md
new file mode 100644
index 0000000..7a2e222
--- /dev/null
+++ b/benchmarks/README.md
@@ -0,0 +1,65 @@
+# LayerForge offline benchmarks
+
+These benchmarks are deliberately separate from the functional test suite. They
+measure the expensive data and pixel paths without starting ComfyUI or
+downloading a model.
+
+## Frontend JavaScript
+
+This measures the real LayerForge mask pixel helpers, layer snapshot cloning,
+and state signatures:
+
+```powershell
+npm run benchmark -- --sizes 512,1024,2048 --layer-counts 1,4,8,16 --iterations 5
+```
+
+Use `--format json` for machine-readable output. The Node benchmark does not
+pretend that canvas stubs represent real rasterization; use the browser harness
+for that part.
+
+## Backend Python
+
+Run it with the embedded Python used by the active Easy ComfyUI installation:
+
+```powershell
+rtk proxy E:\AI\AI\ComfyUI\ComfyUI_Easy\All_Nodes\python_embeded\python.exe benchmarks\backend.py --sizes 512,1024,2048 --iterations 5
+```
+
+The script measures tensor/data-URL conversion, per-sample and batch PNG
+serialization, plus preprocessing/postprocessing around a fake RMBG model. It
+never loads a checkpoint. Set `LAYERFORGE_COMFY_ROOT` if the active ComfyUI
+root differs from the project default.
+
+## Real browser Canvas 2D and PNG export
+
+The browser harness measures representative compositing for 1, 4, 8, and 16
+layers, crop/blend/mask variants, PNG export, and the actual LayerForge distance
+transform implementation. It is not a ComfyUI workflow test; it uses only a
+local browser Canvas implementation.
+
+Start a static server with the repository explicitly selected as its document
+root. This works even when the terminal is currently in another directory:
+
+```powershell
+rtk proxy E:\AI\AI\ComfyUI\ComfyUI_Easy\All_Nodes\python_embeded\python.exe -m http.server 8765 --directory E:\AI\AI\ComfyUI\ComfyUI_Windows_portable\ComfyUI\custom_nodes\Comfyui-LayerForge
+```
+
+Open:
+
+```text
+http://127.0.0.1:8765/benchmarks/browser.html
+```
+
+For the larger matrix, set the size field to `512,1024,2048`. Keep the same
+browser, zoom, hardware acceleration setting, and iteration count when comparing
+two revisions. Benchmark output is printed in the page and is not written to the
+repository.
+
+## Interpreting results
+
+Compare median and p95 on the same machine. Warm-up samples are discarded. A
+browser render median above roughly 16 ms is likely to affect 60 FPS interaction;
+PNG export is a separate operation and should not be confused with the per-frame
+render budget. Model inference is intentionally excluded from the fake matting
+measurement and must be benchmarked separately with a specific checkpoint and
+device.
diff --git a/benchmarks/backend.py b/benchmarks/backend.py
new file mode 100644
index 0000000..ca3139a
--- /dev/null
+++ b/benchmarks/backend.py
@@ -0,0 +1,212 @@
+"""Offline LayerForge backend benchmark.
+
+This script measures tensor/image conversion and adapter overhead only. It does
+not start ComfyUI, download models, or run a real matting checkpoint.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import sys
+import time
+from pathlib import Path
+
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_COMFY_ROOT = Path(r"E:\AI\AI\ComfyUI\ComfyUI_Easy\All_Nodes\ComfyUI")
+
+
+def _install_import_paths() -> Path:
+ comfy_root = Path(os.environ.get("LAYERFORGE_COMFY_ROOT", DEFAULT_COMFY_ROOT))
+ sys.path.insert(0, str(REPOSITORY_ROOT))
+ if comfy_root.exists():
+ sys.path.insert(0, str(comfy_root))
+ return comfy_root
+
+
+def _parse_positive_list(value: str) -> list[int]:
+ result = [int(item.strip()) for item in value.split(",") if item.strip()]
+ if not result or any(item <= 0 for item in result):
+ raise argparse.ArgumentTypeError(f"Expected positive integers, got: {value}")
+ return result
+
+
+def _percentile(sorted_values: list[float], fraction: float) -> float:
+ index = min(len(sorted_values) - 1, max(0, math.ceil(len(sorted_values) * fraction) - 1))
+ return sorted_values[index]
+
+
+def _measure(operation, iterations: int) -> dict[str, float]:
+ for _ in range(min(2, iterations)):
+ operation()
+
+ samples = []
+ for _ in range(iterations):
+ started_at = time.perf_counter()
+ operation()
+ samples.append((time.perf_counter() - started_at) * 1000)
+
+ samples.sort()
+ return {
+ "median_ms": _percentile(samples, 0.5),
+ "p95_ms": _percentile(samples, 0.95),
+ "min_ms": samples[0],
+ "max_ms": samples[-1],
+ }
+
+
+def _rounded(value):
+ if isinstance(value, dict):
+ return {key: _rounded(item) for key, item in value.items()}
+ if isinstance(value, float):
+ return round(value, 3)
+ return value
+
+
+def _run_conversion_benchmarks(torch, image_utils, node_module, serialization, sizes, iterations):
+ from PIL import Image
+
+ output = {}
+ for height, width in sizes:
+ tensor = torch.rand((1, 3, height, width), dtype=torch.float32)
+ encoded = image_utils.convert_tensor_to_base64(tensor)
+ sample = tensor[0].permute(1, 2, 0)
+ mask_tensor = torch.rand((height, width), dtype=torch.float32)
+
+ def serialize_mask():
+ mask_array = (mask_tensor.cpu().numpy() * 255).astype("uint8")
+ return serialization.pil_to_data_url(Image.fromarray(mask_array, "L"))
+
+ output[f"{height}x{width}"] = {
+ "tensor_to_base64": _measure(
+ lambda: image_utils.convert_tensor_to_base64(tensor), iterations
+ ),
+ "base64_to_tensor": _measure(
+ lambda: image_utils.convert_base64_to_tensor(encoded), iterations
+ ),
+ "node_serialize_rgb_sample": _measure(
+ lambda: node_module.LayerForgeNode._serialize_rgb_tensor_sample(sample), iterations
+ ),
+ "mask_tensor_to_base64": _measure(serialize_mask, iterations),
+ "encoded_bytes": len(encoded.encode("ascii")),
+ }
+
+ return output
+
+
+def _run_batch_benchmarks(torch, node_module, batch_size: int, batch_counts, iterations):
+ output = {}
+ for batch_count in batch_counts:
+ batch = torch.rand((batch_count, batch_size, batch_size, 3), dtype=torch.float32)
+
+ def serialize_batch():
+ return [
+ node_module.LayerForgeNode._serialize_rgb_tensor_sample(batch[index])
+ for index in range(batch_count)
+ ]
+
+ stats = _measure(serialize_batch, iterations)
+ output[str(batch_count)] = {
+ **stats,
+ "batch_size": batch_size,
+ "per_sample_median_ms": stats["median_ms"] / batch_count,
+ }
+
+ return output
+
+
+def _run_fake_matting_benchmarks(torch, rmbg_module, sizes, iterations):
+ class FakeModel:
+ def __call__(self, image):
+ return torch.zeros((image.shape[0], 1, 256, 256), device=image.device)
+
+ adapter = rmbg_module.RMBG2Model(FakeModel(), torch.device("cpu"))
+ output = {}
+ for height, width in sizes:
+ image = torch.rand((1, height, width, 3), dtype=torch.float32)
+ output[f"{height}x{width}"] = _measure(
+ lambda: adapter.encode_image(image), iterations
+ )
+ return output
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--sizes", type=_parse_positive_list, default=[512, 1024, 2048])
+ parser.add_argument("--iterations", type=int, default=5)
+ parser.add_argument("--batch-size", type=int, default=512)
+ parser.add_argument("--batch-counts", type=_parse_positive_list, default=[1, 4, 8, 16])
+ parser.add_argument("--skip-matting", action="store_true")
+ args = parser.parse_args()
+
+ if args.iterations < 1 or args.batch_size < 1:
+ parser.error("--iterations and --batch-size must be positive")
+
+ comfy_root = _install_import_paths()
+
+ try:
+ import torch
+
+ from python import image_utils
+ from python import image_serialization as serialization
+ from python import node as node_module
+ except Exception as error:
+ print(
+ "Unable to import the LayerForge runtime. Set LAYERFORGE_COMFY_ROOT "
+ f"to the active ComfyUI root. Current root: {comfy_root}\n{error}",
+ file=sys.stderr,
+ )
+ return 2
+
+ sizes = [(size, size) for size in args.sizes]
+ report = {
+ "benchmark": "layerforge-backend-offline",
+ "runtime": {
+ "python": sys.version.split()[0],
+ "torch": torch.__version__,
+ "torch_threads": torch.get_num_threads(),
+ "device": "cpu",
+ "comfy_root": str(comfy_root),
+ },
+ "config": {
+ "sizes": args.sizes,
+ "iterations": args.iterations,
+ "batch_size": args.batch_size,
+ "batch_counts": args.batch_counts,
+ },
+ "image_conversion": _run_conversion_benchmarks(
+ torch, image_utils, node_module, serialization, sizes, args.iterations
+ ),
+ "batch_serialization": _run_batch_benchmarks(
+ torch, node_module, args.batch_size, args.batch_counts, args.iterations
+ ),
+ "skipped": [],
+ }
+
+ if args.skip_matting:
+ report["skipped"].append({
+ "benchmark": "fake_matting_adapter",
+ "reason": "Disabled with --skip-matting",
+ })
+ else:
+ try:
+ from python.matting.backends import rmbg as rmbg_module
+
+ report["fake_matting_adapter"] = _run_fake_matting_benchmarks(
+ torch, rmbg_module, sizes, args.iterations
+ )
+ except Exception as error:
+ report["skipped"].append({
+ "benchmark": "fake_matting_adapter",
+ "reason": f"Adapter import failed: {error}",
+ })
+
+ print(json.dumps(_rounded(report), indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/browser.html b/benchmarks/browser.html
new file mode 100644
index 0000000..1992988
--- /dev/null
+++ b/benchmarks/browser.html
@@ -0,0 +1,394 @@
+
+
+
+
+
+ LayerForge Canvas Benchmark
+
+
+
+
LayerForge Canvas benchmark
+
+ This is a ComfyUI-free browser benchmark. It measures representative Canvas 2D
+ compositing and PNG export, plus the real LayerForge distance-transform helpers.
+ It is intentionally not a replacement for a ComfyUI workflow smoke test.
+