RehamAAhmed commited on
Commit
b8760c9
ยท
verified ยท
1 Parent(s): dd75250

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +20 -27
  2. README.md +18 -23
  3. app.py +379 -517
  4. requirements.txt +13 -6
Dockerfile CHANGED
@@ -1,44 +1,37 @@
1
- # Use a lightweight official Python runtime as a parent image
2
  FROM python:3.10-slim
3
 
4
- # Set environment variables
5
  ENV PYTHONUNBUFFERED=1 \
6
  PYTHONDONTWRITEBYTECODE=1 \
7
- HOME=/home/user \
8
- PATH=/home/user/.local/bin:$PATH
9
 
10
- # Create a non-root user
11
- RUN useradd -m -u 1000 user
12
 
13
- # Install system dependencies for OpenCV, FFmpeg, and Git
14
  RUN apt-get update && apt-get install -y --no-install-recommends \
15
- ffmpeg \
16
- libgl1 \
17
- libglib2.0-0 \
18
  build-essential \
 
 
19
  git \
20
  && rm -rf /var/lib/apt/lists/*
21
 
22
- # Set up workspace directory and change ownership to the non-root user
23
- RUN mkdir -p /home/user/app && chown -R user:user /home/user/app
24
-
25
- # Switch to the non-root user
26
- USER user
27
- WORKDIR /home/user/app
28
 
29
- # Copy the requirements file first to leverage Docker cache
30
- COPY --chown=user:user requirements.txt .
31
 
32
- # Install PyTorch and requirements
33
- RUN pip install --no-cache-dir --upgrade pip && \
34
- pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu && \
35
- pip install --no-cache-dir -r requirements.txt
36
 
37
- # Copy the rest of the application code
38
- COPY --chown=user:user . .
39
 
40
- # Expose port 7860 (Hugging Face Spaces default port)
41
  EXPOSE 7860
42
 
43
- # Start the Gradio application
44
- CMD ["python", "app.py"]
 
1
+ # Use Python 3.10 slim image
2
  FROM python:3.10-slim
3
 
4
+ # Prevent Python from writing pyc files and buffering stdout/stderr
5
  ENV PYTHONUNBUFFERED=1 \
6
  PYTHONDONTWRITEBYTECODE=1 \
7
+ HF_HOME=/tmp/hf_cache
 
8
 
9
+ # Set working directory
10
+ WORKDIR /code
11
 
12
+ # Install system dependencies for OpenCV and basic compilation
13
  RUN apt-get update && apt-get install -y --no-install-recommends \
 
 
 
14
  build-essential \
15
+ libgl1-mesa-glx \
16
+ libglib2.0-0 \
17
  git \
18
  && rm -rf /var/lib/apt/lists/*
19
 
20
+ # Copy and install python dependencies
21
+ COPY requirements.txt /code/requirements.txt
22
+ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
 
 
 
23
 
24
+ # Create cache directories and set permissions for Hugging Face container user
25
+ RUN mkdir -p /tmp/hf_cache && chmod -R 777 /tmp/hf_cache
26
 
27
+ # Copy application files
28
+ COPY . /code
 
 
29
 
30
+ # Change permissions to make files writable by Hugging Face default user (UID 1000)
31
+ RUN chmod -R 777 /code
32
 
33
+ # Expose port 7860 for Hugging Face Space
34
  EXPOSE 7860
35
 
36
+ # Run Streamlit on Hugging Face standard port
37
+ CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
README.md CHANGED
@@ -1,33 +1,28 @@
1
  ---
2
- title: SAM 3 Concept Video Tracker
3
  emoji: ๐Ÿ 
4
  colorFrom: blue
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- # ๐Ÿ  SAM 3 Concept Video Tracker
 
12
 
13
- This Hugging Face Space hosts the **SAM 3 Concept Video Tracker** application, built using Gradio, Hugging Face `transformers` and Meta's **Segment Anything Model 3 (SAM 3)**.
 
 
 
 
14
 
15
- ## Features
16
- - **Single Video Input:** Upload any video and start tracking.
17
- - **Open-Vocabulary Text Prompting:** Simply type a list of comma-separated concepts you want to track (e.g. `chair, couch, bed`).
18
- - **Unified Video Tracking:** Uses `facebook/sam3`'s internal video memory state to automatically detect, segment, and track the concepts across frames with persistent tracking IDs.
19
- - **Detailed Tracking IDs Report:** Outputs the exact tracking IDs associated with each class in a clean markdown table.
20
- - **Interactive Duration Slider:** Limit video duration easily to save memory.
21
 
22
- ## Running Locally
23
- To launch the application locally:
24
- ```bash
25
- pip install -r requirements.txt
26
- python app.py
27
- ```
28
- Or build the Docker container:
29
- ```bash
30
- docker build -t sam3-video-tracker .
31
- docker run -p 7860:7860 sam3-video-tracker
32
- ```
33
- Note: Since `facebook/sam3` is gated, you must provide a valid Hugging Face Access Token in the textbox inside the Gradio UI.
 
1
  ---
2
+ title: Property Damage Inspector & Matcher
3
  emoji: ๐Ÿ 
4
  colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
 
9
  ---
10
 
11
+ # ๐Ÿ  AI Property Damage Inspector & Inventory Matcher
12
+ An advanced computer vision pipeline combining **DINOv2**, **Grounded-SAM**, and **MLLMs (Gemini, Llama Vision, GPT-4o)** to automate inventory matching and damage assessment between "Before" and "After" states.
13
 
14
+ ## ๐ŸŒŸ Key Features
15
+ - **Directory-wide Batch Comparison:** Matches items between two folders automatically using a DINOv2 cls, patch matching, and SIFT+RANSAC cascade.
16
+ - **AI-Powered Damage Detection:** Inspects matched pairs for deep scratches, breakages, or tears using multi-API providers (Gemini, OpenRouter, OpenAI, Groq).
17
+ - **Damage Segmentation (Grounded-SAM):** Automatically generates precise segmentation masks where damage is located using Grounding DINO + SAM.
18
+ - **Premium Bilingual Reports:** Exports a consolidated, interactive, self-contained dark-mode HTML report (English & Arabic) embedding all base64-encoded images.
19
 
20
+ ## ๐Ÿš€ Deployment on Hugging Face Spaces
21
+ This repository is configured to deploy directly to Hugging Face Spaces using **Docker**.
 
 
 
 
22
 
23
+ ### How to Deploy:
24
+ 1. Create a new Space on [Hugging Face](https://huggingface.co/new-space).
25
+ 2. Select **Docker** as the SDK.
26
+ 3. Upload all the files from this directory.
27
+ 4. Add your API keys (e.g., `OPENROUTER_API_KEY`, `GEMINI_API_KEY`) under **Settings > Variables and secrets** in your Hugging Face Space if you want to preload them.
28
+ 5. Hugging Face will build the container and start the web UI automatically.
 
 
 
 
 
 
app.py CHANGED
@@ -1,553 +1,415 @@
 
1
  import os
2
- import cv2
3
- import tempfile
4
- import colorsys
5
- import subprocess
6
- import numpy as np
7
- from collections import defaultdict
8
- from collections.abc import Mapping, Sequence
9
- from typing import Any
10
- import gradio as gr
11
- from PIL import Image, ImageDraw, ImageFont
12
 
13
- try:
14
- from transformers import Sam3VideoModel, Sam3VideoProcessor
15
- import torch
16
- HAS_TRANSFORMERS_SAM3 = True
17
- torch.set_num_threads(2)
18
- except ImportError:
19
- HAS_TRANSFORMERS_SAM3 = False
20
 
21
- def to_device_recursive(obj: Any, device: str | torch.device) -> Any:
22
- """Return a new object where all torch.Tensors reachable from `obj` are moved to the given device."""
23
- device = torch.device(device)
24
- memo = {}
25
 
26
- def _convert(x: Any) -> Any:
27
- obj_id = id(x)
28
- if obj_id in memo:
29
- return memo[obj_id]
 
 
 
 
30
 
31
- if isinstance(x, torch.Tensor):
32
- y = x.to(device)
33
- memo[obj_id] = y
34
- return y
35
 
36
- if isinstance(x, Mapping):
37
- if isinstance(x, defaultdict):
38
- y = defaultdict(x.default_factory)
39
- memo[obj_id] = y
40
- for k, v in x.items():
41
- y[k] = _convert(v)
42
- return y
 
 
43
 
44
- try:
45
- y = type(x)((k, _convert(v)) for k, v in x.items())
46
- memo[obj_id] = y
47
- return y
48
- except TypeError:
49
- y = {k: _convert(v) for k, v in x.items()}
50
- memo[obj_id] = y
51
- return y
52
-
53
- if isinstance(x, Sequence) and not isinstance(x, (str, bytes, bytearray)):
54
- if isinstance(x, list):
55
- y = [_convert(v) for v in x]
56
- elif isinstance(x, tuple):
57
- y = type(x)(_convert(v) for v in x)
58
- else:
59
- try:
60
- y = type(x)(_convert(v) for v in x)
61
- except TypeError:
62
- y = [_convert(v) for v in x]
63
- memo[obj_id] = y
64
- return y
65
 
66
- if hasattr(x, "__dict__") and not isinstance(x, type):
67
- new_obj = x.__class__.__new__(x.__class__)
68
- memo[obj_id] = new_obj
69
- for name, value in vars(x).items():
70
- setattr(new_obj, name, _convert(value))
71
- return new_obj
72
 
73
- memo[obj_id] = x
74
- return x
 
 
75
 
76
- return _convert(obj)
77
 
78
- def transcode_to_h264(input_path, output_path):
79
- """Transcodes video to H.264 codec using ffmpeg so it plays natively in all browsers."""
80
- if os.path.exists(output_path):
81
- try:
82
- os.remove(output_path)
83
- except Exception:
84
- pass
85
-
86
- cmd = [
87
- 'ffmpeg', '-y',
88
- '-i', input_path,
89
- '-vcodec', 'libx264',
90
- '-pix_fmt', 'yuv420p',
91
- '-preset', 'fast',
92
- '-crf', '23',
93
- output_path
94
- ]
95
  try:
96
- subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
97
- return output_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  except Exception as e:
99
- print(f"Transcoding failed: {e}. Returning original video.")
100
- return input_path
101
-
102
- def overlay_masks_on_frame(
103
- frame: Image.Image,
104
- masks_per_object: dict[int, np.ndarray],
105
- color_by_obj: dict[int, tuple[int, int, int]],
106
- alpha: float = 0.5,
107
- ) -> Image.Image:
108
- """Overlays segmentation masks on a frame with the given alpha transparency."""
109
- base = np.array(frame).astype(np.float32) / 255.0
110
- height, width = base.shape[:2]
111
- overlay = base.copy()
112
 
113
- for obj_id, mask in masks_per_object.items():
114
- if mask is None:
115
- continue
116
- if mask.dtype != np.float32:
117
- mask = mask.astype(np.float32)
118
- if mask.ndim == 3:
119
- mask = mask.squeeze()
120
- mask = np.clip(mask, 0.0, 1.0)
121
- color = np.array(color_by_obj.get(obj_id, (255, 0, 0)), dtype=np.float32) / 255.0
122
- a = alpha
123
- m = mask[..., None]
124
- overlay = (1.0 - a * m) * overlay + (a * m) * color
125
 
126
- out = np.clip(overlay * 255.0, 0, 255).astype(np.uint8)
127
- return Image.fromarray(out)
 
128
 
129
- def pastel_color_for_prompt(prompt_text: str) -> tuple[int, int, int]:
130
- """Generate a consistent color for a prompt text using a deterministic hash."""
131
- char_sum = sum(ord(c) for c in prompt_text)
132
- hue = ((char_sum * 2654435761) % 360) / 360.0
133
- saturation = 0.5
134
- value = 0.95
135
- r_f, g_f, b_f = colorsys.hsv_to_rgb(hue, saturation, value)
136
- return int(r_f * 255), int(g_f * 255), int(b_f * 255)
137
 
138
- # Global caches to avoid reloading the model on every run if the token remains the same
139
- _cached_model = None
140
- _cached_processor = None
141
- _cached_model_id = None
142
-
143
- def get_sam3_model_and_processor(model_id="facebook/sam3", hf_token=None):
144
- """Loads the SAM 3 model and processor with authentication lazily."""
145
- global _cached_model, _cached_processor, _cached_model_id
146
-
147
- token = hf_token.strip() if (hf_token and hf_token.strip()) else os.environ.get("HF_TOKEN")
148
- if token:
149
- token = token.strip()
150
-
151
- if token:
152
- try:
153
- from huggingface_hub import login
154
- login(token=token)
155
- print("Logged into Hugging Face Hub successfully.")
156
- except Exception as e:
157
- raise RuntimeError(f"Hugging Face Hub authentication failed: {e}")
158
-
159
- if _cached_model is not None and _cached_model_id == model_id:
160
- return _cached_model, _cached_processor
161
 
162
- try:
163
- device = "cuda" if torch.cuda.is_available() else "cpu"
164
- dtype = torch.float32 if device == "cpu" else torch.bfloat16
165
-
166
- print(f"Loading {model_id} on {device}...")
167
- processor = Sam3VideoProcessor.from_pretrained(model_id)
168
- model = Sam3VideoModel.from_pretrained(model_id, torch_dtype=dtype).to(device).eval()
169
-
170
- _cached_model = model
171
- _cached_processor = processor
172
- _cached_model_id = model_id
173
-
174
- return model, processor
175
- except Exception as e:
176
- raise RuntimeError(
177
- f"Failed to load SAM 3 model '{model_id}': {e}. "
178
- "Please ensure you have requested access to the gated model on Hugging Face "
179
- "and provided a valid access token in the text box."
180
- )
181
 
182
- def is_different_enough(img1, img2, threshold=0.92):
183
- """Detects if two images are visually different enough based on HSV histogram correlation."""
184
- # Convert PIL Images to HSV numpy arrays
185
- arr1 = cv2.cvtColor(np.array(img1), cv2.COLOR_RGB2HSV)
186
- arr2 = cv2.cvtColor(np.array(img2), cv2.COLOR_RGB2HSV)
187
-
188
- # Calculate histogram
189
- hist1 = cv2.calcHist([arr1], [0, 1], None, [30, 32], [0, 180, 0, 256])
190
- hist2 = cv2.calcHist([arr2], [0, 1], None, [30, 32], [0, 180, 0, 256])
191
-
192
- cv2.normalize(hist1, hist1, 0, 1, cv2.NORM_MINMAX)
193
- cv2.normalize(hist2, hist2, 0, 1, cv2.NORM_MINMAX)
194
-
195
- corr = cv2.compareHist(hist1, hist2, cv2.HISTCMP_CORREL)
196
- return corr < threshold
197
 
198
- def analyze_video_sam3(video_path, text_prompts, hf_token, max_duration=8.0, progress=gr.Progress()):
199
- """
200
- Core callback function:
201
- 1. Loads the video frames.
202
- 2. Logs into Hugging Face and initializes Sam3VideoModel / Sam3VideoProcessor.
203
- 3. Runs text prompt object tracking across the frames.
204
- 4. Overlays masks and labels, and generates the annotated output video.
205
- 5. Returns the video path and the tracking IDs list.
206
- """
207
- if not HAS_TRANSFORMERS_SAM3:
208
- return None, "# โš ๏ธ Dependency Error\n\nThe Hugging Face `transformers` library on this system does not support SAM 3. Please upgrade it."
209
 
210
- if not video_path:
211
- return None, "# โš ๏ธ Error\nPlease upload a video first."
212
-
213
- if not text_prompts or not text_prompts.strip():
214
- return None, "# โš ๏ธ Error\nPlease enter at least one text prompt concept to track (e.g. 'chair, couch')."
215
-
216
- progress(0.05, desc="Authenticating and loading SAM 3 video model...")
217
- try:
218
- model, processor = get_sam3_model_and_processor(model_id="facebook/sam3", hf_token=hf_token)
219
- except Exception as e:
220
- return None, f"# โš ๏ธ Model Loading Error\n\n{e}"
221
-
222
- progress(0.10, desc="Loading video frames...")
223
- cap = cv2.VideoCapture(video_path)
224
- if not cap.isOpened():
225
- return None, f"# โš ๏ธ Error\nCould not open video file: {video_path}"
226
-
227
- orig_fps = cap.get(cv2.CAP_PROP_FPS)
228
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
229
- width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
230
- height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
231
-
232
- orig_fps = orig_fps if orig_fps and orig_fps > 0 else 15.0
233
-
234
- # Target 12 FPS for smooth output rendering
235
- target_fps = 12.0
236
- frame_skip = max(1, int(orig_fps / target_fps))
237
- fps = orig_fps / frame_skip
238
- max_frames = int(max_duration * fps)
239
-
240
- # Resize resolution to speed up CPU inference dramatically
241
- max_dim = 256
242
- scale = 1.0
243
- if max(height, width) > max_dim:
244
- scale = max_dim / max(height, width)
245
- new_width = int(width * scale)
246
- new_height = int(height * scale)
247
-
248
- frames = []
249
- frame_idx_read = 0
250
- while len(frames) < max_frames:
251
- ret, frame = cap.read()
252
- if not ret:
253
- break
254
- if frame_idx_read % frame_skip == 0:
255
- if scale < 1.0:
256
- frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_AREA)
257
- frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
258
- frames.append(Image.fromarray(frame_rgb))
259
- frame_idx_read += 1
260
- cap.release()
261
-
262
- if not frames:
263
- return None, "# โš ๏ธ Error\nNo frames could be loaded from the video."
264
-
265
- # Extract motion-based keyframes to minimize model run times
266
- progress(0.12, desc="Extracting keyframes...")
267
- keyframes = [frames[0]]
268
- keyframe_indices = [0]
269
- max_gap = 24 # Force a keyframe at least every 2 seconds (24 frames at 12 FPS)
270
- min_gap = 8 # Wait at least 0.66 seconds between keyframes to save CPU time
271
-
272
- for i in range(1, len(frames)):
273
- gap = i - keyframe_indices[-1]
274
- if gap >= max_gap:
275
- keyframes.append(frames[i])
276
- keyframe_indices.append(i)
277
- elif gap >= min_gap:
278
- if is_different_enough(frames[i], keyframes[-1], threshold=0.94):
279
- keyframes.append(frames[i])
280
- keyframe_indices.append(i)
281
 
282
- print(f"Total frames: {len(frames)}, Extracted keyframes: {len(keyframes)}")
283
- progress(0.13, desc=f"Extracted {len(keyframes)} keyframes from {len(frames)} total frames.")
284
-
285
- progress(0.15, desc="Initializing SAM 3 video session...")
286
-
287
- device = "cuda" if torch.cuda.is_available() else "cpu"
288
- dtype = torch.float32 if device == "cpu" else torch.bfloat16
289
-
290
- # Initialize video session with keyframes only
291
- session = processor.init_video_session(
292
- video=keyframes,
293
- inference_device=device,
294
- inference_state_device=device,
295
- processing_device=device,
296
- video_storage_device="cpu",
297
- dtype=dtype,
298
- )
299
-
300
- # Parse comma-separated prompt concepts
301
- prompt_list = [p.strip() for p in text_prompts.split(",") if p.strip()]
302
- session = processor.add_text_prompt(inference_session=session, text=prompt_list)
303
-
304
- masks_by_frame = defaultdict(dict)
305
- color_by_obj = {}
306
- obj_to_prompt = {}
307
- track_db = defaultdict(set)
308
-
309
- progress(0.20, desc="Running SAM 3 Vision Encoder on CPU... (This takes 3-5 minutes, please wait)")
310
-
311
- session = to_device_recursive(session, device)
312
-
313
- frame_count = len(keyframes)
314
- with torch.no_grad():
315
- for model_outputs in model.propagate_in_video_iterator(
316
- inference_session=session,
317
- start_frame_idx=0,
318
- max_frame_num_to_track=frame_count,
319
- ):
320
- processed_outputs = processor.postprocess_outputs(session, model_outputs)
321
- k_frame_idx = model_outputs.frame_idx # index in keyframes list
322
-
323
- object_ids = processed_outputs["object_ids"]
324
- masks = processed_outputs["masks"]
325
- prompt_to_obj_ids = processed_outputs.get("prompt_to_obj_ids", {})
326
-
327
- # Map object IDs to prompt classes
328
- for prompt, obj_ids in prompt_to_obj_ids.items():
329
- for oid in obj_ids:
330
- oid_int = int(oid)
331
- obj_to_prompt[oid_int] = prompt
332
- track_db[prompt].add(oid_int)
333
- if oid_int not in color_by_obj:
334
- color_by_obj[oid_int] = pastel_color_for_prompt(prompt)
335
-
336
- num_objects = len(object_ids)
337
- if num_objects > 0:
338
- for mask_idx in range(num_objects):
339
- current_obj_id = int(object_ids[mask_idx].item())
340
- mask_2d = masks[mask_idx].float().cpu().numpy()
341
- if mask_2d.ndim == 3:
342
- mask_2d = mask_2d.squeeze()
343
- mask_2d = (mask_2d > 0.0).astype(np.uint8)
344
- masks_by_frame[k_frame_idx][current_obj_id] = mask_2d
345
 
346
- progress(0.20 + 0.60 * ((k_frame_idx + 1) / frame_count), desc=f"Propagating: Keyframe {k_frame_idx + 1}/{frame_count}...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
- session = to_device_recursive(session, "cpu")
349
-
350
- progress(0.80, desc="Rendering transparent mask overlays on video...")
351
- annotated_frames = []
352
-
353
- scale_factor = new_height / 720.0
354
- font_size = max(10, int(13 * scale_factor))
355
- try:
356
- font = ImageFont.truetype("arial.ttf", font_size)
357
- except Exception:
358
- font = ImageFont.load_default()
359
-
360
- for frame_idx, frame in enumerate(frames):
361
- # Find the closest keyframe index
362
- closest_k = min(range(len(keyframe_indices)), key=lambda k: abs(keyframe_indices[k] - frame_idx))
363
- frame_masks = masks_by_frame.get(closest_k, {})
364
- # Overlay masks on frame
365
- annotated_img = overlay_masks_on_frame(frame, frame_masks, color_by_obj, alpha=0.55)
366
-
367
- # Draw labels
368
- draw = ImageDraw.Draw(annotated_img)
369
- for obj_id, mask in frame_masks.items():
370
- if np.any(mask):
371
- rows = np.any(mask, axis=1)
372
- cols = np.any(mask, axis=0)
373
- y_min, y_max = np.where(rows)[0][[0, -1]]
374
- x_min, x_max = np.where(cols)[0][[0, -1]]
375
 
376
- label_x = int(x_min)
377
- label_y = max(5, int(y_min) - int(20 * scale_factor))
 
 
 
 
378
 
379
- prompt = obj_to_prompt.get(obj_id, "object")
380
- label_text = f"#{obj_id} {prompt}"
381
- obj_color = color_by_obj.get(obj_id, (255, 255, 255))
 
382
 
383
- padding = max(2, int(4 * scale_factor))
384
- bbox = draw.textbbox((label_x, label_y), label_text, font=font)
385
- draw.rectangle(
386
- [(bbox[0] - padding, bbox[1] - padding), (bbox[2] + padding, bbox[3] + padding)],
387
- fill=obj_color,
388
- outline=None
389
- )
390
- draw.text((label_x, label_y), label_text, fill=(255, 255, 255), font=font)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
- annotated_frames.append(annotated_img)
 
 
 
 
 
 
 
 
 
 
 
 
393
 
394
- progress(0.90, desc="Writing video output...")
395
- temp_fd, temp_output_path = tempfile.mkstemp(suffix=".mp4")
396
- os.close(temp_fd)
397
-
398
- fourcc = cv2.VideoWriter_fourcc(*'mp4v')
399
- out_video = cv2.VideoWriter(temp_output_path, fourcc, fps, (new_width, new_height))
400
-
401
- for img in annotated_frames:
402
- frame_bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
403
- out_video.write(frame_bgr)
404
- out_video.release()
405
-
406
- progress(0.95, desc="Transcoding video to H.264...")
407
- h264_output_path = temp_output_path.replace(".mp4", "_h264.mp4")
408
- final_output_path = transcode_to_h264(temp_output_path, h264_output_path)
409
-
410
- if final_output_path == h264_output_path:
411
  try:
412
- os.remove(temp_output_path)
413
- except Exception:
414
- pass
415
 
416
- progress(1.0, desc="Done!")
417
-
418
- # Calculate Max-Visible Count per concept (maximum visible in any single keyframe)
419
- max_visible = defaultdict(int)
420
- for k_idx in range(len(keyframes)):
421
- frame_masks = masks_by_frame.get(k_idx, {})
422
- current_visible = defaultdict(int)
423
- for obj_id, mask in frame_masks.items():
424
- if np.any(mask):
425
- prompt = obj_to_prompt.get(obj_id)
426
- if prompt:
427
- current_visible[prompt] += 1
428
- for prompt, count in current_visible.items():
429
- if count > max_visible[prompt]:
430
- max_visible[prompt] = count
431
-
432
- # Generate list report
433
- report_md = "# ๐Ÿ“‹ Apartment Inspection & Counting Report\n\n"
434
- report_md += "Below is the comparison between the **Total Tracked IDs** (which can double-count objects if they temporarily go out of view and get assigned a new ID) and the **Max-Visible Count** (which avoids double-counting by finding the peak number of objects visible simultaneously in a single frame):\n\n"
435
-
436
- # Create a markdown table
437
- report_md += "| Category | Unique Tracked IDs | Max-Visible Count | Detailed Tracking IDs |\n"
438
- report_md += "| :--- | :---: | :---: | :--- |\n"
439
-
440
- for prompt in sorted(track_db.keys()):
441
- obj_ids = sorted(list(track_db[prompt]))
442
- if obj_ids:
443
- ids_str = ", ".join(f"`#{oid}`" for oid in obj_ids)
444
- tracked_count = len(obj_ids)
445
- max_vis = max_visible.get(prompt, 0)
446
- category_name = prompt.capitalize()
447
- report_md += f"| **{category_name}** | {tracked_count} | **{max_vis}** | {ids_str} |\n"
448
 
449
- if not track_db:
450
- report_md += "\n*No objects detected in the video for the given prompts.*"
451
 
452
- return final_output_path, report_md
453
-
454
- # Theme and CSS styling
455
- theme = gr.themes.Soft(primary_hue="blue", secondary_hue="rose", neutral_hue="slate")
456
- css = """
457
- .container { max-width: 1000px; margin: auto; padding: 20px; }
458
- .header { text-align: center; margin-bottom: 30px; }
459
- .header h1 { font-size: 2.5rem; font-weight: 800; color: #3B82F6; margin-bottom: 8px; }
460
- .header p { font-size: 1.1rem; color: #4B5563; }
461
- .analyze-btn { font-size: 1.1rem !important; height: 50px !important; }
462
- .report-card { background: rgba(59, 130, 246, 0.04); padding: 25px; border-radius: 12px; border: 1px solid rgba(59, 130, 246, 0.12); }
463
- """
464
-
465
- # Build the Gradio UI
466
- with gr.Blocks(title="SAM 3 Video Tracker") as demo:
467
-
468
- with gr.Column(elem_classes="container"):
469
- # Header Section
470
- with gr.Column(elem_classes="header"):
471
- gr.Markdown("""
472
- # ๐Ÿ  SAM 3 Concept Video Tracker
473
- Automatic object tracking and segmenting using Meta's **Segment Anything 3 (SAM 3)**.
474
- """)
475
-
476
- with gr.Row():
477
- with gr.Column():
478
- input_video = gr.Video(label="๐Ÿ“น Input Video", format="mp4", interactive=True)
479
- with gr.Column():
480
- text_prompt_input = gr.Textbox(
481
- label="๐Ÿท๏ธ Concepts to Track (comma-separated)",
482
- placeholder="e.g. chair, couch, bed, plant",
483
- value="chair, couch, bed, toilet, sink, plant",
484
- lines=2,
485
- info="Enter the categories you want to track across the video."
486
- )
487
-
488
- with gr.Row():
489
- with gr.Column(scale=4):
490
- hf_token_textbox = gr.Textbox(
491
- label="๐Ÿ”‘ Hugging Face Token (Required for facebook/sam3)",
492
- placeholder="Enter your Hugging Face API Token (e.g. hf_...)",
493
- type="password",
494
- info="Request access at https://huggingface.co/facebook/sam3 first. Your token is never saved."
495
- )
496
- with gr.Column(scale=2):
497
- max_dur_slider = gr.Slider(
498
- minimum=1.0,
499
- maximum=30.0,
500
- value=8.0,
501
- step=1.0,
502
- label="Max Duration (seconds)",
503
- info="Limit video duration to save memory on CPUs."
504
- )
505
-
506
- with gr.Row():
507
- analyze_button = gr.Button("๐Ÿ” Run SAM 3 Tracking & Segmentation", variant="primary", elem_classes="analyze-btn")
508
-
509
- gr.HTML("<hr style='border: 0; height: 1px; background: #E5E7EB; margin: 30px 0;'>")
510
 
511
- # Outputs Section
512
- with gr.Row():
513
- with gr.Column():
514
- output_video = gr.Video(label="๐ŸŽจ Tracked output with SAM 3 masks & IDs", interactive=False)
515
- with gr.Column():
516
- report_output = gr.Markdown(
517
- value="*Upload video, enter concepts, and click the button to view results.*",
518
- elem_classes="report-card"
519
- )
520
 
521
- # Troubleshooting
522
- with gr.Accordion("๐Ÿง  Guide & Info", open=True):
523
- gr.Markdown(r"""
524
- ### ๐Ÿ› ๏ธ How it works:
525
- 1. **Model:** The application uses `facebook/sam3` (Segment Anything Model 3) via the Hugging Face `transformers` library.
526
- 2. **Tracking:** You type a list of comma-separated concepts (e.g., `chair, couch`). SAM 3 automatically detects, segments, and tracks instances of these concepts across the video frames using its internal temporal memory, assigning persistent tracking IDs.
527
- 3. **Requirements:** Since `facebook/sam3` is a gated repository, you must request access at Hugging Face and provide an Access Token in the token textbox.
528
- """)
529
 
530
- # Footer
531
- gr.Markdown("""
532
- <div style="text-align: center; margin-top: 50px; color: #9CA3AF; font-size: 0.9rem;">
533
- Powered by facebook/sam3 | Hugging Face Transformers | Running locally on CPU
534
- </div>
535
- """)
536
-
537
- # Setup actions
538
- analyze_button.click(
539
- fn=analyze_video_sam3,
540
- inputs=[
541
- input_video,
542
- text_prompt_input,
543
- hf_token_textbox,
544
- max_dur_slider
545
- ],
546
- outputs=[
547
- output_video,
548
- report_output
549
- ]
550
- )
551
-
552
- if __name__ == "__main__":
553
- demo.launch(server_name="0.0.0.0", server_port=7860, theme=theme, css=css)
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
  import os
3
+ from PIL import Image
4
+ from dotenv import load_dotenv
5
+ from mllm_inspector import inspect_with_gemini, inspect_with_groq, inspect_with_openai, inspect_with_openrouter
6
+ from pathlib import Path
7
+ from furniture_matcher import InventoryMatcher
8
+ from report_generator import generate_combined_report
 
 
 
 
9
 
10
+ # Load environment variables
11
+ load_dotenv(override=True)
 
 
 
 
 
12
 
13
+ # Configure Streamlit page
14
+ st.set_page_config(page_title="Property Damage Inspector", page_icon="๐Ÿ ", layout="wide")
 
 
15
 
16
+ # Custom CSS
17
+ st.markdown("""
18
+ <style>
19
+ .stApp { font-family: 'Inter', 'Segoe UI', sans-serif; }
20
+ .stButton>button { width: 100%; border-radius: 8px; font-weight: bold; background-color: #4CAF50; color: white; }
21
+ .css-1d391kg { padding-top: 2rem; }
22
+ </style>
23
+ """, unsafe_allow_html=True)
24
 
25
+ st.title("๐Ÿ  AI Property Damage Inspector")
26
+ st.markdown("---")
 
 
27
 
28
+ # Sidebar for configuration
29
+ st.sidebar.header("โš™๏ธ Settings")
30
+ provider = st.sidebar.selectbox("Select AI Provider", [
31
+ "OpenRouter (Free - Llama Vision)",
32
+ "Groq (Free - Fast Vision)",
33
+ "Google Gemini",
34
+ "OpenAI (GPT-4o)",
35
+ "Manual Mode (Skip API)"
36
+ ])
37
 
38
+ # Handle API Key
39
+ api_key = ""
40
+ if provider != "Manual Mode (Skip API)":
41
+ if "OpenRouter" in provider:
42
+ api_key = os.getenv("OPENROUTER_API_KEY", "")
43
+ if api_key:
44
+ st.sidebar.success("โœ… OpenRouter API Key loaded securely.")
45
+ else:
46
+ api_key = st.sidebar.text_input("๐Ÿ”‘ Enter OpenRouter API Key:", type="password")
47
+ elif "Groq" in provider:
48
+ api_key = os.getenv("GROQ_API_KEY", "")
49
+ if api_key:
50
+ st.sidebar.success("โœ… Groq API Key loaded securely.")
51
+ else:
52
+ api_key = st.sidebar.text_input("๐Ÿ”‘ Enter Groq API Key:", type="password")
53
+ else:
54
+ api_key = st.sidebar.text_input("๐Ÿ”‘ Enter API Key:", type="password")
 
 
 
 
55
 
56
+ st.sidebar.markdown("---")
57
+ st.sidebar.info("๐Ÿ’ก **Tip:** In Manual Mode, no internet is required. The system will use your local GPU to draw masks.")
 
 
 
 
58
 
59
+ app_mode = st.sidebar.radio("Select App Mode / ุงุฎุชุฑ ูˆุถุน ุงู„ุชุทุจูŠู‚", [
60
+ "๐Ÿ“ธ Single Image Pair Mode / ูˆุถุน ูุญุต ุตูˆุฑุฉ ู…ูุฑุฏุฉ",
61
+ "๐Ÿ“ Directory-wide Batch Mode / ูˆุถุน ุฌุฑุฏ ุงู„ู…ุฌู„ุฏุงุช ุจุงู„ูƒุงู…ู„"
62
+ ])
63
 
64
+ st.sidebar.markdown("---")
65
 
66
+ def save_optimized_image(uploaded_file, target_path, max_dim=1600):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  try:
68
+ # Load image with PIL
69
+ img = Image.open(uploaded_file)
70
+
71
+ # Convert to RGB (to prevent issues saving as JPEG)
72
+ if img.mode != "RGB":
73
+ img = img.convert("RGB")
74
+
75
+ # Get dimensions
76
+ width, height = img.size
77
+ if max(width, height) > max_dim:
78
+ # Calculate new dimensions preserving aspect ratio
79
+ if width > height:
80
+ new_width = max_dim
81
+ new_height = int(height * (max_dim / width))
82
+ else:
83
+ new_height = max_dim
84
+ new_width = int(width * (max_dim / height))
85
+
86
+ try:
87
+ resample_filter = Image.Resampling.LANCZOS
88
+ except AttributeError:
89
+ resample_filter = Image.ANTIALIAS
90
+
91
+ img = img.resize((new_width, new_height), resample=resample_filter)
92
+
93
+ # Save as JPEG with good quality
94
+ img.save(target_path, "JPEG", quality=85)
95
+ return True
96
  except Exception as e:
97
+ # Fallback to direct write if anything goes wrong
98
+ uploaded_file.seek(0)
99
+ with open(target_path, "wb") as f:
100
+ f.write(uploaded_file.getbuffer())
101
+ return False
 
 
 
 
 
 
 
 
102
 
103
+ if app_mode == "๐Ÿ“ธ Single Image Pair Mode / ูˆุถุน ูุญุต ุตูˆุฑุฉ ู…ูุฑุฏุฉ":
104
+ # Uploaders
105
+ st.subheader("๐Ÿ“ธ Upload Images")
106
+ col1, col2 = st.columns(2)
 
 
 
 
 
 
 
 
107
 
108
+ with col1:
109
+ st.markdown("### Original Image (Before)")
110
+ before_file = st.file_uploader("Upload intact image", type=["jpg", "jpeg", "png"], key="before")
111
 
112
+ with col2:
113
+ st.markdown("### Current Image (After)")
114
+ after_file = st.file_uploader("Upload damaged image", type=["jpg", "jpeg", "png"], key="after")
 
 
 
 
 
115
 
116
+ if before_file and after_file:
117
+ # Save and optimize uploaded files temporarily
118
+ os.makedirs("temp", exist_ok=True)
119
+ before_path = os.path.join("temp", "before_temp.jpg")
120
+ after_path = os.path.join("temp", "after_temp.jpg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
+ save_optimized_image(before_file, before_path)
123
+ save_optimized_image(after_file, after_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
+ # Display images side by side
126
+ c1, c2 = st.columns(2)
127
+ with c1: st.image(before_path, use_container_width=True)
128
+ with c2: st.image(after_path, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
129
 
130
+ st.markdown("---")
 
 
 
 
 
 
 
 
 
 
131
 
132
+ # Manual mode check
133
+ manual_phrases = []
134
+ if provider == "Manual Mode (Skip API)":
135
+ manual_input = st.text_input("โœ๏ธ Enter damage phrases manually (comma-separated, e.g., torn chair, broken table):")
136
+ if manual_input:
137
+ manual_phrases = [p.strip() for p in manual_input.split(',')]
138
+
139
+ if st.button("๐Ÿš€ Start Inspection", type="primary"):
140
+ if provider != "Manual Mode (Skip API)" and not api_key:
141
+ st.error("โŒ Please enter or configure your API Key first.")
142
+ elif provider == "Manual Mode (Skip API)" and not manual_phrases:
143
+ st.error("โŒ Please enter damage phrases manually.")
144
+ else:
145
+ target_phrases_list = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
+ if provider != "Manual Mode (Skip API)":
148
+ with st.spinner("โณ Analyzing images using AI..."):
149
+ try:
150
+ if "OpenRouter" in provider:
151
+ res = inspect_with_openrouter(before_path, after_path, api_key)
152
+ elif "Groq" in provider:
153
+ res = inspect_with_groq(before_path, after_path, api_key)
154
+ elif "Gemini" in provider:
155
+ res = inspect_with_gemini(before_path, after_path, api_key)
156
+ elif "OpenAI" in provider:
157
+ res = inspect_with_openai(before_path, after_path, api_key)
158
+ else:
159
+ res = None
160
+
161
+ if res:
162
+ st.success("โœ… Analysis Complete!")
163
+ st.markdown("### ๐Ÿ“ Inspection Report:")
164
+ st.info(res.get("description", ""))
165
+ target_phrases_list = res.get("target_phrases_list", [])
166
+
167
+ st.markdown("#### ๐ŸŽฏ Detected Damage for Segmentation:")
168
+ for p in target_phrases_list:
169
+ st.markdown(f"- `{p}`")
170
+ else:
171
+ st.error("โŒ Failed to extract report. Check your API key or connection.")
172
+ except Exception as e:
173
+ st.error(f"โŒ Unexpected error: {e}")
174
+ else:
175
+ target_phrases_list = manual_phrases
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
+ if target_phrases_list:
178
+ with st.spinner("๐ŸŽฏ Running Grounded-SAM to precisely locate damage... Please wait..."):
179
+ try:
180
+ from sam_masker import generate_damage_mask
181
+ output_path = os.path.join("temp", "final_result_gui.jpg")
182
+ success = generate_damage_mask(after_path, target_phrases_list, output_path)
183
+
184
+ if success:
185
+ st.markdown("---")
186
+ st.subheader("๐ŸŽ‰ Final Damage Assessment")
187
+ st.image(output_path, use_container_width=True)
188
+
189
+ with open(output_path, "rb") as file:
190
+ st.download_button(
191
+ label="๐Ÿ’พ Download Final Image",
192
+ data=file,
193
+ file_name="damage_report.jpg",
194
+ mime="image/jpeg"
195
+ )
196
+ st.balloons()
197
+ else:
198
+ st.warning("โš ๏ธ The vision model could not precisely locate the damage.")
199
+ except ImportError as e:
200
+ st.error(f"โŒ Failed to load vision library. Is lang-sam installed? Error: {e}")
201
+
202
+ else:
203
+ # Directory-wide Batch Mode
204
+ st.subheader("๐Ÿ“ Directory-wide Batch Mode / ูˆุถุน ุฌุฑุฏ ุงู„ู…ุฌู„ุฏุงุช ุจุงู„ูƒุงู…ู„")
205
+ st.markdown("Compare all images in a 'Before' folder to find corresponding items in an 'After' folder, run AI inspection, and produce a unified report.")
206
+
207
+ col1, col2 = st.columns(2)
208
+ with col1:
209
+ before_dir = st.text_input("๐Ÿ“ Before Folder Path / ู…ุณุงุฑ ู…ุฌู„ุฏ ุตูˆุฑ (ู‚ุจู„)", "./before")
210
+ with col2:
211
+ after_dir = st.text_input("๐Ÿ“ After Folder Path / ู…ุณุงุฑ ู…ุฌู„ุฏ ุตูˆุฑ (ุจุนุฏ)", "./after")
212
+
213
+ # Manual mode check
214
+ manual_phrases = []
215
+ if provider == "Manual Mode (Skip API)":
216
+ manual_input = st.text_input("โœ๏ธ Enter damage phrases manually (comma-separated, e.g., torn chair, broken table):", key="batch_manual")
217
+ if manual_input:
218
+ manual_phrases = [p.strip() for p in manual_input.split(',')]
219
+
220
+ if st.button("๐Ÿš€ Start Batch Inspection / ุจุฏุก ุงู„ูุญุต ุงู„ุดุงู…ู„", type="primary", key="start_batch"):
221
+ if provider != "Manual Mode (Skip API)" and not api_key:
222
+ st.error("โŒ Please enter or configure your API Key first.")
223
+ elif provider == "Manual Mode (Skip API)" and not manual_phrases:
224
+ st.error("โŒ Please enter damage phrases manually.")
225
+ elif not os.path.exists(before_dir) or not os.path.exists(after_dir):
226
+ st.error("โŒ One or both directory paths do not exist. Please check the paths.")
227
+ else:
228
+ results_for_report = []
229
+ os.makedirs("temp", exist_ok=True)
230
 
231
+ # Instantiate the matcher
232
+ progress_bar = st.progress(0.0)
233
+ status_text = st.empty()
234
+
235
+ status_text.text("โณ Loading DINOv2 models and initializing batch matcher...")
236
+ try:
237
+ matcher = InventoryMatcher()
238
+ except Exception as e:
239
+ st.error(f"โŒ Failed to load matching model: {e}")
240
+ st.stop()
241
+
242
+ status_text.text("Step 1/3: Matching inventory items via DINOv2 & SIFT...")
243
+ try:
244
+ match_results = matcher.run(before_dir, after_dir)
245
+ except Exception as e:
246
+ st.error(f"โŒ Error during matching: {e}")
247
+ st.stop()
 
 
 
 
 
 
 
 
 
 
248
 
249
+ total_items = len(match_results)
250
+
251
+ for idx, item in enumerate(match_results):
252
+ # Calculate progress fraction
253
+ progress_val = float(idx + 1) / total_items
254
+ progress_bar.progress(progress_val)
255
 
256
+ status = "INTACT"
257
+ damage_desc = "Intact"
258
+ target_phrases = []
259
+ masked_after_path = None
260
 
261
+ if item.found and item.best_match:
262
+ status_text.text(f"Checking item [{idx+1}/{total_items}]: {item.before_path.name} -> {item.best_match.name} for damage...")
263
+
264
+ res = None
265
+ if provider != "Manual Mode (Skip API)":
266
+ try:
267
+ if "OpenRouter" in provider:
268
+ res = inspect_with_openrouter(str(item.before_path), str(item.best_match), api_key)
269
+ elif "Groq" in provider:
270
+ res = inspect_with_groq(str(item.before_path), str(item.best_match), api_key)
271
+ elif "Gemini" in provider:
272
+ res = inspect_with_gemini(str(item.before_path), str(item.best_match), api_key)
273
+ elif "OpenAI" in provider:
274
+ res = inspect_with_openai(str(item.before_path), str(item.best_match), api_key)
275
+ except Exception as api_err:
276
+ st.warning(f"โš ๏ธ API error for {item.before_path.name}: {api_err}")
277
+ else:
278
+ # In manual mode, we just pass the manual phrases
279
+ res = {
280
+ "description": f"Manual inspection checking for: {', '.join(manual_phrases)}",
281
+ "target_phrases_list": manual_phrases
282
+ }
283
+
284
+ if res:
285
+ target_phrases = res.get("target_phrases_list", [])
286
+ target_phrases = [p.strip() for p in target_phrases if p and p.strip().lower() != "none"]
287
+
288
+ if target_phrases:
289
+ status = "DAMAGED"
290
+ damage_desc = res.get("description", "Damage detected.")
291
+
292
+ masked_out_path = os.path.join("temp", f"masked_{item.best_match.name}")
293
+ from sam_masker import generate_damage_mask
294
+ mask_success = generate_damage_mask(str(item.best_match), target_phrases, masked_out_path)
295
+ if mask_success:
296
+ masked_after_path = masked_out_path
297
+ else:
298
+ masked_after_path = None
299
+ else:
300
+ status = "INTACT"
301
+ damage_desc = res.get("description", "Intact. No major damage detected.")
302
+ else:
303
+ status = "INTACT"
304
+ damage_desc = "Intact (inspection skipped/failed)."
305
+ else:
306
+ status_text.text(f"Item [{idx+1}/{total_items}]: {item.before_path.name} is MISSING...")
307
+ status = "MISSING"
308
+ damage_desc = "Item from before inventory was not found in the after inventory."
309
+
310
+ results_for_report.append({
311
+ 'before_path': str(item.before_path),
312
+ 'after_path': str(item.best_match) if (item.found and item.best_match) else None,
313
+ 'status': status,
314
+ 'cls_score': float(item.cls_score),
315
+ 'patch_score': float(item.patch_score) if item.patch_score else 0.0,
316
+ 'geo_inliers': int(item.geo_inliers) if item.geo_inliers else 0,
317
+ 'confidence': item.confidence,
318
+ 'stage_used': int(item.stage_used),
319
+ 'damage_description': damage_desc,
320
+ 'target_phrases_list': target_phrases,
321
+ 'masked_after_path': masked_after_path
322
+ })
323
 
324
+ st.session_state.batch_results = results_for_report
325
+ status_text.success("๐ŸŽ‰ Batch Inspection Completed successfully / ุงูƒุชู…ู„ ุงู„ูุญุต ุงู„ุดุงู…ู„ ุจู†ุฌุงุญ!")
326
+ st.balloons()
327
+
328
+ # Results dashboard
329
+ if "batch_results" in st.session_state:
330
+ st.markdown("---")
331
+ st.header("๐Ÿ“Š Inspection Results / ู†ุชุงุฆุฌ ุงู„ูุญุต")
332
+
333
+ n_total = len(st.session_state.batch_results)
334
+ n_intact = sum(1 for r in st.session_state.batch_results if r['status'] == 'INTACT')
335
+ n_damaged = sum(1 for r in st.session_state.batch_results if r['status'] == 'DAMAGED')
336
+ n_missing = sum(1 for r in st.session_state.batch_results if r['status'] == 'MISSING')
337
 
338
+ col_stat1, col_stat2, col_stat3, col_stat4 = st.columns(4)
339
+ with col_stat1:
340
+ st.metric("Total Items / ุฅุฌู…ุงู„ูŠ ุงู„ุนู†ุงุตุฑ", n_total)
341
+ with col_stat2:
342
+ st.metric("Intact / ุณู„ูŠู…", n_intact)
343
+ with col_stat3:
344
+ st.metric("Damaged / ุชุงู„ู", n_damaged)
345
+ with col_stat4:
346
+ st.metric("Missing / ู…ูู‚ูˆุฏ", n_missing)
347
+
348
+ # Download Button for the Report
 
 
 
 
 
 
349
  try:
350
+ report_path = generate_combined_report(st.session_state.batch_results, "temp")
351
+ with open(report_path, "r", encoding="utf-8") as f:
352
+ html_content = f.read()
353
 
354
+ st.download_button(
355
+ label="๐Ÿ’พ Download Combined HTML Report / ุชุญู…ูŠู„ ุงู„ุชู‚ุฑูŠุฑ ุงู„ุดุงู…ู„ HTML",
356
+ data=html_content,
357
+ file_name="inventory_damage_report.html",
358
+ mime="text/html",
359
+ key="download_report"
360
+ )
361
+ except Exception as report_err:
362
+ st.error(f"Error generating report: {report_err}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
+ st.markdown("### Detailed Items / ุชูุงุตูŠู„ ุงู„ุนู†ุงุตุฑ")
365
+ filter_status = st.selectbox("Filter by Status / ุชุตููŠุฉ ุญุณ๏ฟฝ๏ฟฝ ุงู„ุญุงู„ุฉ", ["All / ุงู„ูƒู„", "Intact / ุณู„ูŠู…", "Damaged / ุชุงู„ู", "Missing / ู…ูู‚ูˆุฏ"], key="filter_status")
366
 
367
+ status_map = {
368
+ "All / ุงู„ูƒู„": "ALL",
369
+ "Intact / ุณู„ูŠู…": "INTACT",
370
+ "Damaged / ุชุงู„ู": "DAMAGED",
371
+ "Missing / ู…ูู‚ูˆุฏ": "MISSING"
372
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
 
374
+ selected_status = status_map[filter_status]
375
+
376
+ for idx, r in enumerate(st.session_state.batch_results):
377
+ if selected_status != "ALL" and r['status'] != selected_status:
378
+ continue
 
 
 
 
379
 
380
+ status_color = "#10b981" if r['status'] == 'INTACT' else "#f59e0b" if r['status'] == 'DAMAGED' else "#ef4444"
381
+ status_label = "โœ… INTACT / ุณู„ูŠู…" if r['status'] == 'INTACT' else "โš ๏ธ DAMAGED / ุชุงู„ู" if r['status'] == 'DAMAGED' else "โŒ MISSING / ู…ูู‚ูˆุฏ"
 
 
 
 
 
 
382
 
383
+ with st.container():
384
+ st.markdown(f"""
385
+ <div style="border: 1px solid {status_color}; border-radius: 12px; padding: 15px; margin-top: 15px; margin-bottom: 5px; background-color: rgba(255,255,255,0.02);">
386
+ <h4 style="margin: 0; color: {status_color};">{status_label}</h4>
387
+ </div>
388
+ """, unsafe_allow_html=True)
389
+
390
+ if r['status'] == 'MISSING':
391
+ c1, c2 = st.columns([1, 2])
392
+ with c1:
393
+ st.image(r['before_path'], caption=f"Before: {Path(r['before_path']).name}", use_container_width=True)
394
+ with c2:
395
+ st.warning(f"๐Ÿ” {r['damage_description']}")
396
+ else:
397
+ num_cols = 3 if (r['status'] == 'DAMAGED' and r['masked_after_path']) else 2
398
+ cols = st.columns(num_cols)
399
+
400
+ with cols[0]:
401
+ st.image(r['before_path'], caption=f"Before: {Path(r['before_path']).name}", use_container_width=True)
402
+ with cols[1]:
403
+ st.image(r['after_path'], caption=f"After: {Path(r['after_path']).name}", use_container_width=True)
404
+ if num_cols == 3:
405
+ with cols[2]:
406
+ st.image(r['masked_after_path'], caption="Grounded-SAM Mask", use_container_width=True)
407
+
408
+ st.markdown(f"**Description / ุงู„ูˆุตู:** {r['damage_description']}")
409
+
410
+ st.markdown(f"""
411
+ <div style="font-size: 0.85em; color: #888; margin-top: 5px; background-color: rgba(0,0,0,0.1); padding: 5px 10px; border-radius: 5px;">
412
+ Match Method: {r['stage_used']} | CLS Score: {r['cls_score']:.3f} |
413
+ Patch Score: {r['patch_score']:.3f} | Geo Inliers: {r['geo_inliers']} | Confidence: {r['confidence']}
414
+ </div>
415
+ """, unsafe_allow_html=True)
requirements.txt CHANGED
@@ -1,7 +1,14 @@
1
- ultralytics>=8.4.21
2
- gradio>=4.0.0
3
- opencv-python-headless
4
- pandas
5
  numpy
6
- timm
7
- https://github.com/huggingface/transformers/archive/refs/heads/main.zip
 
 
 
 
 
 
 
 
 
1
+ streamlit
2
+ python-dotenv
3
+ pillow
 
4
  numpy
5
+ opencv-python
6
+ torch
7
+ torchvision
8
+ transformers
9
+ accelerate
10
+ google-generativeai
11
+ groq
12
+ openai
13
+ huggingface-hub
14
+ rich