Spaces:
Sleeping
Sleeping
| import sys | |
| import os | |
| sys.path.append(os.path.dirname(os.path.abspath(__file__))) | |
| import streamlit as st | |
| import zipfile | |
| from PIL import Image | |
| from dotenv import load_dotenv | |
| from mllm_inspector import inspect_with_gemini, inspect_with_groq, inspect_with_openai, inspect_with_openrouter | |
| from pathlib import Path | |
| # Force reload furniture_matcher to prevent Streamlit hot-reload cache issue | |
| import importlib | |
| import furniture_matcher | |
| importlib.reload(furniture_matcher) | |
| # Force reload furniture_matcher to prevent Streamlit hot-reload cache issue | |
| import importlib | |
| import furniture_matcher | |
| importlib.reload(furniture_matcher) | |
| from furniture_matcher import ( | |
| InventoryMatcher, ItemResult, collect_images, patch_similarity, | |
| CLS_CONFIRM_FOUND, CLS_CONFIRM_MISSING, PATCH_CONFIRM_FOUND, PATCH_CONFIRM_MISSING | |
| ) | |
| from report_generator import generate_combined_report | |
| # โโ Helper function defined early โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def get_category_and_type(path_or_str) -> tuple[str, str]: | |
| import re | |
| name = Path(path_or_str).stem | |
| type_val = None | |
| if name.lower().endswith('_b'): | |
| type_val = 'B' | |
| elif name.lower().endswith('_a'): | |
| type_val = 'A' | |
| clean_name = re.sub(r'_[aAbB]$', '', name) | |
| category = re.sub(r'\d+$', '', clean_name).lower().strip() | |
| return category, type_val | |
| # โโ Dynamic Prompt Patching โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| import mllm_inspector | |
| def custom_get_prompt(): | |
| return """ | |
| You are an expert property inspector. | |
| Compare these two images: the first one is the "Before" state, and the second one is the "After" state. | |
| First, verify if the item in the "Before" image and the item in the "After" image are the exact same physical instance of the object. | |
| CRITICAL INSTANCE MATCHING GUIDELINES: | |
| - Focus on the object itself: its specific patterns, unique textures, materials, shape, brand logos, or custom design details. | |
| - An object may be moved to a different location in the apartment. Therefore, the background room, flooring, or wall tiles CAN be different if the item was moved. Do not classify it as a mismatch solely due to a different background if the item itself is identical. | |
| - However, do NOT classify them as a match just because they belong to the same category. For example, two different toilet models, or two different wooden chairs with different shapes/colors are NOT a match. | |
| - Set "is_match" to true if it is the exact same physical item (even if it is now damaged, moved, rotated, or cropped). | |
| - Set "is_match" to false if they are different individual objects. | |
| Second, inspect the "After" image to see if there is any real, logical property damage compared to the "Before" state. | |
| CRITICAL DAMAGE DEFINITION & FILTERING RULES: | |
| - Only flag MAJOR, SIGNIFICANT damage that is worth noting, such as: | |
| - **Large Breakage / Smashed / Shattered / Collapsed (ูุณุฑ ูุจูุฑ / ุชุญุทูู )** | |
| - **Large Crack / Major Fracture (ุดุฑุฎ ูุจูุฑ)** | |
| - **Large Tear / Big Rip / Large Hole in fabric, upholstery, or leather (ูุทุน / ุชู ุฒู ูุจูุฑ)** | |
| - Do NOT classify minor, trivial, or normal/natural changes as damage: | |
| - **Absolutely ignore normal wear and tear (Tear & Wear).** | |
| - **Do NOT report minor scratches, tiny scuffs, minor stains, small spots, dust, or superficial scrapes.** | |
| - Waste or trash accumulation in waste bins (e.g., "Increased waste in bin" is NOT damage!). | |
| - Rearranged or moved secondary items (e.g., a bottle placed on a table, pillows shifted, sheets slightly rumpled, books adjusted). | |
| - Natural variations in lighting, camera exposure, shadow patterns, perspective distortion, or minor fabric wrinkles. | |
| - **CRITICAL KITCHEN EXCLUSION**: Do NOT detect or report any damage for kitchen utensils, kitchenware, kitchen tools, or kitchen appliances (e.g., microwave, stove, kitchen drawers, cooker, fridge, blender, dishwasher, plates, cups, kettles, etc.). Always treat kitchen-related items as completely INTACT and free of damage. | |
| - If there is no major, significant structural or material damage matching the rules above, classify the item as intact. | |
| Respond STRICTLY in JSON format with four keys: | |
| 1. "is_match": A boolean (true or false). Set to true only if they are the exact same physical object. Set to false if they are different individual objects. | |
| 2. "description": A detailed English description of the damage found. If no damage is found, state that it is intact. If they are different items (mismatch), set to "Mismatch detected: different objects." | |
| 3. "target_phrase": A short English phrase (2-4 words) describing the overall damaged parts. If no damage or mismatch, return "None". | |
| 4. "target_phrases_list": A list of highly specific, short English phrases (1-3 words each), breaking down every single damaged object separately (e.g., ["torn chair", "broken table corner", "cracked wall", "peeling paint"]). If no damage or mismatch, return []. | |
| Do not output any markdown text outside the JSON object. | |
| """ | |
| mllm_inspector.get_prompt = custom_get_prompt | |
| # Load environment variables | |
| load_dotenv(override=True) | |
| # Automatically extract before.zip and after.zip with smart path resolution | |
| def smart_extract_zip(zip_path, default_extract_to): | |
| if not os.path.exists(zip_path): | |
| return | |
| try: | |
| with zipfile.ZipFile(zip_path, 'r') as zip_ref: | |
| namelist = zip_ref.namelist() | |
| # Find the top-level directories in the zip | |
| first_parts = set() | |
| for name in namelist: | |
| parts = Path(name).parts | |
| if parts: | |
| first_parts.add(parts[0]) | |
| # If the zip already contains a folder named "before" or "after", extract to "." (root) | |
| # otherwise extract to default_extract_to | |
| if any(p in ["before", "after"] for p in first_parts): | |
| extract_to = "." | |
| else: | |
| extract_to = default_extract_to | |
| os.makedirs(extract_to, exist_ok=True) | |
| zip_ref.extractall(extract_to) | |
| print(f"Successfully extracted {zip_path} to {extract_to}") | |
| except Exception as e: | |
| print(f"Error extracting {zip_path}: {e}") | |
| smart_extract_zip("before.zip", "before") | |
| smart_extract_zip("after.zip", "after") | |
| # Configure Streamlit page | |
| st.set_page_config(page_title="Property Damage Inspector", page_icon="๐ ", layout="wide") | |
| # Custom CSS | |
| st.markdown(""" | |
| <style> | |
| .stApp { font-family: 'Inter', 'Segoe UI', sans-serif; } | |
| .stButton>button { width: 100%; border-radius: 8px; font-weight: bold; background-color: #4CAF50; color: white; } | |
| .css-1d391kg { padding-top: 2rem; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| st.title("๐ AI Property Damage Inspector") | |
| st.markdown("---") | |
| # Sidebar for configuration | |
| st.sidebar.header("โ๏ธ Settings") | |
| provider = st.sidebar.selectbox("Select AI Provider", [ | |
| "OpenRouter (Free - Llama Vision)", | |
| "Groq (Free - Fast Vision)", | |
| "Google Gemini", | |
| "OpenAI (GPT-4o)", | |
| "NVIDIA - Step 3.7 Flash", | |
| "NVIDIA - Moonshot Kimi", | |
| "NVIDIA - Gemma 4", | |
| "NVIDIA - DeepSeek v4 Pro", | |
| "NVIDIA - Qwen 3.5", | |
| "Manual Mode (Skip API)" | |
| ]) | |
| if provider in ["NVIDIA - DeepSeek v4 Pro", "NVIDIA - Qwen 3.5"]: | |
| st.sidebar.warning("โ ๏ธ **DeepSeek v4 Pro** & **Qwen 3.5** are text-only models and cannot process images. Please select **Step 3.7 Flash** or **Moonshot Kimi** for vision tasks.") | |
| # Handle API Key | |
| api_key = "" | |
| if provider != "Manual Mode (Skip API)": | |
| if "OpenRouter" in provider: | |
| api_key = os.getenv("OPENROUTER_API_KEY", "") | |
| if api_key: | |
| st.sidebar.success("โ OpenRouter API Key loaded securely.") | |
| else: | |
| api_key = st.sidebar.text_input("๐ Enter OpenRouter API Key:", type="password") | |
| elif "Groq" in provider: | |
| api_key = os.getenv("GROQ_API_KEY", "") | |
| if api_key: | |
| st.sidebar.success("โ Groq API Key loaded securely.") | |
| else: | |
| api_key = st.sidebar.text_input("๐ Enter Groq API Key:", type="password") | |
| elif "NVIDIA" in provider: | |
| api_key = st.sidebar.text_input("๐ Enter NVIDIA API Key (Optional - leaves as default):", type="password") | |
| else: | |
| api_key = st.sidebar.text_input("๐ Enter API Key:", type="password") | |
| st.sidebar.markdown("---") | |
| st.sidebar.info("๐ก **Tip:** In Manual Mode, no internet is required. The system will use your local GPU to draw masks.") | |
| app_mode = st.sidebar.radio("Select App Mode / ุงุฎุชุฑ ูุถุน ุงูุชุทุจูู", [ | |
| "๐ธ Single Image Pair Mode / ูุถุน ูุญุต ุตูุฑุฉ ู ูุฑุฏุฉ", | |
| "๐ Directory-wide Batch Mode / ูุถุน ุฌุฑุฏ ุงูู ุฌูุฏุงุช ุจุงููุงู ู" | |
| ]) | |
| st.sidebar.markdown("---") | |
| def save_optimized_image(uploaded_file, target_path, max_dim=1600): | |
| try: | |
| # Load image with PIL | |
| img = Image.open(uploaded_file) | |
| # Convert to RGB (to prevent issues saving as JPEG) | |
| if img.mode != "RGB": | |
| img = img.convert("RGB") | |
| # Get dimensions | |
| width, height = img.size | |
| if max(width, height) > max_dim: | |
| # Calculate new dimensions preserving aspect ratio | |
| if width > height: | |
| new_width = max_dim | |
| new_height = int(height * (max_dim / width)) | |
| else: | |
| new_height = max_dim | |
| new_width = int(width * (max_dim / height)) | |
| try: | |
| resample_filter = Image.Resampling.LANCZOS | |
| except AttributeError: | |
| resample_filter = Image.ANTIALIAS | |
| img = img.resize((new_width, new_height), resample=resample_filter) | |
| # Save as JPEG with good quality | |
| img.save(target_path, "JPEG", quality=85) | |
| return True | |
| except Exception as e: | |
| # Fallback to direct write if anything goes wrong | |
| uploaded_file.seek(0) | |
| with open(target_path, "wb") as f: | |
| f.write(uploaded_file.getbuffer()) | |
| return False | |
| # get_category_and_type is defined at the top level of app.py | |
| def inspect_with_nvidia(image_before_path, image_after_path, model_name, default_key, user_key=None): | |
| try: | |
| from openai import OpenAI | |
| from mllm_inspector import encode_image | |
| api_key = user_key if (user_key and user_key.strip()) else default_key | |
| client = OpenAI( | |
| base_url="https://integrate.api.nvidia.com/v1", | |
| api_key=api_key, | |
| timeout=30.0 | |
| ) | |
| base64_before = encode_image(image_before_path) | |
| base64_after = encode_image(image_after_path) | |
| extra_body = {} | |
| if model_name == "deepseek-ai/deepseek-v4-pro": | |
| extra_body = {"chat_template_kwargs": {"thinking": False}} | |
| elif model_name == "google/gemma-4-31b-it": | |
| extra_body = {"chat_template_kwargs": {"enable_thinking": True}} | |
| response = client.chat.completions.create( | |
| model=model_name, | |
| messages=[ | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "text", "text": custom_get_prompt()}, | |
| {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_before}"}}, | |
| {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_after}"}} | |
| ] | |
| } | |
| ], | |
| temperature=0.1, | |
| max_tokens=4096, | |
| extra_body=extra_body | |
| ) | |
| text_response = response.choices[0].message.content | |
| if not text_response: | |
| return None | |
| import re | |
| import json | |
| json_match = re.search(r'\{.*\}', text_response, re.DOTALL) | |
| if json_match: | |
| text_response = json_match.group(0) | |
| return json.loads(text_response) | |
| except Exception as e: | |
| print(f"\nโ Error calling NVIDIA NIM ({model_name}): {e}") | |
| return None | |
| def inspect_with_nvidia_deepseek(image_before_path, image_after_path, api_key=None): | |
| return inspect_with_nvidia( | |
| image_before_path, image_after_path, | |
| "deepseek-ai/deepseek-v4-pro", | |
| "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", | |
| api_key | |
| ) | |
| def inspect_with_nvidia_qwen(image_before_path, image_after_path, api_key=None): | |
| return inspect_with_nvidia( | |
| image_before_path, image_after_path, | |
| "qwen/qwen3.5-122b-a10b", | |
| "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", | |
| api_key | |
| ) | |
| def inspect_with_nvidia_kimi(image_before_path, image_after_path, api_key=None): | |
| return inspect_with_nvidia( | |
| image_before_path, image_after_path, | |
| "moonshotai/kimi-k2.6", | |
| "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", | |
| api_key | |
| ) | |
| def inspect_with_nvidia_stepfun(image_before_path, image_after_path, api_key=None): | |
| return inspect_with_nvidia( | |
| image_before_path, image_after_path, | |
| "stepfun-ai/step-3.7-flash", | |
| "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", | |
| api_key | |
| ) | |
| def inspect_with_nvidia_gemma(image_before_path, image_after_path, api_key=None): | |
| return inspect_with_nvidia( | |
| image_before_path, image_after_path, | |
| "google/gemma-4-31b-it", | |
| "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", | |
| api_key | |
| ) | |
| def is_kitchen_item(path_or_str) -> bool: | |
| if not path_or_str: | |
| return False | |
| cat, _ = get_category_and_type(path_or_str) | |
| kitchen_keywords = { | |
| 'kitchen', 'fridge', 'refrigerator', 'microwave', 'oven', 'stove', | |
| 'dishwasher', 'cooker', 'kettle', 'blender', 'toaster', 'utensil', | |
| 'utensils', 'plate', 'pot', 'pan', 'fork', 'spoon', 'knife', | |
| 'cup', 'glass', 'crockery', 'cutlery', 'mixer', 'juicer', | |
| 'coffee_machine', 'saucepan' | |
| } | |
| return any(kw in cat for kw in kitchen_keywords) | |
| if app_mode == "๐ธ Single Image Pair Mode / ูุถุน ูุญุต ุตูุฑุฉ ู ูุฑุฏุฉ": | |
| # Uploaders | |
| st.subheader("๐ธ Upload Images") | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.markdown("### Original Image (Before)") | |
| before_file = st.file_uploader("Upload intact image", type=["jpg", "jpeg", "png"], key="before") | |
| with col2: | |
| st.markdown("### Current Image (After)") | |
| after_file = st.file_uploader("Upload damaged image", type=["jpg", "jpeg", "png"], key="after") | |
| if before_file and after_file: | |
| # Save and optimize uploaded files temporarily | |
| os.makedirs("temp", exist_ok=True) | |
| before_path = os.path.join("temp", "before_temp.jpg") | |
| after_path = os.path.join("temp", "after_temp.jpg") | |
| save_optimized_image(before_file, before_path) | |
| save_optimized_image(after_file, after_path) | |
| # Display images side by side | |
| c1, c2 = st.columns(2) | |
| with c1: st.image(before_path, use_container_width=True) | |
| with c2: st.image(after_path, use_container_width=True) | |
| st.markdown("---") | |
| # Manual mode check | |
| manual_phrases = [] | |
| if provider == "Manual Mode (Skip API)": | |
| manual_input = st.text_input("โ๏ธ Enter damage phrases manually (comma-separated, e.g., torn chair, broken table):") | |
| if manual_input: | |
| manual_phrases = [p.strip() for p in manual_input.split(',')] | |
| if st.button("๐ Start Inspection", type="primary"): | |
| is_nvidia = "NVIDIA" in provider | |
| if provider != "Manual Mode (Skip API)" and not api_key and not is_nvidia: | |
| st.error("โ Please enter or configure your API Key first.") | |
| elif provider == "Manual Mode (Skip API)" and not manual_phrases: | |
| st.error("โ Please enter damage phrases manually.") | |
| else: | |
| target_phrases_list = [] | |
| if provider != "Manual Mode (Skip API)": | |
| with st.spinner("โณ Analyzing images using AI..."): | |
| try: | |
| if "OpenRouter" in provider: | |
| res = inspect_with_openrouter(before_path, after_path, api_key) | |
| elif "Groq" in provider: | |
| res = inspect_with_groq(before_path, after_path, api_key) | |
| elif "Gemini" in provider: | |
| res = inspect_with_gemini(before_path, after_path, api_key) | |
| elif "OpenAI" in provider: | |
| res = inspect_with_openai(before_path, after_path, api_key) | |
| elif "DeepSeek v4 Pro" in provider: | |
| res = inspect_with_nvidia_deepseek(before_path, after_path, api_key) | |
| elif "Qwen 3.5" in provider: | |
| res = inspect_with_nvidia_qwen(before_path, after_path, api_key) | |
| elif "Moonshot Kimi" in provider: | |
| res = inspect_with_nvidia_kimi(before_path, after_path, api_key) | |
| elif "Step 3.7 Flash" in provider: | |
| res = inspect_with_nvidia_stepfun(before_path, after_path, api_key) | |
| elif "Gemma 4" in provider: | |
| res = inspect_with_nvidia_gemma(before_path, after_path, api_key) | |
| else: | |
| res = None | |
| if res: | |
| # Check MLLM match confirmation | |
| is_match = res.get("is_match", True) | |
| if not is_match: | |
| st.warning("โ ๏ธ Mismatch detected / ุชู ุฑุตุฏ ุงุฎุชูุงู: The AI model thinks these two images are of different items!") | |
| st.error("โ Inspection stopped because the items do not match.") | |
| else: | |
| st.success("โ Analysis Complete!") | |
| st.markdown("### ๐ Inspection Report:") | |
| st.info(res.get("description", "")) | |
| target_phrases_list = res.get("target_phrases_list", []) | |
| st.markdown("#### ๐ฏ Detected Damage for Segmentation:") | |
| for p in target_phrases_list: | |
| st.markdown(f"- `{p}`") | |
| else: | |
| st.error("โ Failed to extract report. Check your API key or connection.") | |
| except Exception as e: | |
| st.error(f"โ Unexpected error: {e}") | |
| else: | |
| target_phrases_list = manual_phrases | |
| if target_phrases_list: | |
| with st.spinner("๐ฏ Running Grounded-SAM to precisely locate damage... Please wait..."): | |
| try: | |
| from sam_masker import generate_damage_mask | |
| output_path = os.path.join("temp", "final_result_gui.jpg") | |
| success = generate_damage_mask(after_path, target_phrases_list, output_path) | |
| if success: | |
| st.markdown("---") | |
| st.subheader("๐ Final Damage Assessment") | |
| st.image(output_path, use_container_width=True) | |
| with open(output_path, "rb") as file: | |
| st.download_button( | |
| label="๐พ Download Final Image", | |
| data=file, | |
| file_name="damage_report.jpg", | |
| mime="image/jpeg" | |
| ) | |
| st.balloons() | |
| else: | |
| st.warning("โ ๏ธ The vision model could not precisely locate the damage.") | |
| except ImportError as e: | |
| st.error(f"โ Failed to load vision library. Is lang-sam installed? Error: {e}") | |
| else: | |
| # Directory-wide Batch Mode | |
| st.subheader("๐ Directory-wide Batch Mode / ูุถุน ุฌุฑุฏ ุงูู ุฌูุฏุงุช ุจุงููุงู ู") | |
| 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.") | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| before_dir = st.text_input("๐ Before Folder Path / ู ุณุงุฑ ู ุฌูุฏ ุตูุฑ (ูุจู)", "./before") | |
| with col2: | |
| after_dir = st.text_input("๐ After Folder Path / ู ุณุงุฑ ู ุฌูุฏ ุตูุฑ (ุจุนุฏ)", "./after") | |
| # Clean zip extension if entered by the user | |
| if before_dir.endswith(".zip"): | |
| before_dir = before_dir[:-4] | |
| if after_dir.endswith(".zip"): | |
| after_dir = after_dir[:-4] | |
| # Manual mode check | |
| manual_phrases = [] | |
| if provider == "Manual Mode (Skip API)": | |
| manual_input = st.text_input("โ๏ธ Enter damage phrases manually (comma-separated, e.g., torn chair, broken table):", key="batch_manual") | |
| if manual_input: | |
| manual_phrases = [p.strip() for p in manual_input.split(',')] | |
| if st.button("๐ Start Batch Inspection / ุจุฏุก ุงููุญุต ุงูุดุงู ู", type="primary", key="start_batch"): | |
| is_nvidia = "NVIDIA" in provider | |
| if provider != "Manual Mode (Skip API)" and not api_key and not is_nvidia: | |
| st.error("โ Please enter or configure your API Key first.") | |
| elif provider == "Manual Mode (Skip API)" and not manual_phrases: | |
| st.error("โ Please enter damage phrases manually.") | |
| elif not os.path.exists(before_dir) or not os.path.exists(after_dir): | |
| st.error("โ One or both directory paths do not exist. Please check the paths.") | |
| else: | |
| results_for_report = [] | |
| os.makedirs("temp", exist_ok=True) | |
| # Instantiate the matcher | |
| progress_bar = st.progress(0.0) | |
| status_text = st.empty() | |
| status_text.text("โณ Loading DINOv2 models and initializing batch matcher...") | |
| try: | |
| matcher = InventoryMatcher() | |
| except Exception as e: | |
| st.error(f"โ Failed to load matching model: {e}") | |
| st.stop() | |
| status_text.text("Step 1/2: Running local Ensemble Matching (DINOv2 + SigLIP)...") | |
| try: | |
| results = matcher.run(before_dir, after_dir) | |
| except Exception as e: | |
| st.error(f"โ Error during local ensemble matching: {e}") | |
| st.stop() | |
| status_text.text("Step 2/2: Inspecting matched items for damage using AI...") | |
| results_for_report = [] | |
| total_items = len(results) | |
| for idx, item in enumerate(results): | |
| progress_val = min(1.0, float(idx) / total_items) | |
| progress_bar.progress(progress_val) | |
| # Check if it was matched (found) | |
| if item.found and item.best_match: | |
| b_path = item.before_path | |
| a_path = item.best_match | |
| status_text.text(f"Running damage check: {b_path.name} -> {a_path.name}...") | |
| res = None | |
| if is_kitchen_item(str(b_path)): | |
| res = { | |
| "is_match": True, | |
| "description": "Intact (Kitchen items are excluded from damage checks).", | |
| "target_phrases_list": [] | |
| } | |
| elif provider != "Manual Mode (Skip API)": | |
| try: | |
| if "OpenRouter" in provider: | |
| res = inspect_with_openrouter(str(b_path), str(a_path), api_key) | |
| elif "Groq" in provider: | |
| res = inspect_with_groq(str(b_path), str(a_path), api_key) | |
| elif "Gemini" in provider: | |
| res = inspect_with_gemini(str(b_path), str(a_path), api_key) | |
| elif "OpenAI" in provider: | |
| res = inspect_with_openai(str(b_path), str(a_path), api_key) | |
| elif "DeepSeek v4 Pro" in provider: | |
| res = inspect_with_nvidia_deepseek(str(b_path), str(a_path), api_key) | |
| elif "Qwen 3.5" in provider: | |
| res = inspect_with_nvidia_qwen(str(b_path), str(a_path), api_key) | |
| elif "Moonshot Kimi" in provider: | |
| res = inspect_with_nvidia_kimi(str(b_path), str(a_path), api_key) | |
| elif "Step 3.7 Flash" in provider: | |
| res = inspect_with_nvidia_stepfun(str(b_path), str(a_path), api_key) | |
| elif "Gemma 4" in provider: | |
| res = inspect_with_nvidia_gemma(str(b_path), str(a_path), api_key) | |
| except Exception as api_err: | |
| st.warning(f"โ ๏ธ API error for {b_path.name}: {api_err}") | |
| else: | |
| res = { | |
| "is_match": True, | |
| "description": f"Manual inspection checking for: {', '.join(manual_phrases)}", | |
| "target_phrases_list": manual_phrases | |
| } | |
| status = "INTACT" | |
| damage_desc = "Intact (inspection skipped/failed)." | |
| target_phrases = [] | |
| masked_after_path = None | |
| if res: | |
| target_phrases = res.get("target_phrases_list", []) | |
| target_phrases = [p.strip() for p in target_phrases if p and p.strip().lower() != "none"] | |
| status = "INTACT" | |
| damage_desc = res.get("description", "Intact. No major damage detected.") | |
| if target_phrases: | |
| status = "DAMAGED" | |
| damage_desc = res.get("description", "Damage detected.") | |
| masked_out_path = os.path.join("temp", f"masked_{a_path.name}") | |
| from sam_masker import generate_damage_mask | |
| mask_success = generate_damage_mask(str(a_path), target_phrases, masked_out_path) | |
| if mask_success: | |
| masked_after_path = masked_out_path | |
| results_for_report.append({ | |
| 'before_path': str(b_path), | |
| 'after_path': str(a_path), | |
| 'status': status, | |
| 'cls_score': item.cls_score, | |
| 'siglip_score': item.siglip_score, | |
| 'fused_score': item.fused_score, | |
| 'patch_score': item.patch_score, | |
| 'geo_inliers': item.geo_inliers, | |
| 'confidence': item.confidence, | |
| 'stage_used': item.stage_used, | |
| 'damage_description': damage_desc, | |
| 'target_phrases_list': target_phrases, | |
| 'masked_after_path': masked_after_path | |
| }) | |
| else: | |
| # Missing item | |
| results_for_report.append({ | |
| 'before_path': str(item.before_path), | |
| 'after_path': None, | |
| 'status': "MISSING", | |
| 'cls_score': 0.0, | |
| 'siglip_score': 0.0, | |
| 'fused_score': 0.0, | |
| 'patch_score': 0.0, | |
| 'geo_inliers': 0, | |
| 'confidence': "โ", | |
| 'stage_used': 1, | |
| 'damage_description': "Item from before inventory was not found in the after inventory.", | |
| 'target_phrases_list': [], | |
| 'masked_after_path': None | |
| }) | |
| # Detect Added Items (exist in after folder but were never matched) | |
| matched_after_paths = set() | |
| for r in results_for_report: | |
| if r['status'] in ['INTACT', 'DAMAGED'] and r['after_path']: | |
| matched_after_paths.add(os.path.abspath(r['after_path'])) | |
| try: | |
| from furniture_matcher import collect_images | |
| all_after_files = collect_images(after_dir) | |
| all_after_paths = [os.path.abspath(str(p)) for p in all_after_files] | |
| except Exception: | |
| all_after_paths = [] | |
| for after_file_path in all_after_paths: | |
| if after_file_path not in matched_after_paths: | |
| results_for_report.append({ | |
| 'before_path': None, | |
| 'after_path': after_file_path, | |
| 'status': 'ADDED', | |
| 'cls_score': 0.0, | |
| 'siglip_score': 0.0, | |
| 'fused_score': 0.0, | |
| 'patch_score': 0.0, | |
| 'geo_inliers': 0, | |
| 'confidence': 'โ', | |
| 'stage_used': 0, | |
| 'damage_description': "New item added in the after inventory (not present in the before inventory). / ุชู ุฑุตุฏ ุนูุตุฑ ุฌุฏูุฏ ู ุถุงู ูู ุตูุฑ ุงูุจุนุฏ ูู ููู ู ูุฌูุฏุงู ูู ุตูุฑ ุงููุจู.", | |
| 'target_phrases_list': [], | |
| 'masked_after_path': None | |
| }) | |
| st.session_state.batch_results = results_for_report | |
| status_text.success("๐ Batch Inspection Completed successfully / ุงูุชู ู ุงููุญุต ุงูุดุงู ู ุจูุฌุงุญ!") | |
| st.balloons() | |
| # Results dashboard | |
| if "batch_results" in st.session_state: | |
| st.markdown("---") | |
| st.header("๐ Inspection Results / ูุชุงุฆุฌ ุงููุญุต") | |
| n_total = len(st.session_state.batch_results) | |
| n_intact = sum(1 for r in st.session_state.batch_results if r['status'] == 'INTACT') | |
| n_damaged = sum(1 for r in st.session_state.batch_results if r['status'] == 'DAMAGED') | |
| n_missing = sum(1 for r in st.session_state.batch_results if r['status'] == 'MISSING') | |
| n_added = sum(1 for r in st.session_state.batch_results if r['status'] == 'ADDED') | |
| col_stat1, col_stat2, col_stat3, col_stat4, col_stat5 = st.columns(5) | |
| with col_stat1: | |
| st.metric("Total Items / ุฅุฌู ุงูู ุงูุนูุงุตุฑ", n_total) | |
| with col_stat2: | |
| st.metric("Intact / ุณููู ", n_intact) | |
| with col_stat3: | |
| st.metric("Damaged / ุชุงูู", n_damaged) | |
| with col_stat4: | |
| st.metric("Missing / ู ูููุฏ", n_missing) | |
| with col_stat5: | |
| st.metric("Added / ู ุถุงู", n_added) | |
| # Download Button for the Report | |
| try: | |
| report_path = generate_combined_report(st.session_state.batch_results, "temp") | |
| with open(report_path, "r", encoding="utf-8") as f: | |
| html_content = f.read() | |
| st.download_button( | |
| label="๐พ Download Combined HTML Report / ุชุญู ูู ุงูุชูุฑูุฑ ุงูุดุงู ู HTML", | |
| data=html_content, | |
| file_name="inventory_damage_report.html", | |
| mime="text/html", | |
| key="download_report" | |
| ) | |
| except Exception as report_err: | |
| st.error(f"Error generating report: {report_err}") | |
| st.markdown("### Detailed Items / ุชูุงุตูู ุงูุนูุงุตุฑ") | |
| filter_status = st.selectbox("Filter by Status / ุชุตููุฉ ุญุณุจ ุงูุญุงูุฉ", ["All / ุงููู", "Intact / ุณููู ", "Damaged / ุชุงูู", "Missing / ู ูููุฏ", "Added / ู ุถุงู"], key="filter_status") | |
| status_map = { | |
| "All / ุงููู": "ALL", | |
| "Intact / ุณููู ": "INTACT", | |
| "Damaged / ุชุงูู": "DAMAGED", | |
| "Missing / ู ูููุฏ": "MISSING", | |
| "Added / ู ุถุงู": "ADDED" | |
| } | |
| selected_status = status_map[filter_status] | |
| for idx, r in enumerate(st.session_state.batch_results): | |
| if selected_status != "ALL" and r['status'] != selected_status: | |
| continue | |
| status_color = "#10b981" if r['status'] == 'INTACT' else "#f59e0b" if r['status'] == 'DAMAGED' else "#ef4444" if r['status'] == 'MISSING' else "#3b82f6" | |
| status_label = "โ INTACT / ุณููู " if r['status'] == 'INTACT' else "โ ๏ธ DAMAGED / ุชุงูู" if r['status'] == 'DAMAGED' else "โ MISSING / ู ูููุฏ" if r['status'] == 'MISSING' else "โ ADDED / ู ุถุงู" | |
| with st.container(): | |
| st.markdown(f""" | |
| <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);"> | |
| <h4 style="margin: 0; color: {status_color};">{status_label}</h4> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if r['status'] == 'MISSING': | |
| c1, c2 = st.columns([1, 2]) | |
| with c1: | |
| st.image(r['before_path'], caption=f"Before: {Path(r['before_path']).name}", use_container_width=True) | |
| with c2: | |
| st.warning(f"๐ {r['damage_description']}") | |
| elif r['status'] == 'ADDED': | |
| c1, c2 = st.columns([1, 2]) | |
| with c1: | |
| st.image(r['after_path'], caption=f"Added: {Path(r['after_path']).name}", use_container_width=True) | |
| with c2: | |
| st.info(f"โ {r['damage_description']}") | |
| else: | |
| num_cols = 3 if (r['status'] == 'DAMAGED' and r['masked_after_path']) else 2 | |
| cols = st.columns(num_cols) | |
| with cols[0]: | |
| st.image(r['before_path'], caption=f"Before: {Path(r['before_path']).name}", use_container_width=True) | |
| with cols[1]: | |
| st.image(r['after_path'], caption=f"After: {Path(r['after_path']).name}", use_container_width=True) | |
| if num_cols == 3: | |
| with cols[2]: | |
| st.image(r['masked_after_path'], caption="Grounded-SAM Mask", use_container_width=True) | |
| st.markdown(f"**Description / ุงููุตู:** {r['damage_description']}") | |
| st.markdown(f""" | |
| <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;"> | |
| Match Method: {r['stage_used']} | CLS Score: {r['cls_score']:.3f} | | |
| SigLIP Score: {r.get('siglip_score', 0.0):.3f} | Fused Score: {r.get('fused_score', 0.0):.3f} | | |
| Patch Score: {r['patch_score']:.3f} | Geo Inliers: {r['geo_inliers']} | Confidence: {r['confidence']} | |
| </div> | |
| """, unsafe_allow_html=True) | |