Dr-Swopt 3 giorni fa
parent
commit
ec09741f95
3 ha cambiato i file con 169 aggiunte e 54 eliminazioni
  1. BIN
      YOLOv5-Detection.pt
  2. 122 52
      demo_app.py
  3. 47 2
      src/api/main.py

BIN
YOLOv5-Detection.pt


+ 122 - 52
demo_app.py

@@ -16,32 +16,106 @@ from fpdf import FPDF
 
 @st.dialog("📘 AI Interpretation Guide")
 def show_tech_guide():
-    st.write("### 🎯 What does 'Confidence' mean?")
+    st.write("### 🧠 1. The 'Thinking' Phase: The Raw Tensor [1, 300, 6]")
     st.write("""
-    This is a probability score from **0.0 to 1.0**. 
-    - **0.90+**: The AI is nearly certain this is a bunch of this grade.
-    - **0.25 (Threshold)**: We ignore anything below this to filter out 'ghost' detections or background noise.
+    When the AI 'thinks' about an image, it doesn't see 'Ripe' or 'Unripe'. It populates a 
+    fixed-size memory buffer (Tensor) with **300 potential candidates**. Each candidate is 
+    represented by a row of 6 numbers.
     """)
     
-    st.write("### 🛠️ The Raw Mathematical Tensor")
-    st.write("The AI returns a raw array of shape `[1, 300, 6]`. Here is the key:")
+    
+    
     st.table({
-        "Index": ["0-3", "4", "5"],
-        "Meaning": ["Coordinates (x1, y1, x2, y2)", "Confidence Score", "Class ID (0-5)"],
-        "Reality": ["The 'Box' in the image.", "The AI's certainty.", "The Ripeness Grade."]
+        "Tensor Index": ["0, 1, 2, 3", "4", "5"],
+        "AI Output": ["Coordinates", "Confidence Score", "Class ID"],
+        "Programmer's Logic": ["`[x1, y1, x2, y2]`", "`float (0.0 - 1.0)`", "`int (0-5)`"]
     })
+
+    st.write("#### 🎯 The Coordinate Paradox (Pixels vs. Ratios)")
+    st.write("""
+    Depending on the engine, the **Values at Index 0-3** speak different languages. 
+    This is why the raw numbers won't match if you swap engines:
+    """)
     
-    st.write("### ⚡ Inference vs. Processing Time")
+    col_a, col_b = st.columns(2)
+    with col_a:
+        st.info("**PyTorch Pathway (.pt)**")
+        st.write("- **Format**: Absolute Pixels")
+        st.write("- **Logic**: The AI outputs numbers mapped to the photo's resolution (e.g., `245.0`).")
+    with col_b:
+        st.success("**ONNX Pathway (.onnx)**")
+        st.write("- **Format**: Normalized Ratios")
+        st.write("- **Logic**: The AI outputs percentages (0.0 to 1.0) relative to its internal 640x640 grid (e.g., `0.38`).")
+
+    st.write("---")
+    st.write("### 🎯 2. What is 'Confidence'? (The Probability Filter)")
     st.write("""
-    - **Inference Speed**: The time the AI model took to 'think' about the pixels.
-    - **Total Time**: Includes image uploading and database saving overhead.
+    Confidence is the AI's **mathematical certainty** that an object exists in a specific box. 
+    It is the product of *Objectness* (Is something there?) and *Class Probability* (What is it?).
     """)
-    st.info("💡 **Engine Note**: ONNX is optimized for latency (~39ms), while PyTorch offers native indicator flexibility.")
+    
+    st.table({
+        "Confidence Value": ["> 0.90", "0.50 - 0.89", "< 0.25 (Threshold)"],
+        "Interpretation": ["**Certain**: Clear, unobstructed view.", "**Likely**: Valid, but possibly obscured by fronds.", "**Noise**: Discarded to prevent False Positives."]
+    })
+    
+    
 
+    st.write("---")
+    st.write("### 🛠️ 3. The Custom Handler (The Translation Layer)")
+    st.write("""
+    Because ONNX returns raw ratios, we built a **Manual Scaling Handler**. It maps those 
+    `0.0 - 1.0` values back to your high-resolution photo pixels.
+    
+    This explains our two key metrics:
+    - **Inference Speed**: The time the AI spent populating the Raw Tensor.
+    - **Post-Processing**: The time our code spent 'translating' that Tensor into labels and pixels.
+    """)
+
+    st.write("---")
+    st.write("### 🧠 4. Model Benchmarking: YOLO26 vs YOLOv5")
+    st.write("""
+    We have included **YOLOv5** as an industry-standard baseline to validate the performance of our custom **YOLO26** model.
+    
+    **Key Comparison Points:**
+    - **NMS-Free Architecture**: YOLO26 is designed to be NMS-Free, meaning it doesn't require a secondary 'cleaning' step to remove overlapping boxes, making it more efficient in post-processing.
+    - **Accuracy vs. Latency**: While YOLOv5 is a robust general-purpose model, YOLO26 is fine-tuned specifically for Palm Oil FFB features, providing superior ripeness grading.
+    - **Real-Time Efficiency**: By comparing the 'Inference Speed' and 'Post-Processing' metrics, you can see how the architectural differences impact real-world performance.
+    """)
 
 # --- 1. Global Backend Check ---
 API_BASE_URL = "http://localhost:8000"
 
+# MPOB Color Map for Overlays (Global for consistency)
+overlay_colors = {
+    'Ripe': '#22c55e',       # Industrial Green
+    'Underripe': '#fbbf24',  # Industrial Orange
+    'Unripe': '#3b82f6',     # Industrial Blue
+    'Abnormal': '#dc2626',   # Critical Red
+    'Empty_Bunch': '#64748b',# Waste Gray
+    'Overripe': '#7c2d12'    # Dark Brown/Orange
+}
+
+# Helper to reset results when files change or engine switches
+def reset_single_results():
+    st.session_state.last_detection = None
+
+def reset_batch_results():
+    st.session_state.last_batch_results = None
+
+def reset_all_analysis():
+    """Global reset for all active analysis views."""
+    st.session_state.last_detection = None
+    st.session_state.last_batch_results = None
+    # Increment uploader keys to 'forget' current files (Clear Canvas)
+    if "single_uploader_key" not in st.session_state:
+        st.session_state.single_uploader_key = 0
+    st.session_state.single_uploader_key += 1
+    
+    if "batch_uploader_key" not in st.session_state:
+        st.session_state.batch_uploader_key = 0
+    st.session_state.batch_uploader_key += 1
+
 def check_backend():
     try:
         res = requests.get(f"{API_BASE_URL}/get_confidence", timeout=2)
@@ -93,43 +167,26 @@ st.sidebar.slider(
 )
 
 st.sidebar.markdown("---")
-st.sidebar.subheader("Inference Engine")
+# Inference Engine
 engine_choice = st.sidebar.selectbox(
     "Select Model Engine",
-    ["YOLO26 (PyTorch - Native)", "YOLO26 (ONNX - High Speed)"],
+    ["YOLO26 (PyTorch - Native)", "YOLO26 (ONNX - High Speed)", "YOLOv5 (Benchmarking Mode)"],
     index=0,
-    help="ONNX is optimized for latency. PyTorch provides native object handling."
+    help="ONNX is optimized for latency. PyTorch provides native object handling. YOLOv5 is the benchmark baseline.",
+    on_change=reset_all_analysis # Clear canvas on engine switch
 )
 st.sidebar.markdown("---")
-st.sidebar.subheader("🛠️ Technical Controls")
-show_trace = st.sidebar.toggle("🔬 Show Technical Trace", value=False, help="Enable to see raw mathematical tensor data alongside AI labels.")
-st.session_state.tech_trace = show_trace
-model_type = "onnx" if "ONNX" in engine_choice else "pytorch"
-if model_type == "pytorch":
-    st.sidebar.warning("PyTorch Engine: Higher Memory Usage")
+if "ONNX" in engine_choice:
+    model_type = "onnx"
+elif "YOLOv5" in engine_choice:
+    model_type = "yolov5"
 else:
-    st.sidebar.info("ONNX Engine: ~39ms Latency")
+    model_type = "pytorch"
 
-st.sidebar.markdown("---")
 if st.sidebar.button("❓ How to read results?", icon="📘", width='stretch'):
     show_tech_guide()
 
-# Helper to reset results when files change
-def reset_single_results():
-    st.session_state.last_detection = None
-
-def reset_batch_results():
-    st.session_state.last_batch_results = None
-
-# MPOB Color Map for Overlays (Global for consistency)
-overlay_colors = {
-    'Ripe': '#22c55e',       # Industrial Green
-    'Underripe': '#fbbf24',  # Industrial Orange
-    'Unripe': '#3b82f6',     # Industrial Blue
-    'Abnormal': '#dc2626',   # Critical Red
-    'Empty_Bunch': '#64748b',# Waste Gray
-    'Overripe': '#7c2d12'    # Dark Brown/Orange
-}
+# Function definitions moved to top
 
 def display_interactive_results(image, detections, key=None):
     """Renders image with interactive hover-boxes using Plotly."""
@@ -169,7 +226,7 @@ def display_interactive_results(image, detections, key=None):
         ))
 
     fig.update_layout(width=800, height=600, margin=dict(l=0, r=0, b=0, t=0), showlegend=False)
-    st.plotly_chart(fig, use_container_width=True, key=key)
+    st.plotly_chart(fig, width='stretch', key=key)
 
 def annotate_image(image, detections):
     """Draws high-visibility 'Plated Labels' and boxes on the image."""
@@ -333,10 +390,14 @@ tab1, tab2, tab3, tab4 = st.tabs(["Single Analysis", "Batch Processing", "Simila
 # --- Tab 1: Single Analysis ---
 with tab1:
     st.subheader("Analyze Single Bunch")
+    # 1. Initialize Uploader Key
+    if "single_uploader_key" not in st.session_state:
+        st.session_state.single_uploader_key = 0
+
     uploaded_file = st.file_uploader(
         "Upload a bunch image...", 
         type=["jpg", "jpeg", "png"], 
-        key="single",
+        key=f"single_{st.session_state.single_uploader_key}",
         on_change=reset_single_results
     )
     
@@ -353,12 +414,20 @@ with tab1:
                 res = requests.post(f"{API_BASE_URL}/analyze", files=files, data=payload)
                 if res.status_code == 200:
                     st.session_state.last_detection = res.json()
+                    detections = st.session_state.last_detection.get('detections', [])
+                    if model_type == "yolov5" and not detections:
+                        st.warning("⚠️ YOLOv5 Benchmarking model failed to load on the backend. Results are empty.")
                     st.rerun() # Refresh to show results immediately
                 else:
                     st.error(f"Detection Failed: {res.text}")
 
         # 2. Results Layout
         if st.session_state.last_detection:
+            # Redo Button at the top for easy access
+            if st.button("🔄 Re-analyze Image", width='stretch', type="primary", help="Force a fresh detection (useful if threshold changed)."):
+                st.session_state.last_detection = None
+                st.rerun()
+                
             data = st.session_state.last_detection
             st.divider()
             
@@ -397,15 +466,15 @@ with tab1:
             col1, col2 = st.columns([1.5, 1]) # Keep original col structure for summary below
             
             with col1:
-                col_tech_h1, col_tech_h2 = st.columns([4, 1])
+                col_tech_h1, col_tech_h2 = st.columns([1, 1])
                 with col_tech_h1:
                     st.write("#### 🛠️ Technical Evidence")
                 with col_tech_h2:
-                    if st.button("❓ Guide", key="guide_tab1"):
-                        show_tech_guide()
+                    st.session_state.tech_trace = st.toggle("🔬 Side-by-Side Trace", value=st.session_state.get('tech_trace', False))
                 
                 with st.expander("Raw Output Tensor (NMS-Free)", expanded=False):
-                    st.caption("See the Interpretation Guide for a breakdown of these numbers.")
+                    coord_type = "Absolute Pixels" if model_type == "pytorch" else "Normalized Ratios (0.0-1.0)"
+                    st.warning(f"Engine detected: {model_type.upper()} | Coordinate System: {coord_type}")
                     st.json(data.get('raw_array_sample', []))
                 with st.container(border=True):
                     st.write("### 🏷️ Detection Results")
@@ -717,13 +786,13 @@ with tab4:
                     # Prepare searchable dataframe
                     df_history = pd.DataFrame(history_data)
                     # Clean up for display
-                    display_df = df_history[['id', 'timestamp', 'filename', 'inference_ms']].copy()
-                    display_df.columns = ['ID', 'Date/Time', 'Filename', 'Inference (ms)']
+                    display_df = df_history[['id', 'timestamp', 'engine', 'filename', 'inference_ms']].copy()
+                    display_df.columns = ['ID', 'Date/Time', 'Engine', 'Filename', 'Inference (ms)']
                     
                     st.dataframe(
                         display_df, 
                         hide_index=True, 
-                        use_container_width=True,
+                        width='stretch',
                         column_config={
                             "ID": st.column_config.NumberColumn(width="small"),
                             "Inference (ms)": st.column_config.NumberColumn(format="%.1f ms")
@@ -740,7 +809,7 @@ with tab4:
                         )
                     with hist_col2:
                         st.write("##") # Alignment
-                        if st.button("🔬 Start Deep Dive", type="primary", use_container_width=True):
+                        if st.button("🔬 Start Deep Dive", type="primary", width='stretch'):
                             st.session_state.selected_history_id = target_id
                             st.rerun()
                 else:
@@ -756,7 +825,8 @@ with tab4:
                         
                         st.divider()
                         st.write(f"## 🔍 Deep Dive: Record #{record['id']}")
-                        st.caption(f"Original Filename: `{record['filename']}` | Processed: `{record['timestamp']}`")
+                        engine_val = record.get('engine', 'Unknown')
+                        st.caption(f"Original Filename: `{record['filename']}` | Processed: `{record['timestamp']}` | Engine: `{engine_val.upper()}`")
                         
                         detections = json.loads(record['detections'])
                         summary = json.loads(record['summary'])
@@ -783,7 +853,7 @@ with tab4:
                                 display_interactive_results(hist_img, detections, key=f"hist_plotly_{record['id']}")
                             with v_tab2:
                                 img_plate = annotate_image(hist_img.copy(), detections)
-                                st.image(img_plate, use_container_width=True, caption="Point-of-Harvest AI Interpretation")
+                                st.image(img_plate, width='stretch', caption="Point-of-Harvest AI Interpretation")
                         else:
                             st.warning(f"Technical Error: Archive file missing at `{record['archive_path']}`")
                         

+ 47 - 2
src/api/main.py

@@ -32,12 +32,20 @@ def init_local_db():
             archive_path TEXT,
             detections TEXT,
             summary TEXT,
+            engine TEXT,
             inference_ms REAL,
             processing_ms REAL,
             raw_tensor TEXT,
             timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
         )
     ''')
+    # Migration: Add engine column if it doesn't exist (for existing DBs)
+    try:
+        cursor.execute("ALTER TABLE history ADD COLUMN engine TEXT")
+        print("Migrated History table: Added 'engine' column.")
+    except sqlite3.OperationalError:
+        # Column already exists
+        pass
     conn.commit()
     conn.close()
     print("Local DB Initialized.")
@@ -57,6 +65,12 @@ class ModelManager:
         self.onnx_session = ort.InferenceSession(onnx_path)
         self.onnx_input_name = self.onnx_session.get_inputs()[0].name
         self.pt_model = YOLO(pt_path)
+        try:
+            self.v5_model = YOLO('YOLOv5-Detection.pt')
+            print("YOLOv5 model loaded successfully.")
+        except Exception as e:
+            print(f"Warning: YOLOv5-Detection.pt could not be loaded via Ultralytics (Compatibility issue). Details: {e}")
+            self.v5_model = None
         self.class_names = self.pt_model.names
 
     def preprocess_onnx(self, img: Image.Image):
@@ -133,6 +147,34 @@ class ModelManager:
         raw_snippet = results[0].boxes.data[:5].tolist() if len(results[0].boxes) > 0 else []
         return detections, raw_snippet, inference_ms
 
+    def run_v5_inference(self, img: Image.Image, conf_threshold: float):
+        if self.v5_model is None:
+            # Fallback for benchmarking if model is missing
+            print("Logic: Skipping YOLOv5 inference - model not loaded.")
+            return [], [], 0.0
+
+        import time
+        start_inf = time.perf_counter()
+        results = self.v5_model(img, conf=conf_threshold, verbose=False)
+        end_inf = time.perf_counter()
+        inference_ms = (end_inf - start_inf) * 1000
+
+        detections = []
+        for i, box in enumerate(results[0].boxes):
+            class_id = int(box.cls)
+            class_name = self.class_names.get(class_id, "Unknown")
+            detections.append({
+                "bunch_id": i + 1,
+                "class": class_name,
+                "confidence": round(float(box.conf), 2),
+                "is_health_alert": class_name in ["Abnormal", "Empty_Bunch"],
+                "box": box.xyxy.tolist()[0]
+            })
+        
+        # Extract snippet from results (simulating raw output)
+        raw_snippet = results[0].boxes.data[:5].tolist() if len(results[0].boxes) > 0 else []
+        return detections, raw_snippet, inference_ms
+
 model_manager = ModelManager(onnx_path='best.onnx', pt_path='best.pt')
 
 
@@ -186,6 +228,8 @@ async def analyze_with_health_metrics(file: UploadFile = File(...), model_type:
     # Select Inference Engine
     if model_type == "pytorch":
         detections, raw_sample, inference_ms = model_manager.run_pytorch_inference(img, current_conf)
+    elif model_type == "yolov5":
+        detections, raw_sample, inference_ms = model_manager.run_v5_inference(img, current_conf)
     else:
         detections, raw_sample, inference_ms = model_manager.run_onnx_inference(img, current_conf)
     
@@ -210,8 +254,8 @@ async def analyze_with_health_metrics(file: UploadFile = File(...), model_type:
     # Save to SQLite
     conn = sqlite3.connect(DB_PATH)
     cursor = conn.cursor()
-    cursor.execute("INSERT INTO history (filename, archive_path, detections, summary, inference_ms, processing_ms, raw_tensor) VALUES (?, ?, ?, ?, ?, ?, ?)",
-                   (file.filename, archive_path, json.dumps(detections), json.dumps(summary), inference_ms, processing_ms, json.dumps(raw_sample)))
+    cursor.execute("INSERT INTO history (filename, archive_path, detections, summary, engine, inference_ms, processing_ms, raw_tensor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+                   (file.filename, archive_path, json.dumps(detections), json.dumps(summary), model_type, inference_ms, processing_ms, json.dumps(raw_sample)))
     conn.commit()
     conn.close()
             
@@ -291,6 +335,7 @@ async def process_batch(files: List[UploadFile] = File(...), model_type: str = F
                 batch_results.append({
                     "path": path,
                     "yolo": det,
+                    "engine": model_type, # Track engine
                     "inference_ms": inference_ms,
                     "raw_array_sample": raw_sample
                 })