1- """Build a reviewable end-to-end FactoryVision demo GIF."""
1+ """Build a reviewable end-to-end FactoryVision demo video and GIF."""
22
33from __future__ import annotations
44
88import json
99from pathlib import Path
1010
11+ import cv2
12+ import numpy as np
1113from fastapi .testclient import TestClient
1214from PIL import Image , ImageDraw , ImageFont , ImageOps
1315
1820ROOT = Path (__file__ ).resolve ().parents [1 ]
1921OUT_DIR = ROOT / "assets" / "demo"
2022WIDTH , 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
2934class DemoStore :
@@ -69,7 +74,13 @@ def write_text(
6974def 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
7586def 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+
86122def 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+
106164def 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+
302385def 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