acpr123 commited on
Commit
c68a03b
·
verified ·
1 Parent(s): 32b26e5

Upload deployment-ready Space app

Browse files
Files changed (40) hide show
  1. .gitignore +6 -0
  2. Dockerfile +25 -0
  3. README.md +59 -5
  4. app.py +18 -0
  5. assets/examples/examples_manifest.json +42 -0
  6. assets/examples/query_Ariel_Sharon_0002.jpg +0 -0
  7. assets/examples/query_Colin_Powell_0002.jpg +0 -0
  8. assets/examples/query_Donald_Rumsfeld_0002.jpg +0 -0
  9. assets/examples/query_George_W_Bush_0002.jpg +0 -0
  10. assets/examples/query_Gerhard_Schroeder_0002.jpg +0 -0
  11. assets/examples/query_Hugo_Chavez_0002.jpg +0 -0
  12. assets/examples/query_Junichiro_Koizumi_0002.jpg +0 -0
  13. assets/examples/query_Tony_Blair_0002.jpg +0 -0
  14. assets/gallery/Ariel_Sharon_0001.jpg +0 -0
  15. assets/gallery/Colin_Powell_0001.jpg +0 -0
  16. assets/gallery/Donald_Rumsfeld_0001.jpg +0 -0
  17. assets/gallery/George_W_Bush_0001.jpg +0 -0
  18. assets/gallery/Gerhard_Schroeder_0001.jpg +0 -0
  19. assets/gallery/Hugo_Chavez_0001.jpg +0 -0
  20. assets/gallery/Junichiro_Koizumi_0001.jpg +0 -0
  21. assets/gallery/README.txt +1 -0
  22. assets/gallery/Tony_Blair_0001.jpg +0 -0
  23. assets/gallery/gallery_manifest.json +58 -0
  24. assets/gallery/lfw_demo_calibration.npz +3 -0
  25. assets/gallery/lfw_demo_gallery.npz +3 -0
  26. assets/videos/README.txt +1 -0
  27. requirements.txt +10 -0
  28. src/__init__.py +1 -0
  29. src/config.py +36 -0
  30. src/data/__init__.py +1 -0
  31. src/data/gallery_store.py +59 -0
  32. src/pipeline/__init__.py +1 -0
  33. src/pipeline/image_pipeline.py +68 -0
  34. src/pipeline/video_pipeline.py +42 -0
  35. src/protocol/__init__.py +1 -0
  36. src/protocol/elsh_params.py +19 -0
  37. src/protocol/fpsi_adapter.py +286 -0
  38. src/protocol/runtime_stub.txt +1 -0
  39. src/ui/__init__.py +1 -0
  40. src/ui/components.py +208 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ out/
4
+ .venv/
5
+ .env
6
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ ffmpeg \
7
+ git \
8
+ libgl1 \
9
+ libglib2.0-0 \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ WORKDIR /home/user/app
13
+
14
+ COPY --chown=user requirements.txt ./
15
+ RUN pip install --no-cache-dir --upgrade pip && \
16
+ pip install --no-cache-dir -r requirements.txt
17
+
18
+ COPY --chown=user . .
19
+
20
+ USER user
21
+ ENV HOME=/home/user \
22
+ PATH=/home/user/.local/bin:$PATH \
23
+ PYTHONUNBUFFERED=1
24
+
25
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,64 @@
1
  ---
2
- title: FuzzyPSI Hamming
3
- emoji: 💻
4
- colorFrom: purple
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FuzzyPSI-hamming
3
+ emoji: 🔐
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
  ---
11
 
12
+ # FuzzyPSI-hamming: Deployment-Oriented FPSI Demo
13
+
14
+ This Hugging Face Space presents **FuzzyPSI-hamming** as a reviewer-facing, deployment-oriented demo for privacy-preserving fuzzy private set intersection over Hamming distance.
15
+
16
+ ## What this Space demonstrates
17
+
18
+ - **Image-driven query workflow** for a public demo frontend
19
+ - **Binary-code matching** derived from the Hamming-FPSI pipeline
20
+ - **E-LSH candidate generation + exact verification story** exposed in a product-style interface
21
+ - **Protocol metrics panels** showing threshold, communication, latency, and Gao-feasibility information
22
+ - **Dual backend modes**:
23
+ - **Simulation mode** for robust public interaction
24
+ - **Optional full protocol mode** when native FPSI binaries are configured
25
+
26
+ ## Reviewer-facing positioning
27
+
28
+ This Space is designed to show the engineering effort beyond the paper artifact:
29
+
30
+ 1. turning the protocol into an interactive web application;
31
+ 2. bridging uploaded images to binary-code matching;
32
+ 3. exposing system metrics and deployment status in a usable UI;
33
+ 4. organizing the project as an isolated Hugging Face deployment target.
34
+
35
+ The current public release is **image-first**. The code layout also includes a path for short-video analysis, but the first deployment focuses on making the image path reliable.
36
+
37
+ ## Demo data policy
38
+
39
+ The public demo uses a **fixed LFW-derived gallery** so reviewers can test the system in a stable and reproducible way. The deployment project is intentionally isolated from the main research artifact repository.
40
+
41
+ ## Repository structure
42
+
43
+ ```text
44
+ .
45
+ ├── app.py
46
+ ├── Dockerfile
47
+ ├── requirements.txt
48
+ ├── src/
49
+ │ ├── config.py
50
+ │ ├── protocol/
51
+ │ ├── pipeline/
52
+ │ ├── data/
53
+ │ └── ui/
54
+ └── assets/
55
+ ├── gallery/
56
+ ├── examples/
57
+ └── videos/
58
+ ```
59
+
60
+ ## Notes
61
+
62
+ - The protocol core is adapted into this Space as a separate deployment project.
63
+ - Public demo mode prioritizes reliability and reviewer usability.
64
+ - Full native protocol execution is exposed only when the deployment environment is provisioned for it.
app.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
5
+
6
+ from src.ui.components import build_app
7
+
8
+ app = build_app()
9
+
10
+ if __name__ == "__main__":
11
+ port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
12
+ project_root = Path(__file__).resolve().parent
13
+ app.launch(
14
+ server_name="0.0.0.0",
15
+ server_port=port,
16
+ allowed_paths=[str(project_root / "assets")],
17
+ show_api=True,
18
+ )
assets/examples/examples_manifest.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "person": "George_W_Bush",
4
+ "filename": "query_George_W_Bush_0002.jpg",
5
+ "path": "query_George_W_Bush_0002.jpg"
6
+ },
7
+ {
8
+ "person": "Colin_Powell",
9
+ "filename": "query_Colin_Powell_0002.jpg",
10
+ "path": "query_Colin_Powell_0002.jpg"
11
+ },
12
+ {
13
+ "person": "Tony_Blair",
14
+ "filename": "query_Tony_Blair_0002.jpg",
15
+ "path": "query_Tony_Blair_0002.jpg"
16
+ },
17
+ {
18
+ "person": "Donald_Rumsfeld",
19
+ "filename": "query_Donald_Rumsfeld_0002.jpg",
20
+ "path": "query_Donald_Rumsfeld_0002.jpg"
21
+ },
22
+ {
23
+ "person": "Gerhard_Schroeder",
24
+ "filename": "query_Gerhard_Schroeder_0002.jpg",
25
+ "path": "query_Gerhard_Schroeder_0002.jpg"
26
+ },
27
+ {
28
+ "person": "Ariel_Sharon",
29
+ "filename": "query_Ariel_Sharon_0002.jpg",
30
+ "path": "query_Ariel_Sharon_0002.jpg"
31
+ },
32
+ {
33
+ "person": "Hugo_Chavez",
34
+ "filename": "query_Hugo_Chavez_0002.jpg",
35
+ "path": "query_Hugo_Chavez_0002.jpg"
36
+ },
37
+ {
38
+ "person": "Junichiro_Koizumi",
39
+ "filename": "query_Junichiro_Koizumi_0002.jpg",
40
+ "path": "query_Junichiro_Koizumi_0002.jpg"
41
+ }
42
+ ]
assets/examples/query_Ariel_Sharon_0002.jpg ADDED
assets/examples/query_Colin_Powell_0002.jpg ADDED
assets/examples/query_Donald_Rumsfeld_0002.jpg ADDED
assets/examples/query_George_W_Bush_0002.jpg ADDED
assets/examples/query_Gerhard_Schroeder_0002.jpg ADDED
assets/examples/query_Hugo_Chavez_0002.jpg ADDED
assets/examples/query_Junichiro_Koizumi_0002.jpg ADDED
assets/examples/query_Tony_Blair_0002.jpg ADDED
assets/gallery/Ariel_Sharon_0001.jpg ADDED
assets/gallery/Colin_Powell_0001.jpg ADDED
assets/gallery/Donald_Rumsfeld_0001.jpg ADDED
assets/gallery/George_W_Bush_0001.jpg ADDED
assets/gallery/Gerhard_Schroeder_0001.jpg ADDED
assets/gallery/Hugo_Chavez_0001.jpg ADDED
assets/gallery/Junichiro_Koizumi_0001.jpg ADDED
assets/gallery/README.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ This directory stores the fixed LFW-derived reviewer gallery used by the public Hugging Face demo. The packaged gallery is intentionally small, explicit, and deployment-friendly.
assets/gallery/Tony_Blair_0001.jpg ADDED
assets/gallery/gallery_manifest.json ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "person": "George_W_Bush",
4
+ "gallery_filename": "George_W_Bush_0001.jpg",
5
+ "query_filename": "query_George_W_Bush_0002.jpg",
6
+ "gallery_path": "George_W_Bush_0001.jpg",
7
+ "query_path": "query_George_W_Bush_0002.jpg"
8
+ },
9
+ {
10
+ "person": "Colin_Powell",
11
+ "gallery_filename": "Colin_Powell_0001.jpg",
12
+ "query_filename": "query_Colin_Powell_0002.jpg",
13
+ "gallery_path": "Colin_Powell_0001.jpg",
14
+ "query_path": "query_Colin_Powell_0002.jpg"
15
+ },
16
+ {
17
+ "person": "Tony_Blair",
18
+ "gallery_filename": "Tony_Blair_0001.jpg",
19
+ "query_filename": "query_Tony_Blair_0002.jpg",
20
+ "gallery_path": "Tony_Blair_0001.jpg",
21
+ "query_path": "query_Tony_Blair_0002.jpg"
22
+ },
23
+ {
24
+ "person": "Donald_Rumsfeld",
25
+ "gallery_filename": "Donald_Rumsfeld_0001.jpg",
26
+ "query_filename": "query_Donald_Rumsfeld_0002.jpg",
27
+ "gallery_path": "Donald_Rumsfeld_0001.jpg",
28
+ "query_path": "query_Donald_Rumsfeld_0002.jpg"
29
+ },
30
+ {
31
+ "person": "Gerhard_Schroeder",
32
+ "gallery_filename": "Gerhard_Schroeder_0001.jpg",
33
+ "query_filename": "query_Gerhard_Schroeder_0002.jpg",
34
+ "gallery_path": "Gerhard_Schroeder_0001.jpg",
35
+ "query_path": "query_Gerhard_Schroeder_0002.jpg"
36
+ },
37
+ {
38
+ "person": "Ariel_Sharon",
39
+ "gallery_filename": "Ariel_Sharon_0001.jpg",
40
+ "query_filename": "query_Ariel_Sharon_0002.jpg",
41
+ "gallery_path": "Ariel_Sharon_0001.jpg",
42
+ "query_path": "query_Ariel_Sharon_0002.jpg"
43
+ },
44
+ {
45
+ "person": "Hugo_Chavez",
46
+ "gallery_filename": "Hugo_Chavez_0001.jpg",
47
+ "query_filename": "query_Hugo_Chavez_0002.jpg",
48
+ "gallery_path": "Hugo_Chavez_0001.jpg",
49
+ "query_path": "query_Hugo_Chavez_0002.jpg"
50
+ },
51
+ {
52
+ "person": "Junichiro_Koizumi",
53
+ "gallery_filename": "Junichiro_Koizumi_0001.jpg",
54
+ "query_filename": "query_Junichiro_Koizumi_0002.jpg",
55
+ "gallery_path": "Junichiro_Koizumi_0001.jpg",
56
+ "query_path": "query_Junichiro_Koizumi_0002.jpg"
57
+ }
58
+ ]
assets/gallery/lfw_demo_calibration.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f2311f8e9eaefc33c921a9df7d3110038fe8c94b63eead35cda248985fd9b726
3
+ size 152062
assets/gallery/lfw_demo_gallery.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5e812c24e8c5c1ca3f2e026223736e95c8f74ba01273388d82a332b695cc2a90
3
+ size 18526
assets/videos/README.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Short uploaded video analysis is planned in the code layout but not enabled as the primary reviewer path in the first deployment. Place approved short demo clips in this directory if the video tab is activated later.
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ numpy>=1.24.0
3
+ pandas>=2.0.0
4
+ scikit-learn>=1.3.0
5
+ Pillow>=10.0.0
6
+ opencv-python-headless>=4.9.0.80
7
+ facenet-pytorch>=2.6.0
8
+ torch>=2.1.0
9
+ torchvision>=0.16.0
10
+ matplotlib>=3.7.0
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FuzzyPSI-hamming Hugging Face deployment package."""
src/config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import os
5
+
6
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
7
+ ASSETS_DIR = PROJECT_ROOT / "assets"
8
+ GALLERY_DIR = ASSETS_DIR / "gallery"
9
+ EXAMPLES_DIR = ASSETS_DIR / "examples"
10
+ VIDEOS_DIR = ASSETS_DIR / "videos"
11
+ OUTPUT_DIR = PROJECT_ROOT / "out"
12
+ OUTPUT_DIR.mkdir(exist_ok=True)
13
+
14
+ SPACE_TITLE = "FuzzyPSI-hamming"
15
+ SPACE_SUBTITLE = "Deployment-oriented demo for privacy-preserving fuzzy private set intersection over Hamming distance"
16
+
17
+ DEFAULT_MODE = os.environ.get("FPSI_DEMO_MODE", "simulation")
18
+ SUPPORTED_MODES = ("simulation", "full")
19
+
20
+ DEFAULT_DIMENSIONS = (128, 256, 512)
21
+ DEFAULT_DIM = 128
22
+ DEFAULT_SECURITY_LAMBDA = 40
23
+ DEFAULT_N_SENDER = 512
24
+ DEFAULT_N_RECEIVER = 100
25
+ DEFAULT_FRAME_SAMPLE_LIMIT = 8
26
+ DEFAULT_VIDEO_SECONDS = 12
27
+
28
+ LFW_FEATURES_PATH = os.environ.get("LFW_FEATURES_PATH", str(GALLERY_DIR / "lfw_demo_gallery.npz"))
29
+ LFW_IMAGE_ROOT = os.environ.get("LFW_IMAGE_ROOT", "")
30
+
31
+ FULL_PROTOCOL_ENABLED = os.environ.get("FPSI_ENABLE_FULL_PROTOCOL", "0") == "1"
32
+ FULL_PROTOCOL_DIR = Path(os.environ.get("FPSI_PROTOCOL_ROOT", str(PROJECT_ROOT / "runtime")))
33
+ FULL_PROTOCOL_BUILD_DIR = Path(os.environ.get("FPSI_PROTOCOL_BUILD_DIR", str(FULL_PROTOCOL_DIR / "build")))
34
+
35
+ FACE_MODEL_DEVICE = os.environ.get("FPSI_FACE_DEVICE", "cpu")
36
+ MATCH_THRESHOLD_MARGIN = float(os.environ.get("FPSI_THRESHOLD_MARGIN", "0.0"))
src/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Gallery and packaged asset loaders."""
src/data/gallery_store.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ from PIL import Image
9
+
10
+ from src import config
11
+
12
+
13
+ @dataclass
14
+ class GalleryRecord:
15
+ person: str
16
+ gallery_filename: str
17
+ query_filename: str
18
+ gallery_path: str
19
+ query_path: str
20
+
21
+
22
+ class DemoGallery:
23
+ def __init__(self) -> None:
24
+ gallery_npz = config.GALLERY_DIR / 'lfw_demo_gallery.npz'
25
+ manifest_path = config.GALLERY_DIR / 'gallery_manifest.json'
26
+ example_manifest_path = config.EXAMPLES_DIR / 'examples_manifest.json'
27
+
28
+ data = np.load(gallery_npz, allow_pickle=True)
29
+ self.features = data['features'].astype(np.float32)
30
+ self.people = data['persons']
31
+ self.filenames = data['filenames']
32
+
33
+ calibration_npz = config.GALLERY_DIR / 'lfw_demo_calibration.npz'
34
+ calibration = np.load(calibration_npz, allow_pickle=True)
35
+ self.calibration_features = calibration['features'].astype(np.float32)
36
+ self.calibration_people = calibration['persons']
37
+ self.calibration_filenames = calibration['filenames']
38
+
39
+ with open(manifest_path) as fh:
40
+ self.records = [GalleryRecord(**row) for row in json.load(fh)]
41
+ with open(example_manifest_path) as fh:
42
+ self.examples = json.load(fh)
43
+
44
+ def summary(self) -> dict[str, object]:
45
+ return {
46
+ 'gallery_size': int(len(self.people)),
47
+ 'calibration_size': int(len(self.calibration_people)),
48
+ 'people': [str(x.person) for x in self.records],
49
+ 'dimensions': int(self.features.shape[1]),
50
+ }
51
+
52
+ def example_choices(self) -> list[tuple[str, str]]:
53
+ return [(item['person'], str(config.EXAMPLES_DIR / item['filename'])) for item in self.examples]
54
+
55
+ def load_example_image(self, filename: str) -> Image.Image:
56
+ return Image.open(config.EXAMPLES_DIR / filename).convert('RGB')
57
+
58
+ def gallery_image_path(self, gallery_filename: str) -> Path:
59
+ return config.GALLERY_DIR / gallery_filename
src/pipeline/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Media preprocessing and embedding extraction."""
src/pipeline/image_pipeline.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ from PIL import Image, ImageOps, ImageStat
9
+ import torch
10
+ from facenet_pytorch import MTCNN, InceptionResnetV1
11
+
12
+ from src import config
13
+
14
+
15
+ @dataclass
16
+ class ImageAnalysis:
17
+ face_detected: bool
18
+ embedding: np.ndarray
19
+ preview: Image.Image
20
+ notes: list[str]
21
+
22
+
23
+ class ImagePipeline:
24
+ def __init__(self) -> None:
25
+ self.device = torch.device(config.FACE_MODEL_DEVICE if torch.cuda.is_available() and config.FACE_MODEL_DEVICE == 'cuda' else 'cpu')
26
+ self.mtcnn = MTCNN(image_size=160, margin=16, post_process=True, device=self.device)
27
+ self.resnet = InceptionResnetV1(pretrained='vggface2').eval().to(self.device)
28
+
29
+ def analyze(self, image: Image.Image | np.ndarray | str | Path) -> ImageAnalysis:
30
+ pil = self._to_pil(image)
31
+ pil = ImageOps.exif_transpose(pil).convert('RGB')
32
+ notes: list[str] = []
33
+
34
+ face = self.mtcnn(pil)
35
+ if face is None:
36
+ notes.append('No face detected; falling back to center crop for demo continuity.')
37
+ preview = ImageOps.fit(pil, (220, 220))
38
+ face = self.mtcnn(preview)
39
+ if face is None:
40
+ face = self._fallback_tensor(preview)
41
+ notes.append('Used RGB center-crop fallback embedding path.')
42
+ face_detected = False
43
+ else:
44
+ face_detected = True
45
+ else:
46
+ preview = ImageOps.fit(pil, (220, 220))
47
+ face_detected = True
48
+
49
+ with torch.no_grad():
50
+ embedding = self.resnet(face.unsqueeze(0).to(self.device)).cpu().numpy()[0].astype(np.float32)
51
+
52
+ brightness = float(sum(ImageStat.Stat(preview).mean) / 3.0)
53
+ notes.append(f'Average preview brightness: {brightness:.1f}')
54
+ notes.append(f'Embedding device: {self.device.type}')
55
+ return ImageAnalysis(face_detected=face_detected, embedding=embedding, preview=preview, notes=notes)
56
+
57
+ def _to_pil(self, image: Image.Image | np.ndarray | str | Path) -> Image.Image:
58
+ if isinstance(image, Image.Image):
59
+ return image
60
+ if isinstance(image, np.ndarray):
61
+ return Image.fromarray(image.astype(np.uint8))
62
+ return Image.open(image)
63
+
64
+ def _fallback_tensor(self, image: Image.Image) -> torch.Tensor:
65
+ array = np.asarray(ImageOps.fit(image, (160, 160))).astype(np.float32) / 255.0
66
+ tensor = torch.from_numpy(array).permute(2, 0, 1)
67
+ tensor = (tensor - 0.5) / 0.5
68
+ return tensor
src/pipeline/video_pipeline.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import cv2
7
+ from PIL import Image
8
+
9
+ from src import config
10
+
11
+
12
+ @dataclass
13
+ class VideoFrameSample:
14
+ timestamp_s: float
15
+ image: Image.Image
16
+
17
+
18
+ class VideoPipeline:
19
+ def sample_frames(self, video_path: str | Path, max_frames: int | None = None) -> list[VideoFrameSample]:
20
+ max_frames = max_frames or config.DEFAULT_FRAME_SAMPLE_LIMIT
21
+ cap = cv2.VideoCapture(str(video_path))
22
+ if not cap.isOpened():
23
+ raise ValueError('Unable to open uploaded video.')
24
+
25
+ fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
26
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
27
+ if total_frames == 0:
28
+ raise ValueError('Uploaded video contains no readable frames.')
29
+
30
+ step = max(total_frames // max_frames, 1)
31
+ samples: list[VideoFrameSample] = []
32
+ frame_idx = 0
33
+ while len(samples) < max_frames:
34
+ cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
35
+ ok, frame = cap.read()
36
+ if not ok:
37
+ break
38
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
39
+ samples.append(VideoFrameSample(timestamp_s=frame_idx / fps, image=Image.fromarray(rgb)))
40
+ frame_idx += step
41
+ cap.release()
42
+ return samples
src/protocol/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Protocol adapters and parameter helpers."""
src/protocol/elsh_params.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+
6
+ def collision_probability(d: int, delta: int, k: int) -> float:
7
+ denom = math.comb(d, k)
8
+ total = 0.0
9
+ for r in range(0, min(k, delta) + 1, 2):
10
+ total += math.comb(delta, r) * math.comb(d - delta, k - r) / denom
11
+ return total
12
+
13
+
14
+ def recommended_params(d: int, delta: int, lam: int = 40) -> tuple[int, float, int]:
15
+ k = math.ceil(d / (delta + 1))
16
+ p_delta = collision_probability(d, delta, k)
17
+ p_delta = min(max(p_delta, 1e-9), 1.0 - 1e-9)
18
+ l_min = math.ceil(lam / (-math.log2(1.0 - p_delta)))
19
+ return k, p_delta, l_min
src/protocol/fpsi_adapter.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ import random
7
+ import subprocess
8
+ import time
9
+ from dataclasses import dataclass, asdict
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from sklearn.decomposition import PCA
15
+ from sklearn.preprocessing import normalize
16
+
17
+ from src import config
18
+ from src.protocol.elsh_params import recommended_params
19
+
20
+
21
+ @dataclass
22
+ class MatchResult:
23
+ person: str
24
+ filename: str
25
+ hamming_distance: int
26
+ matched: bool
27
+ score: float
28
+
29
+
30
+ @dataclass
31
+ class ProtocolSummary:
32
+ mode: str
33
+ dim: int
34
+ delta: int
35
+ L: int
36
+ k: int
37
+ p_delta: float
38
+ communication_mb: float
39
+ time_s: float
40
+ gao_feasible: bool
41
+ tar: float
42
+ far: float
43
+ accuracy: float
44
+ gallery_size: int
45
+ query_count: int
46
+ binary_density: float
47
+ notes: list[str]
48
+
49
+
50
+ class FuzzyPSIAdapter:
51
+ def __init__(self, mode: str | None = None) -> None:
52
+ self.mode = mode or config.DEFAULT_MODE
53
+ if self.mode not in config.SUPPORTED_MODES:
54
+ self.mode = "simulation"
55
+
56
+ def binarize(self, features: np.ndarray, target_dim: int) -> tuple[np.ndarray, float]:
57
+ max_components = min(features.shape[0], features.shape[1])
58
+ if target_dim < features.shape[1] and target_dim <= max_components:
59
+ pca = PCA(n_components=target_dim, random_state=42)
60
+ projected = pca.fit_transform(features)
61
+ variance = float(pca.explained_variance_ratio_.sum())
62
+ elif target_dim < features.shape[1]:
63
+ projected = features[:, :target_dim]
64
+ variance = min(1.0, float(target_dim / features.shape[1]))
65
+ else:
66
+ projected = features[:, :target_dim]
67
+ variance = 1.0
68
+ projected = normalize(projected)
69
+ binary_codes = (projected > 0).astype(np.uint8)
70
+ return binary_codes, variance
71
+
72
+ def calibrate_threshold(self, binary_codes: np.ndarray, labels: np.ndarray) -> dict[str, float | int]:
73
+ rng = np.random.default_rng(42)
74
+ label_to_idx: dict[Any, list[int]] = {}
75
+ for i, label in enumerate(labels):
76
+ label_to_idx.setdefault(label, []).append(i)
77
+
78
+ multi_labels = [label for label, idx in label_to_idx.items() if len(idx) >= 2]
79
+ unique_labels = np.array(list(label_to_idx.keys()))
80
+
81
+ genuine_dists: list[int] = []
82
+ impostor_dists: list[int] = []
83
+ for label in multi_labels[:300]:
84
+ indices = label_to_idx[label]
85
+ for i in range(min(len(indices) - 1, 3)):
86
+ genuine_dists.append(int(np.sum(binary_codes[indices[i]] != binary_codes[indices[i + 1]])))
87
+
88
+ for _ in range(max(len(genuine_dists) * 2, 32)):
89
+ l1, l2 = rng.choice(unique_labels, 2, replace=False)
90
+ i1 = rng.choice(label_to_idx[l1])
91
+ i2 = rng.choice(label_to_idx[l2])
92
+ impostor_dists.append(int(np.sum(binary_codes[i1] != binary_codes[i2])))
93
+
94
+ genuine = np.array(genuine_dists if genuine_dists else [0])
95
+ impostor = np.array(impostor_dists if impostor_dists else [binary_codes.shape[1]])
96
+
97
+ best_delta = 0
98
+ best_acc = 0.0
99
+ best_tar = 0.0
100
+ best_far = 1.0
101
+ for delta in range(binary_codes.shape[1]):
102
+ tar = float(np.mean(genuine <= delta))
103
+ far = float(np.mean(impostor <= delta))
104
+ acc = (tar + (1.0 - far)) / 2.0
105
+ if acc > best_acc:
106
+ best_delta = delta
107
+ best_acc = acc
108
+ best_tar = tar
109
+ best_far = far
110
+
111
+ adjusted_delta = max(0, int(best_delta + config.MATCH_THRESHOLD_MARGIN))
112
+ return {
113
+ "delta": adjusted_delta,
114
+ "tar": best_tar,
115
+ "far": best_far,
116
+ "accuracy": best_acc,
117
+ "genuine_mean": float(genuine.mean()),
118
+ "impostor_mean": float(impostor.mean()),
119
+ }
120
+
121
+ def gao_feasible(self, dim: int, delta: int) -> bool:
122
+ return dim > 8 * delta + 8
123
+
124
+ def select_l(self, dim: int, delta: int) -> tuple[int, int, float]:
125
+ k, p_delta, l_min = recommended_params(dim, delta, config.DEFAULT_SECURITY_LAMBDA)
126
+ return l_min, k, p_delta
127
+
128
+ def query_against_gallery(
129
+ self,
130
+ query_feature: np.ndarray,
131
+ gallery_features: np.ndarray,
132
+ gallery_people: np.ndarray,
133
+ gallery_filenames: np.ndarray,
134
+ dim: int,
135
+ calibration_features: np.ndarray | None = None,
136
+ calibration_people: np.ndarray | None = None,
137
+ ) -> tuple[MatchResult, ProtocolSummary, dict[str, Any]]:
138
+ calibration_features = gallery_features if calibration_features is None else calibration_features
139
+ calibration_people = gallery_people if calibration_people is None else calibration_people
140
+
141
+ binarization_pool = np.vstack([query_feature.reshape(1, -1), calibration_features])
142
+ pool_binary, variance = self.binarize(binarization_pool, dim)
143
+ query_binary = pool_binary[0]
144
+ calibration_binary = pool_binary[1:]
145
+
146
+ gallery_pool = np.vstack([query_feature.reshape(1, -1), gallery_features])
147
+ gallery_binary = self.binarize(gallery_pool, dim)[0][1:]
148
+
149
+ labels = np.concatenate([np.array(["__query__"], dtype=object), calibration_people.astype(object)])
150
+ threshold_stats = self.calibrate_threshold(pool_binary, labels)
151
+ delta = int(threshold_stats["delta"])
152
+ L, k, p_delta = self.select_l(dim, delta)
153
+ distances = np.sum(gallery_binary != query_binary, axis=1)
154
+ best_idx = int(np.argmin(distances))
155
+ best_dist = int(distances[best_idx])
156
+ matched = best_dist <= delta
157
+ density = float(np.mean(query_binary))
158
+ score = 1.0 - (best_dist / max(dim, 1))
159
+
160
+ if self.mode == "full" and config.FULL_PROTOCOL_ENABLED:
161
+ communication_mb, protocol_time, full_notes = self._run_full_protocol(gallery_binary, query_binary, dim, delta, L)
162
+ notes = ["full protocol mode"] + full_notes
163
+ else:
164
+ communication_mb, protocol_time = self._simulate_protocol_metrics(gallery_binary, query_binary, dim, delta, L)
165
+ notes = ["simulation mode", "full protocol disabled or unavailable"]
166
+
167
+ summary = ProtocolSummary(
168
+ mode=self.mode if self.mode == "simulation" or config.FULL_PROTOCOL_ENABLED else "simulation",
169
+ dim=dim,
170
+ delta=delta,
171
+ L=L,
172
+ k=k,
173
+ p_delta=p_delta,
174
+ communication_mb=communication_mb,
175
+ time_s=protocol_time,
176
+ gao_feasible=self.gao_feasible(dim, delta),
177
+ tar=float(threshold_stats["tar"]),
178
+ far=float(threshold_stats["far"]),
179
+ accuracy=float(threshold_stats["accuracy"]),
180
+ gallery_size=int(len(gallery_features)),
181
+ query_count=1,
182
+ binary_density=density,
183
+ notes=notes,
184
+ )
185
+ match = MatchResult(
186
+ person=str(gallery_people[best_idx]),
187
+ filename=str(gallery_filenames[best_idx]),
188
+ hamming_distance=best_dist,
189
+ matched=matched,
190
+ score=score,
191
+ )
192
+ details = {
193
+ "variance_explained": variance,
194
+ "query_binary": query_binary.tolist(),
195
+ "best_index": best_idx,
196
+ "top5_distances": [int(x) for x in np.sort(distances)[:5]],
197
+ "calibration_size": int(len(calibration_binary)),
198
+ }
199
+ return match, summary, details
200
+
201
+ def _simulate_protocol_metrics(
202
+ self,
203
+ gallery_binary: np.ndarray,
204
+ query_binary: np.ndarray,
205
+ dim: int,
206
+ delta: int,
207
+ L: int,
208
+ ) -> tuple[float, float]:
209
+ gallery_size = len(gallery_binary)
210
+ hit_ratio = max(0.02, min(0.35, (delta + 1) / max(dim, 1) * 6.0))
211
+ candidate_count = max(1, int(gallery_size * hit_ratio))
212
+ communication_mb = (L * dim + candidate_count * 16 + 500000) / (1024.0 * 1024.0)
213
+ base_time = 0.05 + candidate_count * 0.0012 + L * 0.001
214
+ return float(communication_mb), float(base_time)
215
+
216
+ def _run_full_protocol(
217
+ self,
218
+ gallery_binary: np.ndarray,
219
+ query_binary: np.ndarray,
220
+ dim: int,
221
+ delta: int,
222
+ L: int,
223
+ ) -> tuple[float, float, list[str]]:
224
+ sender_path, receiver_path = self._write_binary_pair(gallery_binary, query_binary, dim)
225
+ build_dir = config.FULL_PROTOCOL_BUILD_DIR
226
+ receiver_bin = build_dir / "fpsi_receiver"
227
+ sender_bin = build_dir / "fpsi_sender"
228
+ if not receiver_bin.exists() or not sender_bin.exists():
229
+ communication_mb, protocol_time = self._simulate_protocol_metrics(gallery_binary, query_binary, dim, delta, L)
230
+ return communication_mb, protocol_time, ["native binaries missing; fell back to simulated metrics"]
231
+
232
+ port = 26000 + random.randint(0, 900)
233
+ recv_cmd = [str(receiver_bin), str(port), str(len(query_binary.reshape(1, -1))), str(dim), str(delta), str(L)]
234
+ send_cmd = [str(sender_bin), "127.0.0.1", str(port), str(len(gallery_binary)), str(dim), str(delta), str(L)]
235
+
236
+ recv_proc = subprocess.Popen(recv_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
237
+ time.sleep(1.0)
238
+ send_proc = subprocess.Popen(send_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
239
+ send_out, send_err = send_proc.communicate(timeout=300)
240
+ recv_out, recv_err = recv_proc.communicate(timeout=300)
241
+ comm_mb = 0.0
242
+ total_time = 0.0
243
+ for line in recv_out.splitlines():
244
+ if "Total:" in line:
245
+ parts = line.split()
246
+ for i, token in enumerate(parts):
247
+ if token.endswith("s,"):
248
+ total_time = float(token[:-2])
249
+ elif token == "MB":
250
+ comm_mb = float(parts[i - 1])
251
+ notes = []
252
+ if send_err.strip():
253
+ notes.append(f"sender stderr: {send_err.strip()[:200]}")
254
+ if recv_err.strip():
255
+ notes.append(f"receiver stderr: {recv_err.strip()[:200]}")
256
+ if comm_mb == 0.0 and total_time == 0.0:
257
+ sim_comm, sim_time = self._simulate_protocol_metrics(gallery_binary, query_binary, dim, delta, L)
258
+ return sim_comm, sim_time, notes + ["native run returned no parsable totals; using simulated metrics"]
259
+ return comm_mb, total_time, notes
260
+
261
+ def _write_binary_pair(self, gallery_binary: np.ndarray, query_binary: np.ndarray, dim: int) -> tuple[Path, Path]:
262
+ runtime_dir = config.OUTPUT_DIR / "runtime_inputs"
263
+ runtime_dir.mkdir(parents=True, exist_ok=True)
264
+ sender_path = runtime_dir / f"sender_d{dim}.bin"
265
+ receiver_path = runtime_dir / f"receiver_d{dim}.bin"
266
+
267
+ self._write_binary_dataset(sender_path, gallery_binary)
268
+ self._write_binary_dataset(receiver_path, query_binary.reshape(1, -1))
269
+ return sender_path, receiver_path
270
+
271
+ @staticmethod
272
+ def _write_binary_dataset(path: Path, vectors: np.ndarray) -> None:
273
+ vectors = np.asarray(vectors, dtype=np.uint8)
274
+ n, d = vectors.shape
275
+ with open(path, "wb") as fh:
276
+ fh.write(int(n).to_bytes(4, "little"))
277
+ fh.write(int(d).to_bytes(4, "little"))
278
+ fh.write(vectors.tobytes())
279
+
280
+ def export_summary(self, match: MatchResult, summary: ProtocolSummary, details: dict[str, Any]) -> str:
281
+ payload = {
282
+ "match": asdict(match),
283
+ "summary": asdict(summary),
284
+ "details": details,
285
+ }
286
+ return json.dumps(payload, indent=2)
src/protocol/runtime_stub.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ This deployment project defaults to simulation mode. Full native protocol mode can be enabled later by provisioning a built FPSI runtime under runtime/build and setting FPSI_ENABLE_FULL_PROTOCOL=1.
src/ui/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """UI helpers for the Hugging Face demo."""
src/ui/components.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import gradio as gr
7
+ import pandas as pd
8
+ from PIL import Image
9
+
10
+ from src import config
11
+ from src.data.gallery_store import DemoGallery
12
+ from src.pipeline.image_pipeline import ImagePipeline
13
+ from src.protocol.fpsi_adapter import FuzzyPSIAdapter
14
+
15
+
16
+ gallery = DemoGallery()
17
+ image_pipeline = ImagePipeline()
18
+ adapter = FuzzyPSIAdapter(config.DEFAULT_MODE)
19
+
20
+ CSS = """
21
+ .hero {padding: 20px 24px; border-radius: 18px; background: linear-gradient(135deg, #102a43 0%, #243b53 50%, #334e68 100%); color: white;}
22
+ .metric-card {padding: 14px 16px; border-radius: 14px; background: #f8fafc; border: 1px solid #d9e2ec;}
23
+ .note-card {padding: 14px 16px; border-radius: 14px; background: #f4f7fb; border-left: 4px solid #486581;}
24
+ .small-muted {color: #627d98; font-size: 0.95rem;}
25
+ """
26
+
27
+
28
+ def _example_paths() -> list[list[str]]:
29
+ return [[path] for _, path in gallery.example_choices()]
30
+
31
+
32
+ def _system_status() -> str:
33
+ summary = gallery.summary()
34
+ return json.dumps(
35
+ {
36
+ "mode": adapter.mode,
37
+ "full_protocol_enabled": config.FULL_PROTOCOL_ENABLED,
38
+ "gallery_size": summary["gallery_size"],
39
+ "gallery_feature_dim": summary["dimensions"],
40
+ "supported_dims": list(config.DEFAULT_DIMENSIONS),
41
+ "device": config.FACE_MODEL_DEVICE,
42
+ },
43
+ indent=2,
44
+ )
45
+
46
+
47
+ def _protocol_story(dim: int, summary_json: str) -> pd.DataFrame:
48
+ payload = json.loads(summary_json)
49
+ summary = payload["summary"]
50
+ rows = [
51
+ ("1. Image frontend", "Uploaded face image is normalized and passed through the demo embedding stack."),
52
+ ("2. Binary projection", f"Embedding is binarized to d={dim} bits for Hamming-space matching."),
53
+ ("3. Candidate generation", f"E-LSH is parameterized with L={summary['L']} and k={summary['k']}."),
54
+ ("4. Exact verification", f"Final decision uses δ={summary['delta']} with backend mode={summary['mode']}."),
55
+ ("5. Reviewer metrics", f"Communication={summary['communication_mb']:.3f} MB, latency={summary['time_s']:.3f} s, Gao feasible={summary['gao_feasible']}."),
56
+ ]
57
+ return pd.DataFrame(rows, columns=["Stage", "Deployment-facing explanation"])
58
+
59
+
60
+ def run_image_demo(image: Image.Image, dim: int, mode: str):
61
+ if image is None:
62
+ raise gr.Error("Please upload or capture a face image.")
63
+
64
+ adapter.mode = mode
65
+ analysis = image_pipeline.analyze(image)
66
+ match, summary, details = adapter.query_against_gallery(
67
+ analysis.embedding,
68
+ gallery.features,
69
+ gallery.people,
70
+ gallery.filenames,
71
+ dim,
72
+ calibration_features=gallery.calibration_features,
73
+ calibration_people=gallery.calibration_people,
74
+ )
75
+ gallery_image = Image.open(gallery.gallery_image_path(match.filename)).convert("RGB")
76
+ summary_json = adapter.export_summary(match, summary, details)
77
+ result_md = f"""
78
+ ### Match result
79
+ - **Predicted identity:** {match.person}
80
+ - **Matched gallery image:** `{match.filename}`
81
+ - **Hamming distance:** {match.hamming_distance}
82
+ - **Decision:** {'Match accepted' if match.matched else 'No match under current threshold'}
83
+ - **Similarity-style score:** {match.score:.4f}
84
+
85
+ ### Protocol metrics
86
+ - **Mode:** {summary.mode}
87
+ - **Dimension:** {summary.dim}
88
+ - **Threshold δ:** {summary.delta}
89
+ - **E-LSH L:** {summary.L}
90
+ - **Communication:** {summary.communication_mb:.3f} MB
91
+ - **Latency:** {summary.time_s:.3f} s
92
+ - **TAR / FAR / Acc:** {summary.tar:.4f} / {summary.far:.4f} / {summary.accuracy:.4f}
93
+ - **Gao feasibility:** {'YES' if summary.gao_feasible else 'NO'}
94
+ """
95
+ notes = "\n".join(f"- {note}" for note in (analysis.notes + summary.notes))
96
+ metrics_df = pd.DataFrame([
97
+ ("binary_density", round(summary.binary_density, 4)),
98
+ ("variance_explained", round(details['variance_explained'], 4)),
99
+ ("top5_distances", ", ".join(map(str, details['top5_distances']))),
100
+ ("gallery_size", summary.gallery_size),
101
+ ], columns=["Metric", "Value"])
102
+ return result_md, analysis.preview, gallery_image, summary_json, notes, metrics_df, _protocol_story(dim, summary_json)
103
+
104
+
105
+ def load_example(example_path: str):
106
+ return Image.open(example_path).convert("RGB")
107
+
108
+
109
+ def build_app() -> gr.Blocks:
110
+ with gr.Blocks(title=config.SPACE_TITLE, css=CSS) as demo:
111
+ gr.HTML(
112
+ f"""
113
+ <div class='hero'>
114
+ <h1>{config.SPACE_TITLE}</h1>
115
+ <p>{config.SPACE_SUBTITLE}</p>
116
+ <p>This reviewer-facing demo shows how the research artifact can be surfaced as a deployment-oriented application with image upload, protocol metrics, and a fixed LFW-derived gallery.</p>
117
+ </div>
118
+ """
119
+ )
120
+
121
+ with gr.Row():
122
+ with gr.Column(scale=2):
123
+ gr.Markdown(
124
+ """
125
+ ### Deployment focus
126
+ - **Image-first MVP** with upload and webcam capture
127
+ - **Dual backend modes**: simulation by default, optional full protocol mode
128
+ - **Fixed LFW-derived reviewer gallery** for stable testing
129
+ - **Protocol explanation panels** to highlight engineering and deployment effort
130
+ """
131
+ )
132
+ with gr.Column(scale=1):
133
+ gr.Code(_system_status(), language="json", label="System status")
134
+
135
+ with gr.Tabs():
136
+ with gr.Tab("Image test"):
137
+ with gr.Row():
138
+ with gr.Column(scale=1):
139
+ image_input = gr.Image(label="Upload or capture a query face", type="pil", sources=["upload", "webcam"])
140
+ dim_input = gr.Dropdown(choices=list(config.DEFAULT_DIMENSIONS), value=config.DEFAULT_DIM, label="Binary dimension")
141
+ mode_input = gr.Dropdown(choices=list(config.SUPPORTED_MODES), value=config.DEFAULT_MODE, label="Backend mode")
142
+ run_btn = gr.Button("Run FuzzyPSI-hamming demo", variant="primary")
143
+ gr.Examples(examples=_example_paths(), inputs=[image_input], label="Reviewer examples")
144
+ with gr.Column(scale=1):
145
+ preview_output = gr.Image(label="Processed query preview")
146
+ gallery_output = gr.Image(label="Best gallery match")
147
+ with gr.Column(scale=1):
148
+ result_output = gr.Markdown(label="Match result")
149
+ notes_output = gr.Markdown(label="Execution notes")
150
+
151
+ with gr.Row():
152
+ metrics_output = gr.Dataframe(label="Protocol metrics", interactive=False)
153
+ story_output = gr.Dataframe(label="How the pipeline maps to deployment", interactive=False)
154
+ summary_output = gr.Code(label="Detailed JSON summary", language="json")
155
+
156
+ run_btn.click(
157
+ run_image_demo,
158
+ inputs=[image_input, dim_input, mode_input],
159
+ outputs=[result_output, preview_output, gallery_output, summary_output, notes_output, metrics_output, story_output],
160
+ )
161
+
162
+ with gr.Tab("Protocol metrics"):
163
+ gr.Markdown(
164
+ """
165
+ ### Reviewer-visible protocol story
166
+ This deployment keeps the paper’s Hamming-FPSI logic visible through dimension, threshold, communication, latency, and Gao-feasibility metrics. The public Space defaults to simulation mode for reliability, while the project layout preserves a path for native protocol execution.
167
+ """
168
+ )
169
+ metrics_seed = pd.DataFrame([
170
+ (128, "adaptive", "reported after query", "public default", "best for reliable reviewer experience"),
171
+ (256, "adaptive", "reported after query", "public default", "larger code space and stronger separation"),
172
+ (512, "adaptive", "reported after query", "optional heavy mode", "closest to high-dimensional biometric features"),
173
+ ], columns=["d", "δ", "runtime metrics", "recommended mode", "deployment note"])
174
+ gr.Dataframe(value=metrics_seed, interactive=False)
175
+
176
+ with gr.Tab("How it works"):
177
+ gr.Markdown(
178
+ """
179
+ ### End-to-end deployment path
180
+ 1. **Frontend intake**: upload or webcam image enters the Gradio service.
181
+ 2. **Face embedding**: a deployment-friendly embedding stack converts the image into a dense vector.
182
+ 3. **Binary projection**: the adapter ports the artifact’s binarization logic into an interactive service layer.
183
+ 4. **E-LSH candidate generation**: deployment metrics expose the recommended `k`, collision probability, and `L` values.
184
+ 5. **Exact verification**: the service returns the closest gallery identity together with thresholded Hamming-distance evidence.
185
+ 6. **Reviewer instrumentation**: UI cards surface latency, communication, threshold, and Gao infeasibility in the same run.
186
+ """
187
+ )
188
+
189
+ with gr.Tab("Engineering story"):
190
+ gr.Markdown(
191
+ """
192
+ ### Why this Space shows substantial engineering effort
193
+ - The original release artifact is **script-first** and **feature-file-driven**.
194
+ - This deployment adds a **new image ingestion layer**, **web interaction model**, **demo gallery packaging**, and **dual backend execution abstraction**.
195
+ - The deployment project is isolated from the research artifact so the web stack can evolve independently.
196
+ - The public interface is intentionally framed as a **factory-facing deployment prototype** rather than only a benchmark runner.
197
+ """
198
+ )
199
+
200
+ with gr.Tab("Video path"):
201
+ gr.Markdown(
202
+ """
203
+ ### Planned short-video support
204
+ The current first release is image-first. The code layout already includes a video pipeline module so that short uploaded clip analysis can be enabled next without restructuring the app.
205
+ """
206
+ )
207
+
208
+ return demo