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(""" """, 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"""