Skip to content

Commit ab2c3d6

Browse files
committed
Feature end-to-end demo in README
1 parent 8223b31 commit ab2c3d6

7 files changed

Lines changed: 140 additions & 29 deletions

File tree

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ The target outcome is a reproducible, production-oriented computer-vision platfo
1212

1313
> This README is the main project guide. It summarizes the implemented system, shows verified results, and links to the commands and operational documentation needed to reproduce it.
1414
15+
## Featured demo
16+
17+
**[▶ Watch the 2 minute 30 second end-to-end demo video](assets/demo/factoryvision-demo.mp4)**
18+
19+
The walkthrough shows an image upload, FastAPI prediction, segmentation
20+
post-processing, Prometheus/Grafana monitoring, and MLflow model selection.
21+
The [static storyboard](assets/demo/factoryvision-demo-storyboard.png) and
22+
[demo guide](docs/demo.md) explain the scenes in detail.
23+
1524
## Project goals
1625

1726
- Train a PyTorch segmentation model for industrial surface defects.
@@ -285,9 +294,10 @@ and can be reproduced with Docker Compose.
285294

286295
## End-to-end demo
287296

288-
The [2 minute 30 second demo GIF](assets/demo/factoryvision-demo.gif) walks
297+
The [2 minute 30 second demo video](assets/demo/factoryvision-demo.mp4) walks
289298
through image upload, FastAPI prediction, segmentation post-processing,
290299
Prometheus/Grafana monitoring, and MLflow model selection. The static
300+
[GIF fallback](assets/demo/factoryvision-demo.gif),
291301
[storyboard](assets/demo/factoryvision-demo-storyboard.png), exact
292302
[API response](assets/demo/api-response.json), and instructions for rebuilding
293303
the demo are in [`docs/demo.md`](docs/demo.md).
@@ -371,7 +381,7 @@ factoryvision-mlops/
371381
|-- conf/base/ # Kedro parameters and catalog configuration
372382
|-- data/ # DVC pointers only
373383
|-- assets/screenshots/ # Checked-in README visual evidence
374-
|-- assets/demo/ # End-to-end demo GIF and storyboard
384+
|-- assets/demo/ # End-to-end demo video, GIF, and storyboard
375385
|-- docker/
376386
|-- k8s/
377387
|-- docs/ # Configuration, load-test, and rollback guides
213 KB
Loading
3.73 KB
Loading

assets/demo/factoryvision-demo.gif

54.8 KB
Loading

assets/demo/factoryvision-demo.mp4

9.56 MB
Binary file not shown.

docs/demo.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
# End-to-end demo
22

3-
The reviewable demo is [`assets/demo/factoryvision-demo.gif`](../assets/demo/factoryvision-demo.gif).
4-
It is a 2 minute 30 second looping walkthrough of the FactoryVision path:
3+
The primary reviewable demo is [`assets/demo/factoryvision-demo.mp4`](../assets/demo/factoryvision-demo.mp4).
4+
It is a 2 minute 30 second walkthrough of the FactoryVision path. A looping
5+
[`GIF fallback`](../assets/demo/factoryvision-demo.gif) is also included:
56

67
```text
78
image upload -> FastAPI /predict -> ONNX mask and score
89
-> prediction persistence -> Prometheus/Grafana
910
-> MLflow experiment and registered candidate
1011
```
1112

12-
The GIF is assembled from real repository evidence. Its API response is
13+
The video is assembled from real repository evidence. Its API response is
1314
generated by calling the real FastAPI `/predict` route with the ONNX model and
1415
an in-memory store. The monitoring frame is a captured local Grafana dashboard,
1516
and the MLflow frame uses the actual mini-study results. This keeps the demo
1617
reproducible without requiring a cloud account or embedding credentials.
1718

19+
The model still receives its fixed `256 x 640` letterboxed input. For the
20+
qualitative prediction image, the padded region is cropped away and the mask is
21+
mapped back to the original image dimensions, so the source image appears only
22+
once.
23+
1824
## Timeline
1925

2026
| Time | Scene | What to explain |
@@ -38,7 +44,8 @@ training, registration, and ONNX export instructions in the README first.
3844

3945
The script writes:
4046

41-
- `assets/demo/factoryvision-demo.gif` — the 2:30 looping demo;
47+
- `assets/demo/factoryvision-demo.mp4` — the primary 2:30 demo video;
48+
- `assets/demo/factoryvision-demo.gif` — a looping GIF fallback;
4249
- `assets/demo/factoryvision-demo-storyboard.png` — a static six-scene review;
4350
- `assets/demo/api-prediction-overlay.png` — the actual API prediction overlay;
4451
- `assets/demo/api-response.json` — the exact response used in the API scene.

scripts/build_demo_gif.py

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Build a reviewable end-to-end FactoryVision demo GIF."""
1+
"""Build a reviewable end-to-end FactoryVision demo video and GIF."""
22

33
from __future__ import annotations
44

@@ -8,6 +8,8 @@
88
import json
99
from pathlib import Path
1010

11+
import cv2
12+
import numpy as np
1113
from fastapi.testclient import TestClient
1214
from PIL import Image, ImageDraw, ImageFont, ImageOps
1315

@@ -18,12 +20,15 @@
1820
ROOT = Path(__file__).resolve().parents[1]
1921
OUT_DIR = ROOT / "assets" / "demo"
2022
WIDTH, HEIGHT = 1280, 720
21-
BACKGROUND = "#0f172a"
22-
PANEL = "#1e293b"
23-
TEXT = "#f8fafc"
24-
MUTED = "#cbd5e1"
25-
GREEN = "#86efac"
26-
ORANGE = "#fdba74"
23+
TOTAL_SECONDS = 150
24+
BACKGROUND = "#f7f3ec"
25+
PANEL = "#fffdf9"
26+
PANEL_BORDER = "#e5ded4"
27+
TEXT = "#263238"
28+
MUTED = "#66727a"
29+
GREEN = "#2f855a"
30+
ORANGE = "#e8752a"
31+
TRACK = "#e5ded4"
2732

2833

2934
class DemoStore:
@@ -69,7 +74,13 @@ def write_text(
6974
def panel(canvas: Image.Image, box: tuple[int, int, int, int]) -> None:
7075
"""Draw a rounded panel behind one demo section."""
7176

72-
ImageDraw.Draw(canvas).rounded_rectangle(box, radius=18, fill=PANEL)
77+
ImageDraw.Draw(canvas).rounded_rectangle(
78+
box,
79+
radius=18,
80+
fill=PANEL,
81+
outline=PANEL_BORDER,
82+
width=2,
83+
)
7384

7485

7586
def header(title: str, subtitle: str) -> Image.Image:
@@ -79,10 +90,35 @@ def header(title: str, subtitle: str) -> Image.Image:
7990
draw = ImageDraw.Draw(canvas)
8091
write_text(draw, (48, 34), title, 34, bold=True)
8192
write_text(draw, (50, 82), subtitle, 18, MUTED)
82-
write_text(draw, (48, 678), "FactoryVision | local evidence walkthrough", 16, MUTED)
93+
write_text(draw, (48, 662), "FactoryVision | local evidence walkthrough", 16, MUTED)
8394
return canvas
8495

8596

97+
def add_progress_bar(canvas: Image.Image, elapsed_seconds: float) -> Image.Image:
98+
"""Add a bottom timeline that grows continuously through the video."""
99+
100+
result = canvas.copy()
101+
draw = ImageDraw.Draw(result)
102+
left, right = 48, WIDTH - 48
103+
track_top, track_bottom = HEIGHT - 18, HEIGHT - 10
104+
progress = min(1.0, max(0.0, elapsed_seconds / TOTAL_SECONDS))
105+
draw.rounded_rectangle(
106+
(left, track_top, right, track_bottom),
107+
radius=4,
108+
fill=TRACK,
109+
)
110+
draw.rounded_rectangle(
111+
(left, track_top, left + int((right - left) * progress), track_bottom),
112+
radius=4,
113+
fill=ORANGE,
114+
)
115+
minutes, seconds = divmod(int(elapsed_seconds), 60)
116+
total_minutes, total_seconds = divmod(TOTAL_SECONDS, 60)
117+
timestamp = f"{minutes:02d}:{seconds:02d} / {total_minutes:02d}:{total_seconds:02d}"
118+
write_text(draw, (WIDTH - 190, 662), timestamp, 16, MUTED)
119+
return result
120+
121+
86122
def contain(image: Image.Image, size: tuple[int, int]) -> Image.Image:
87123
"""Fit an image inside a box without stretching it."""
88124

@@ -103,6 +139,28 @@ def centered_paste(
103139
canvas.paste(fitted, (x, y))
104140

105141

142+
def letterbox_content_box(
143+
image_path: Path,
144+
target_size: tuple[int, int],
145+
) -> tuple[int, int, int, int]:
146+
"""Return the original-image region inside the API's padded canvas."""
147+
148+
image = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
149+
if image is None:
150+
raise FileNotFoundError(f"Could not read demo image: {image_path}")
151+
target_width, target_height = target_size
152+
original_height, original_width = image.shape[:2]
153+
scale = min(
154+
target_height / original_height,
155+
target_width / original_width,
156+
)
157+
resized_height = min(target_height, max(1, round(original_height * scale)))
158+
resized_width = min(target_width, max(1, round(original_width * scale)))
159+
top = (target_height - resized_height) // 2
160+
left = (target_width - resized_width) // 2
161+
return (left, top, left + resized_width, top + resized_height)
162+
163+
106164
def run_api_demo() -> tuple[dict[str, object], Image.Image]:
107165
"""Call the real FastAPI prediction route and build its overlay."""
108166

@@ -122,15 +180,14 @@ def run_api_demo() -> tuple[dict[str, object], Image.Image]:
122180
mask = Image.open(io.BytesIO(mask_bytes)).convert("L")
123181
original = Image.open(image_path).convert("RGB")
124182
target_size = (payload["mask_width"], payload["mask_height"])
125-
letterboxed = Image.new("RGB", target_size)
126-
fitted = contain(original, target_size)
127-
letterboxed.paste(
128-
fitted,
129-
((letterboxed.width - fitted.width) // 2, (letterboxed.height - fitted.height) // 2),
183+
content_box = letterbox_content_box(image_path, target_size)
184+
cropped_mask = mask.crop(content_box).resize(
185+
original.size,
186+
resample=Image.Resampling.NEAREST,
130187
)
131-
red = Image.new("RGB", letterboxed.size, (239, 68, 68))
132-
highlighted = Image.composite(red, letterboxed, mask)
133-
overlay = Image.blend(letterboxed, highlighted, 0.45)
188+
red = Image.new("RGB", original.size, (239, 68, 68))
189+
highlighted = Image.composite(red, original, cropped_mask)
190+
overlay = Image.blend(original, highlighted, 0.45)
134191
return payload, overlay
135192

136193

@@ -165,7 +222,7 @@ def api_slide(payload: dict[str, object]) -> Image.Image:
165222

166223
canvas = header(
167224
"1 | Upload and predict",
168-
"The GIF calls the real FastAPI /predict route with an in-memory demo store",
225+
"The demo calls the real FastAPI /predict route with an in-memory demo store",
169226
)
170227
panel(canvas, (48, 135, 420, 625))
171228
centered_paste(
@@ -197,12 +254,12 @@ def prediction_slide(payload: dict[str, object], overlay: Image.Image) -> Image.
197254

198255
canvas = header(
199256
"2 | Inspect the segmentation result",
200-
"The predicted mask is returned as a base64-encoded PNG",
257+
"The predicted mask is returned as a base64-encoded PNG and displayed on the original image",
201258
)
202259
panel(canvas, (48, 135, 790, 625))
203260
centered_paste(canvas, overlay, (72, 165, 766, 580))
204261
draw = ImageDraw.Draw(canvas)
205-
write_text(draw, (72, 590), "Predicted defect overlay", 20, GREEN, bold=True)
262+
write_text(draw, (72, 590), "Predicted overlay, cropped to original image", 20, GREEN, bold=True)
206263
panel(canvas, (830, 135, 1232, 625))
207264
write_text(draw, (860, 175), "Post-processing", 24, bold=True)
208265
lines = [
@@ -299,8 +356,34 @@ def closing_slide() -> Image.Image:
299356
return canvas
300357

301358

359+
def save_mp4(frames: list[Image.Image], output_path: Path) -> None:
360+
"""Write the storyboard frames as a 2:30 MP4 video."""
361+
362+
fps = 10
363+
durations = [12, 24, 30, 30, 30, 24]
364+
writer = cv2.VideoWriter(
365+
str(output_path),
366+
cv2.VideoWriter_fourcc(*"mp4v"),
367+
fps,
368+
(WIDTH, HEIGHT),
369+
)
370+
if not writer.isOpened():
371+
raise RuntimeError("Could not open an MP4 video writer.")
372+
elapsed = 0.0
373+
try:
374+
for frame, seconds in zip(frames, durations, strict=True):
375+
for tick in range(seconds * fps):
376+
elapsed_frame = add_progress_bar(frame, elapsed + tick / fps)
377+
frame_array = np.asarray(elapsed_frame)
378+
frame_bgr = cv2.cvtColor(frame_array, cv2.COLOR_RGB2BGR)
379+
writer.write(frame_bgr)
380+
elapsed += seconds
381+
finally:
382+
writer.release()
383+
384+
302385
def main() -> None:
303-
"""Build the demo GIF, storyboard preview, overlay, and response evidence."""
386+
"""Build the demo video, GIF fallback, storyboard, and response evidence."""
304387

305388
OUT_DIR.mkdir(parents=True, exist_ok=True)
306389
payload, overlay = run_api_demo()
@@ -312,11 +395,21 @@ def main() -> None:
312395
mlflow_slide(),
313396
closing_slide(),
314397
]
398+
video_path = OUT_DIR / "factoryvision-demo.mp4"
399+
save_mp4(frames, video_path)
315400
gif_path = OUT_DIR / "factoryvision-demo.gif"
316-
frames[0].save(
401+
gif_frames = [
402+
add_progress_bar(frame, elapsed)
403+
for frame, elapsed in zip(
404+
frames,
405+
[0, 12, 36, 66, 96, 126],
406+
strict=True,
407+
)
408+
]
409+
gif_frames[0].save(
317410
gif_path,
318411
save_all=True,
319-
append_images=frames[1:],
412+
append_images=gif_frames[1:],
320413
duration=[12000, 24000, 30000, 30000, 30000, 24000],
321414
loop=0,
322415
optimize=True,
@@ -333,6 +426,7 @@ def main() -> None:
333426
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
334427
)
335428
print(f"Saved {gif_path}")
429+
print(f"Saved {video_path}")
336430
print(f"Saved {OUT_DIR / 'factoryvision-demo-storyboard.png'}")
337431
print(f"Saved {OUT_DIR / 'api-prediction-overlay.png'}")
338432
print(f"Saved {OUT_DIR / 'api-response.json'}")

0 commit comments

Comments
 (0)