diff --git a/GUI/requirements.txt b/GUI/requirements.txt index 779d432a9..7c9c1f7ba 100644 --- a/GUI/requirements.txt +++ b/GUI/requirements.txt @@ -38,6 +38,9 @@ plotly shapely PyJWT>=2.9.0 sip +Pillow +boto3 +botocore SQLAlchemy>=2.0 geoalchemy2>=0.15.0 diff --git a/GUI/src/vast/main_window.py b/GUI/src/vast/main_window.py index 726b129c6..5107464e4 100644 --- a/GUI/src/vast/main_window.py +++ b/GUI/src/vast/main_window.py @@ -928,6 +928,8 @@ from views.fruits_view import FruitsView from dashboard_api import DashboardApi from vast.alerts.alert_service import AlertService +from views.leaves_dashboard import LeafDiseaseView +from views.leaves_view import LeafView @@ -1140,7 +1142,7 @@ def reposition_badge(): self.nav_list.setFont(font) for name in [ - "Home", "Sensors", "Sound", "Ground Image", + "Home", "Sensors", "Sound", "Ground Image", "Leaves", "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Irrigation" ]: QListWidgetItem(f" {name}", self.nav_list) @@ -1180,7 +1182,8 @@ def reposition_badge(): self.fruits_view = FruitsView(api, self) self.ground_view = GroundView(api, self) self.auth_status = AuthStatusView(api, self) - + self.leaves_view = LeafView(self.api, self) + self.leaves_dashboard =LeafDiseaseView(self.api, self) self.sensors_status_summary = SensorsStatusSummary(api, self) self.sensors_health = SensorsView(api, self) self.sensors_main = SensorsMainView(api, self) @@ -1203,6 +1206,8 @@ def reposition_badge(): "Notifications": self.notification_view, "Security": self.security_view, "Fruits": self.fruits_view, + "Leaves - Leaves View": self.leaves_view, + "Leaves - Leaves Dashboard": self.leaves_dashboard, "Ground Image": self.ground_view, "Irrigation": self.irrigation_view, "Auth": self.auth_status diff --git a/GUI/src/vast/views/leaf_diseases.py b/GUI/src/vast/views/leaves_dashboard.py similarity index 99% rename from GUI/src/vast/views/leaf_diseases.py rename to GUI/src/vast/views/leaves_dashboard.py index 7b9fe59fb..5f96a0cc9 100644 --- a/GUI/src/vast/views/leaf_diseases.py +++ b/GUI/src/vast/views/leaves_dashboard.py @@ -1,4 +1,4 @@ -# leaf_diseases.py (compact, fixed + Grafana integration) +# leaves_dashboard.py (compact, fixed + Grafana integration) # UI: English, Comments: English, 4 spaces indent. from __future__ import annotations diff --git a/GUI/src/vast/views/leaves_view.py b/GUI/src/vast/views/leaves_view.py new file mode 100644 index 000000000..d8a3aeaeb --- /dev/null +++ b/GUI/src/vast/views/leaves_view.py @@ -0,0 +1,1332 @@ +# ...existing code... +from __future__ import annotations +import os, io, re, json +from dataclasses import dataclass +from typing import List, Dict, Tuple, Optional, Any +from contextlib import contextmanager +from urllib.parse import urlparse, unquote + +# from PIL import Image +from PIL import Image, ImageDraw + +from PIL.ImageQt import ImageQt +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError +import requests + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, + QScrollArea, QGridLayout, QFrame, QDialog, QDialogButtonBox, QFormLayout, QComboBox, QMessageBox, QTableWidgetItem ,QTableWidget +) +# from PyQt6.QtGui import QPixmap, QImage +from PyQt6.QtGui import QPixmap, QImage, QPainter, QPen, QColor + +from PyQt6.QtCore import Qt, QTimer, QRunnable, QThreadPool, pyqtSignal, QObject + +# ---------------- worker utils ---------------- +class WorkerSignals(QObject): + finished = pyqtSignal(object) + error = pyqtSignal(tuple) + +class Worker(QRunnable): + def __init__(self, fn, *args, **kwargs): + super().__init__() + self.fn = fn + self.args = args + self.kwargs = kwargs + self.signals = WorkerSignals() + self._anomalies_by_key: dict[str, dict] = {} + + def run(self): + try: + res = self.fn(*self.args, **self.kwargs) + self.signals.finished.emit(res) + except Exception as e: + import traceback + self.signals.error.emit((e, traceback.format_exc())) + +# ---------------- data types ---------------- +@dataclass +class LeafDet: + det_index: int + bbox: Tuple[int,int,int,int] + crop_key: Optional[str] = None + status: str = "healthy" + anomaly_severity: Optional[float] = None + anomaly_id: Optional[int] = None + details_snippet: Optional[str] = None + score: Optional[float] = None + match_method: Optional[str] = None + disease_name: Optional[str] = None + +COLOR_BY_STATUS = {"sick": (255, 0, 0), "suspect": (255, 215, 0), "healthy": (0, 180, 0)} +CROP_DET_RE = re.compile(r".*?_det(?P\d{3})_cls", re.I) + +# ---------------- helper functions (ported) ---------------- +def _to_int4(x1, y1, x2, y2): + return int(round(x1)), int(round(y1)), int(round(x2)), int(round(y2)) + +def _clip_box(x1, y1, x2, y2, w, h): + x1 = max(0, min(x1, w-1)) + y1 = max(0, min(y1, h-1)) + x2 = max(0, min(x2, w-1)) + y2 = max(0, min(y2, h-1)) + return x1, y1, x2, y2 + +def compute_boxes(meta: Any, img_w: int, img_h: int, original_fname_candidates: List[str]) -> Tuple[List[Tuple[int,int,int,int]], List[Optional[float]]]: + boxes: List[Tuple[int,int,int,int]] = [] + scores: List[Optional[float]] = [] + + if isinstance(meta, dict) and isinstance(meta.get("boxes"), list): + for rec in meta["boxes"]: + if isinstance(rec, (list, tuple)) and len(rec) >= 4: + x1, y1, x2, y2 = map(float, rec[:4]) + sc = float(rec[4]) if len(rec) >= 5 else None + x1, y1, x2, y2 = _clip_box(x1, y1, x2, y2, img_w, img_h) + boxes.append(_to_int4(x1, y1, x2, y2)) + scores.append(sc) + return boxes, scores + + def _push_xyxy(vals): + x1, y1, x2, y2 = map(float, vals) + x1, y1, x2, y2 = _clip_box(x1, y1, x2, y2, img_w, img_h) + boxes.append(_to_int4(x1, y1, x2, y2)) + scores.append(None) + + if isinstance(meta, list): + for it in meta: + if isinstance(it, (list, tuple)) and len(it) == 4: + _push_xyxy(it) + elif isinstance(it, dict) and "bbox" in it and len(it["bbox"]) == 4: + _push_xyxy(it["bbox"]) + return boxes, scores + + if isinstance(meta, dict): + for key in ("detections","objects","leaves","boxes","predictions","items"): + if isinstance(meta.get(key), list): + for it in meta[key]: + if isinstance(it, (list, tuple)) and len(it) == 4: + _push_xyxy(it) + elif isinstance(it, dict) and "bbox" in it and len(it["bbox"]) == 4: + _push_xyxy(it["bbox"]) + return boxes, scores + + return boxes, scores + +def normalize_to_first_jpg(fname: str) -> str: + m = re.search(r'(?i)\.jpe?g', fname) + if not m: + return fname + return fname[:m.end()] + +def candidate_original_names(selected_basename: str) -> List[str]: + n1 = normalize_to_first_jpg(selected_basename) + stem_full = os.path.splitext(selected_basename)[0] + seen, out = set(), [] + for cand in [n1, stem_full]: + if cand not in seen: + seen.add(cand) + out.append(cand) + return out + +def pwb_json_key_for(original_fname: str, pwb_date: str) -> str: + return f"leaves/{pwb_date}/pwb/json/{original_fname}.json" + + +def _canonical_minio_id(raw: str, default_bucket: Optional[str] = None) -> Optional[str]: + """ + הופך minio_key/minio_url לכלל מזהה קנוני: "/" + מחזיר None אם לא ניתן לחלץ key. + (נרמול מחוזק: unquote, החלפת backslash, הפחתת // כפולים) + """ + if not raw: + return None + from urllib.parse import urlparse as _urlparse, unquote + s = unquote(raw.strip()) + s = s.replace("\\", "/") + if "://" in s: + proto, rest = s.split("://", 1) + while "//" in rest: + rest = rest.replace("//", "/") + s = f"{proto}://{rest}" + else: + while "//" in s: + s = s.replace("//", "/") + + bucket = None + key = None + + if "://" in s: + try: + p = _urlparse(s) + host = (p.hostname or "") + path = (p.path or "").lstrip("/") + if "." in host: # virtual-host + bucket = host.split(".", 1)[0] + key = path + else: # path-style + if "/" in path: + bucket, key = path.split("/", 1) + else: + bucket, key = default_bucket, path + except Exception: + pass + else: + s2 = s.lstrip("/") + if "/" in s2: + bucket, key = s2.split("/", 1) + else: + bucket, key = default_bucket, s2 + + if not bucket or not key: + return None + key = key.lstrip("/") + return f"{bucket}/{key}" + + +def _canonical_id_for_crop(bucket: str, crop_key: str) -> str: + """מזהה קנוני ל-crop: '/'.""" + return f"{(bucket or 'imagery')}/{crop_key.lstrip('/')}" + +# ---------------- PyQt widget ---------------- +class LeafView(QWidget): + """ + PyQt widget that preserves original logic. + Expects optional `api` object (with .base and .http similar to SensorsView). + Does NOT connect directly to Postgres — uses HTTP API. + """ + def __init__(self, api: Optional[Any]=None, parent=None): + super().__init__(parent) + self.api = api + self.threadpool = QThreadPool.globalInstance() + self._anomalies_by_minio_url: dict[str, dict] = {} + + # defaults (same semantics as original) + self.DEFAULT_BUCKET = os.getenv("LEAF_BUCKET", "imagery") + self.DEFAULT_ROOT_ENDPOINT = os.getenv("MINIO_ENDPOINT", "http://minio-hot:9000") + self.DEFAULT_HOT_ENDPOINT = os.getenv("HOT_ENDPOINT", "http://minio-hot:9000") + self.DEFAULT_EXAMPLES_PREFIX = "leaves/examples/" + self.MINIO_ACCESS_KEY = os.getenv("MINIO_ROOT_USER", os.getenv("AWS_ACCESS_KEY_ID", "minioadmin")) + self.MINIO_SECRET_KEY = os.getenv("MINIO_ROOT_PASSWORD", os.getenv("AWS_SECRET_ACCESS_KEY", "minioadmin123")) + # self.DB_API_BASE = os.environ.get("DB_API_BASE", "http://host.docker.internal:8001") + # UI state + self.page_size = 6 + + self._build_ui() + self._apply_modern_style() + self.load_examples() + def _apply_modern_style(self): + """ + Apply a modern LIGHT UI style (no logic changes). + """ + self.setStyleSheet(""" + QWidget { + background-color: #f3f4f6; /* light gray background */ + color: #111827; /* dark text */ + font-family: "Segoe UI", "Arial", sans-serif; + font-size: 11px; + } + + QLabel#mainTitle { + font-size: 18px; + font-weight: 700; + color: #111827; + } + + QLabel { + color: #1f2933; + } + + QLineEdit { + background-color: #ffffff; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 4px 8px; + color: #111827; + selection-background-color: #93c5fd; + } + + QScrollArea { + border: none; + background-color: transparent; + } + + /* main cards grid */ + QFrame#card { + background-color: #ffffff; + border-radius: 12px; + border: 1px solid #e5e7eb; + } + + QFrame#card:hover { + border: 1px solid #3b82f6; + } + + QPushButton { + background-color: #22c55e; /* green base */ + color: #ffffff; + border-radius: 10px; + padding: 4px 10px; + border: 1px solid #16a34a; /* darker green border */ + } + + QPushButton:hover { + background-color: #16a34a; /* hover = darker green */ + } + + QPushButton:pressed { + background-color: #15803d; /* pressed = even darker */ + } + + + QDialog { + background-color: #f9fafb; + } + + QTableWidget { + background-color: #ffffff; + gridline-color: #e5e7eb; + border: 1px solid #e5e7eb; + } + + QHeaderView::section { + background-color: #e5e7eb; + color: #111827; + padding: 4px; + border: 1px solid #d1d5db; + } + + QDialogButtonBox QPushButton { + min-width: 80px; + padding: 5px 14px; + } + """) + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 12, 16, 12) + layout.setSpacing(10) + + header = QHBoxLayout() + title = QLabel("🎛️ Leaves GUI") + # title.setStyleSheet("font-weight:700; font-size:16px;") + title.setObjectName("mainTitle") + header.addWidget(title) + header.addStretch() + + self.bucket_input = QLineEdit(self.DEFAULT_BUCKET) + self.bucket_input.setFixedWidth(200) + header.addWidget(QLabel("Bucket:")) + header.addWidget(self.bucket_input) + + self.pwb_input = QLineEdit("2025/11/19/0047") + self.pwb_input.setFixedWidth(220) + header.addWidget(QLabel("date:")) + header.addWidget(self.pwb_input) + + self.refresh_btn = QPushButton("⟳ Refresh") + self.refresh_btn.clicked.connect(self.load_examples) + header.addWidget(self.refresh_btn) + # self.test_db_btn = QPushButton("Test DB") + # self.test_db_btn.setToolTip("בדיקת חיבור ל-DB API (anomalies)") + # self.test_db_btn.clicked.connect(self.test_db_connection) + # header.addWidget(self.test_db_btn) + layout.addLayout(header) + self.show_anom_btn = QPushButton("Show anomalies") + self.show_anom_btn.setToolTip("הצגת טבלת anomalies מה-DB API") + self.show_anom_btn.clicked.connect(self.show_anomalies_table) + header.addWidget(self.show_anom_btn) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.container = QWidget() + self.grid = QGridLayout(self.container) + # self.grid.setSpacing(12) + self.grid.setContentsMargins(4, 8, 4, 8) + self.grid.setHorizontalSpacing(14) + self.grid.setVerticalSpacing(16) + self.scroll.setWidget(self.container) + layout.addWidget(self.scroll) + + # -------- S3 helpers (same behavior) -------- + def s3_client(self, endpoint_url: str, access_key: str, secret_key: str): + if not endpoint_url.startswith("http"): + endpoint_url = "http://" + endpoint_url + return boto3.client( + "s3", + endpoint_url=endpoint_url, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + config=Config(signature_version="s3v4"), + region_name=os.getenv("AWS_DEFAULT_REGION", "us-east-1"), + ) + + def test_db_connection(self): + """ + בודק חיבור ל-DB API דרך self.api.http (אותו מנגנון כמו SensorsView). + """ + if self.api is None or not getattr(self.api, "http", None) or not getattr(self.api, "base", ""): + QMessageBox.warning( + self, + "DB API", + "אין self.api עם http/base – אי אפשר לבדוק DB API מתוך ה-GUI.", + ) + return + + # נשתמש בבאקט מה-UI כרמז (bucket_hint), כמו ב-Streamlit + bucket_hint = (self.bucket_input.text() or self.DEFAULT_BUCKET).strip() + + w = Worker(self._get_anomalies_via_api_task, bucket_hint, 5000) # limit קטן לבדיקה + w.signals.finished.connect(self._on_test_db_ok) + w.signals.error.connect(self._on_test_db_error) + self.threadpool.start(w) + + + def _on_test_db_ok(self, anomalies: dict): + """ + anomalies: מילון minio_url -> row + """ + # נשמור לשימוש בזמן פתיחת תמונה + self._anomalies_by_minio_url = anomalies or {} + + if not anomalies: + QMessageBox.information( + self, + "DB API", + "החיבור ל-API הצליח, אבל לא הוחזרו anomalies (רשימה ריקה).", + ) + return + + sample_key = next(iter(anomalies.keys())) + row = anomalies[sample_key] + sev = row.get("severity") + aid = row.get("anomaly_id") + + QMessageBox.information( + self, + "DB API", + f"✅ API OK\n" + f"סך הכל רשומות: {len(anomalies)}\n\n" + f"Example key:\n{sample_key}\n" + f"anomaly_id={aid}, severity={sev}", + ) + def show_anomalies_table(self): + """ + כפתור שמציג טבלה של anomalies (raw) על בסיס ה-API. + משתמש באותה קריאה כמו test_db_connection. + """ + if self.api is None or not getattr(self.api, "http", None) or not getattr(self.api, "base", ""): + QMessageBox.warning( + self, + "DB API", + "אין self.api עם http/base – אי אפשר למשוך anomalies.", + ) + return + + bucket_hint = (self.bucket_input.text() or self.DEFAULT_BUCKET).strip() + + # נמשוך כמות סבירה להצגה + w = Worker(self._get_anomalies_via_api_task, bucket_hint, 5000) + w.signals.finished.connect(self._on_show_anomalies_ok) + w.signals.error.connect(self._on_test_db_error) # משתמשים באותה שגיאה + self.threadpool.start(w) + + def _on_show_anomalies_ok(self, anomalies: dict): + """ + מקבל את מילון ה-anomalies ומציג אותו בטבלת PyQt. + מציג רק רשומות עם anomaly_id > 50 (עד 50 – נתוני טסט). + """ + dlg = QDialog(self) + dlg.setWindowTitle("Anomalies table (my data only)") + dlg.resize(900, 500) + + layout = QVBoxLayout(dlg) + + if not anomalies: + lbl = QLabel("לא הוחזרו anomalies מה-API.") + lbl.setWordWrap(True) + layout.addWidget(lbl) + else: + # קודם כל כל השורות כמו שהגיעו מה-API + rows_all = list(anomalies.values()) + + # סינון: נשאיר רק anomaly_id > 50 + rows: list[dict] = [] + for r in rows_all: + aid = r.get("anomaly_id") + # אם זה מספר וגם קטן/שווה 50 – מדלגים + if isinstance(aid, int) and aid <= 50: + continue + # אם anomaly_id בכלל חסר או לא int – או שאת רוצה לדלג או להשאיר: + # כאן אני בוחרת להשאיר אותם: + rows.append(r) + + if not rows: + lbl = QLabel('לא נמצאו רשומות עם anomaly_id גדול מ-50.') + lbl.setWordWrap(True) + layout.addWidget(lbl) + else: + headers = ["anomaly_id", "severity", "minio_key", "minio_url", "disease_name"] + table = QTableWidget(len(rows), len(headers)) + table.setHorizontalHeaderLabels(headers) + + for r_idx, row in enumerate(rows): + details = row.get("details") or {} + + vals = [ + row.get("anomaly_id"), + row.get("severity"), + details.get("minio_key") or details.get("MINIO_KEY") or "", + details.get("minio_url") or details.get("MINIO_URL") or "", + details.get("disease_name") + or details.get("disease") + or details.get("disease_type") + or "", + ] + + for c_idx, val in enumerate(vals): + item = QTableWidgetItem("" if val is None else str(val)) + table.setItem(r_idx, c_idx, item) + + table.resizeColumnsToContents() + layout.addWidget(table) + + btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + btns.rejected.connect(dlg.reject) + layout.addWidget(btns) + + dlg.exec() + + # return self._anomalies_by_minio_url.get(crop_url) + def _find_anomaly_for_crop(self, crop) -> dict | None: + """ + מוצא anomaly מתאים ל-CROP על ידי שימוש במזהה קנוני (bucket/key). + """ + if not self._anomalies_by_minio_url: + return None + + # 1. ניסיון לחלץ Key או URL גולמיים מה-Crop + raw_id = None + if hasattr(crop, "minio_key"): + raw_id = crop.minio_key + elif hasattr(crop, "crop_url"): + raw_id = crop.crop_url # נניח שזה ה-MinIO Key (למשל: leaves/date/crop/...) + elif hasattr(crop, "meta"): + raw_id = (crop.meta.get("minio_key") + or crop.meta.get("MINIO_KEY") + or crop.meta.get("minio_url")) + + if not raw_id: + return None + + # 2. קנוניזציה מלאה: הפיכת ה-Key/URL לפורמט הנדרש: '/' + # זה משלים את ה-Key החלקי עם ה-Bucket הדיפולטי. + canon_crop_id = _canonical_minio_id(raw_id, default_bucket=self.DEFAULT_BUCKET) + + if not canon_crop_id: + return None + + # 3. חיפוש במילון לפי המזהה הקנוני התקין + return self._anomalies_by_minio_url.get(canon_crop_id) + def _on_test_db_error(self, err_tuple): + """ + Callback במקרה של שגיאה בבקשה ל-API. + """ + e, tb = err_tuple + print("[leaf_tab] test_db_connection error:", e) + print(tb) + QMessageBox.critical( + self, + "DB API", + f"❌ כשל בבדיקת החיבור ל-API:\n{e}", + ) + + def _list_examples_task(self, bucket: str, prefix: str) -> List[str]: + s3 = self.s3_client(self.DEFAULT_ROOT_ENDPOINT, self.MINIO_ACCESS_KEY, self.MINIO_SECRET_KEY) + keys: List[str] = [] + paginator = s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=bucket, Prefix=prefix, MaxKeys=1000): + for obj in page.get("Contents", []): + key = obj["Key"] + if key.lower().endswith((".jpg", ".jpeg", ".png")): + keys.append(key) + return sorted(keys) + + def _get_bytes_task(self, endpoint, access_key, secret_key, bucket, key) -> bytes: + s3 = self.s3_client(endpoint, access_key, secret_key) + return s3.get_object(Bucket=bucket, Key=key)["Body"].read() + + def _head_exists(self, s3, bucket, key) -> bool: + try: + s3.head_object(Bucket=bucket, Key=key) + return True + except ClientError as ce: + code = ce.response.get("Error", {}).get("Code") + if code in ("404", "NoSuchKey"): + return False + raise + + def _load_pwb_json_task(self, hot_endpoint, bucket, candidate_json_key) -> Any: + s3 = self.s3_client(hot_endpoint, self.MINIO_ACCESS_KEY, self.MINIO_SECRET_KEY) + data = s3.get_object(Bucket=bucket, Key=candidate_json_key)["Body"].read() + if len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B: + import gzip + data = gzip.decompress(data) + return json.loads(data.decode("utf-8")) + + def _find_pwb_json_anydate_task(self, hot_endpoint, bucket, original_fnames) -> Optional[str]: + s3 = self.s3_client(hot_endpoint, self.MINIO_ACCESS_KEY, self.MINIO_SECRET_KEY) + candidates = set() + for fname in original_fnames: + base, ext = os.path.splitext(fname) + candidates.update({ + f"{fname}.json", + f"{fname.lower()}.json", + f"{base}.JPG.json", + f"{base}.jpg.json", + f"{base}{ext.upper()}.json", + f"{base}{ext.lower()}.json", + }) + paginator = s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=bucket, Prefix="leaves/", MaxKeys=1000): + for obj in page.get("Contents", []): + key = obj["Key"] + if "/pwb/json/" in key: + tail = key.split("/pwb/json/", 1)[-1] + if tail in candidates: + return key + return None + + def _list_crops_dir_for_original_task(self, hot_endpoint, bucket, original_fname_candidates, pwb_date) -> List[str]: + s3 = self.s3_client(hot_endpoint, self.MINIO_ACCESS_KEY, self.MINIO_SECRET_KEY) + keys: List[str] = [] + prefixes = [f"leaves/{pwb_date}/crop/{cand}/" for cand in original_fname_candidates] + paginator = s3.get_paginator("list_objects_v2") + for pref in prefixes: + for page in paginator.paginate(Bucket=bucket, Prefix=pref, MaxKeys=1000): + for obj in page.get("Contents", []): + k = obj["Key"] + if k.lower().endswith((".jpg",".jpeg",".png")): + keys.append(k) + return sorted(set(keys)) + + def _list_all_crops_for_date_task(self, hot_endpoint, bucket, pwb_date) -> List[str]: + s3 = self.s3_client(hot_endpoint, self.MINIO_ACCESS_KEY, self.MINIO_SECRET_KEY) + keys: List[str] = [] + paginator = s3.get_paginator("list_objects_v2") + prefix = f"leaves/{pwb_date}/crop/" + for page in paginator.paginate(Bucket=bucket, Prefix=prefix, MaxKeys=1000): + for obj in page.get("Contents", []): + k = obj["Key"] + if k.lower().endswith((".jpg", ".jpeg", ".png")): + keys.append(k) + if len(keys) >= 5000: + break + return keys + + def _fetch_anomalies_rows(self, limit: int = 100) -> list[dict]: + url = f"{self.api.base}/api/tables/anomalies" + params = { + "limit": limit, + "order_by": "ts", # מהחדש לישן + "order_dir": "desc", + } + + r = self.api.http.get(url, params=params, timeout=15) + r.raise_for_status() + + data = r.json() + + # אם זו רשימה כמו פעם – מצוין + if isinstance(data, list): + return data + + # אם זה dict – ננסה כמה אופציות נפוצות + if isinstance(data, dict): + if "items" in data: + return data["items"] + if "rows" in data: + return data["rows"] + + # מקרה לא צפוי – נדפיס ונחזיר רשימה ריקה, שלא יפיל את ה-GUI + print(f"[leaf_tab] unexpected anomalies API shape: {data!r}") + return [] + + def _get_anomalies_via_api_task(self, bucket_hint: Optional[str], limit: int = 20000) -> dict[str, dict]: + # ... + rows = self._fetch_anomalies_rows(limit=500) + + print(f"[leaf_tab] total rows from API: {len(rows)}") + if rows: + print(f"[leaf_tab] first row sample: {rows[0]}") + + # סינון לפי anomaly_type_id == 5 + type5 = [r for r in rows if r.get("anomaly_type_id") == 5] + + # # בניית map מ־canonical key ל־severity + # id_to_row: dict[str, dict] = {} + + # for row in type5: + # details = row.get("details") or {} + # raw_key = details.get("minio_key") or details.get("minio_url") + # if not raw_key: + # continue + + # canon = _canonical_minio_id(raw_key, default_bucket="imagery") + # if not canon: + # continue + + # id_to_row[canon] = { + # "severity": row.get("severity", 0.0), + # "anomaly_id": row.get("anomaly_id"), + # "device_id": row.get("device_id"), + # } + # בניית map מ־canonical key לשורה מלאה מה-API + id_to_row: dict[str, dict] = {} + + for row in type5: + details = row.get("details") or {} + raw_key = details.get("minio_key") or details.get("minio_url") + if not raw_key: + continue + + canon = _canonical_minio_id(raw_key, default_bucket="imagery") + if not canon: + continue + + # נשמור את כל השורה כמו שהיא, כולל details+label + id_to_row[canon] = row + + print("[leaf_tab] stats:") + print(f" type=5 rows: {len(type5)}") + print(f" final dict size: {len(id_to_row)}") + + return id_to_row + + # -------------- UI actions -------------- + def load_examples(self): + self.refresh_btn.setEnabled(False) + bucket = (self.bucket_input.text() or self.DEFAULT_BUCKET).strip() + prefix = self.DEFAULT_EXAMPLES_PREFIX + + w = Worker(self._list_examples_task, bucket, prefix) + w.signals.finished.connect(lambda keys: self._on_examples_listed(bucket, keys)) + w.signals.error.connect(lambda e: self._on_worker_error(e, "list_examples")) + self.threadpool.start(w) + + def _on_examples_listed(self, bucket: str, keys: List[str]): + for i in reversed(range(self.grid.count())): + w = self.grid.itemAt(i).widget() + if w: + w.setParent(None) + + if not keys: + lbl = QLabel("No examples found") + self.grid.addWidget(lbl, 0, 0) + self.refresh_btn.setEnabled(True) + return + + cols = 3 + # show first page_size items for speed + keys = keys[:self.page_size] + for idx, key in enumerate(keys): + w = Worker(self._get_bytes_task, self.DEFAULT_ROOT_ENDPOINT, self.MINIO_ACCESS_KEY, self.MINIO_SECRET_KEY, bucket, key) + w.signals.finished.connect(lambda data, k=key, i=idx: self._add_card(k, data, i)) + w.signals.error.connect(lambda e: self._on_worker_error(e, "get_bytes")) + self.threadpool.start(w) + + self.refresh_btn.setEnabled(True) + + def _pil_to_qpixmap(self, img: Image.Image) -> Optional[QPixmap]: + try: + qim = ImageQt(img) # safe conversion + qimg = QImage(qim) + pix = QPixmap.fromImage(qimg) + return pix + except Exception: + return None + + def _add_card(self, key: str, data: bytes, index: int): + try: + img = Image.open(io.BytesIO(data)).convert("RGB") + pix = self._pil_to_qpixmap(img) + except Exception: + pix = None + + card = QFrame() + card.setObjectName("card") + v = QVBoxLayout(card) + if pix: + lbl = QLabel() + lbl.setPixmap(pix.scaledToWidth(220, Qt.TransformationMode.SmoothTransformation)) + v.addWidget(lbl) + t = QLabel(os.path.basename(key)) + v.addWidget(t) + btn = QPushButton("Open") + btn.setFixedWidth(140) # רוחב קטן יותר + btn.setFixedHeight(24) # גובה נמוך + # btn.setStyleSheet("font-size: 10px; padding: 2px 6px;") + btn.clicked.connect(lambda _, k=key: self._on_card_click(k)) + v.addWidget(btn, alignment=Qt.AlignmentFlag.AlignCenter) + v.addWidget(btn) + cols = 3 + r, c = divmod(index, cols) + self.grid.addWidget(card, r, c) + + + def _on_card_click(self, key: str): + """ + כשנלחץ OPEN – מריץ Worker ברקע: + טוען תמונה מקורית + CROPs + PWB + anomalies. + """ + bucket = (self.bucket_input.text() or self.DEFAULT_BUCKET).strip() + pwb_date = self.pwb_input.text().strip() + + w = Worker(self._card_click_task, key, bucket, pwb_date) + w.signals.finished.connect(lambda res, k=key: self._show_card_dialog(k, res)) + w.signals.error.connect(lambda e: self._on_worker_error(e, "card_click")) + self.threadpool.start(w) + + def _card_click_task(self, key: str, bucket: str, pwb_date: str): + """ + רץ ברקע (Worker): + - טוען תמונה מקורית מ-ROOT endpoint + - מוצא CROPs לתמונה לפי pwb_date + - מנסה לטעון PWB JSON ולהוציא ממנו bounding boxes + - מושך anomalies מה-DB API, ומתאים אותם ל-CROPs לפי מזהה קנוני "/" + """ + + import os + out = { + "image_bytes": None, + "crops": [], + "pwb_date": pwb_date, + "error": None, + "crops_error": None, + "pwb_json_key": None, + "meta_error": None, + "boxes": [], + "scores": [], + "leaves": [], + "anomalies": {}, + } + + # 1. תמונה מקורית + try: + s3root = self.s3_client( + self.DEFAULT_ROOT_ENDPOINT, + self.MINIO_ACCESS_KEY, + self.MINIO_SECRET_KEY, + ) + obj = s3root.get_object(Bucket=bucket, Key=key) + out["image_bytes"] = obj["Body"].read() + except Exception as e: + out["error"] = f"Failed to load original image from MinIO: {e}" + return out + + # 2. שמות אפשריים ל-"original" + selected_basename = os.path.basename(key) + original_fnames = candidate_original_names(selected_basename) + + # 3. CROPs לתמונה + det_to_crop: dict[int, str] = {} + try: + crop_keys = self._list_crops_dir_for_original_task( + self.DEFAULT_HOT_ENDPOINT, + bucket, + original_fnames, + pwb_date, + ) + crops = [] + if crop_keys: + s3hot = self.s3_client( + self.DEFAULT_HOT_ENDPOINT, + self.MINIO_ACCESS_KEY, + self.MINIO_SECRET_KEY, + ) + for ck in crop_keys: + fname = os.path.basename(ck) + m = CROP_DET_RE.match(fname) + det_idx = None + if m: + try: + det_idx = int(m.group("det")) + det_to_crop[det_idx] = ck + except Exception: + det_idx = None + + try: + data = s3hot.get_object(Bucket=bucket, Key=ck)["Body"].read() + crops.append({"key": ck, "bytes": data, "det": det_idx}) + except Exception: + continue + out["crops"] = crops + except Exception as e: + out["crops_error"] = str(e) + + # 4. PWB JSON → boxes + try: + s3hot = self.s3_client( + self.DEFAULT_HOT_ENDPOINT, + self.MINIO_ACCESS_KEY, + self.MINIO_SECRET_KEY, + ) + + json_key = None + + for cand in original_fnames: + candidate = pwb_json_key_for(cand, pwb_date) + try: + if self._head_exists(s3hot, bucket, candidate): + json_key = candidate + break + except Exception: + continue + + if json_key is None: + auto_key = self._find_pwb_json_anydate_task( + self.DEFAULT_HOT_ENDPOINT, bucket, original_fnames + ) + if auto_key: + json_key = auto_key + + if json_key is None: + out["meta_error"] = ( + f"No PWB JSON found for {original_fnames} (pwb_date={pwb_date})" + ) + return out + + out["pwb_json_key"] = json_key + + meta = self._load_pwb_json_task(self.DEFAULT_HOT_ENDPOINT, bucket, json_key) + + qimg = QImage.fromData(out["image_bytes"]) + img_w = qimg.width() + img_h = qimg.height() + + boxes, scores = compute_boxes(meta, img_w, img_h, original_fnames) + out["boxes"] = boxes + out["scores"] = scores + except Exception as e: + out["meta_error"] = f"Failed to load/parse PWB JSON: {e}" + out["boxes"] = [] + out["scores"] = [] + return out + + # 5. מושכים anomalies לפי מפתח קנוני "/" + try: + anomalies_by_canon = self._get_anomalies_via_api_task( + bucket_hint=bucket, + limit=20000, + ) + except Exception as e: + print("[leaf_tab] _get_anomalies_via_api_task error:", e) + anomalies_by_canon = {} + + # נשמור גם ל-show_anomalies_table + # out["anomalies"] = anomalies_by_canon + # if not isinstance(anomalies_by_canon, dict): + # anomalies_by_canon = {} + if not isinstance(anomalies_by_canon, dict): + anomalies_by_canon = {} + + pwb_date = out.get("pwb_date") # זה מה שקיבלת מה-UI + if pwb_date: + date_fragment = f"/{pwb_date}/" + filtered = { + k: v for k, v in anomalies_by_canon.items() + if isinstance(k, str) and date_fragment in k + } + print( + f"[leaf_tab] filter by date '{date_fragment}': " + f"before={len(anomalies_by_canon)}, after={len(filtered)}" + ) + anomalies_by_canon = filtered + + # נשמור גם ל-show_anomalies_table + out["anomalies"] = anomalies_by_canon + print(f"[leaf_tab] anomalies dict size: {len(anomalies_by_canon)}") + for i, k in enumerate(anomalies_by_canon.keys()): + if i >= 5: + break + print(" [leaf_tab] anomaly key example:", k) + # בתוך _card_click_task - סוף הפונקציה + + leaves: list[LeafDet] = [] + bucket_for_crops = bucket # למשל "imagery" + + # נבנה מפת "ללא באקט" לשימוש קל + nb_map = { + k.split('/', 1)[1]: v + for k, v in anomalies_by_canon.items() + if isinstance(k, str) and '/' in k + } + + for idx, bbox in enumerate(out["boxes"]): + ld = LeafDet(det_index=idx, bbox=bbox) + + if idx in det_to_crop: + ck = det_to_crop[idx] # למשל: "leaves/2025/11/19/0047/crop/leaf1/....png" + ld.crop_key = ck + + # 1) מפתח קנוני מלא: imagery/leaves/... + canon_full = _canonical_id_for_crop(bucket_for_crops, ck) + # 2) בלי באקט: leaves/... + canon_nobucket = canon_full.split('/', 1)[1] if '/' in canon_full else canon_full + + row = None + + + row = anomalies_by_canon.get(canon_full) + + if row is None: + row = anomalies_by_canon.get(canon_nobucket) + + if row is None: + row = nb_map.get(ck.lstrip('/')) + + if row is None: + import os + base = os.path.basename(ck) + for k_a, v_a in anomalies_by_canon.items(): + if isinstance(k_a, str) and k_a.endswith("/" + base): + row = v_a + break + + has_match = row is not None + print( + f"[leaf_tab] det_idx={idx}, crop_key={ck}, " + f"canon_full={canon_full}, has_match={has_match}" + ) + + if row: + ld.status = "sick" + ld.anomaly_id = row.get("anomaly_id") + ld.anomaly_severity = row.get("severity") + details = row.get("details") or {} + ld.disease_name = ( + details.get("label") + or details.get("disease") + or details.get("disease_type") + or details.get("label") + ) + try: + ld.details_snippet = json.dumps(details, ensure_ascii=False)[:300] + except Exception: + ld.details_snippet = str(details)[:300] + ld.match_method = "canonical/full-or-fallback" + else: + ld.status = "healthy" + ld.match_method = None + else: + ld.status = "healthy" + + leaves.append(ld) + + print( + "[leaf_tab] built leaves:", + len(leaves), + "sick=", + sum(1 for l in leaves if l.status == "sick"), + ) + + out["leaves"] = leaves + return out + + def _safe_show_card_dialog(self, key: str, res: dict): + """עטיפה בטוחה סביב ה-dialog כדי למנוע קריסה של כל האפליקציה.""" + try: + self._show_card_dialog(key, res) + except Exception as e: + import traceback + print("[leaf_tab] dialog error:", e) + traceback.print_exc() + + # נציג הודעה מסודרת במקום לקרוס + dlg = QDialog(self) + dlg.setWindowTitle(f"Leaf dialog error – {os.path.basename(key)}") + layout = QVBoxLayout(dlg) + msg = QLabel(f"ארעה שגיאה בעת פתיחת התמונה:\n{e}") + msg.setWordWrap(True) + layout.addWidget(msg) + + btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + btns.rejected.connect(dlg.reject) + layout.addWidget(btns) + + dlg.exec() + + + def _show_card_dialog(self, key: str, res: dict): + """ + Dialog with scroll: + - Shows original image with bounding boxes (colored by status) and index per leaf + - Shows grid of CROPs below, with filter: Sick / Healthy / All + All inside a QScrollArea so nothing gets cut. + """ + dlg = QDialog(self) + dlg.setWindowTitle(os.path.basename(key)) + dlg.resize(600, 800) # initial size + + # Outer layout of the dialog + outer = QVBoxLayout(dlg) + + # -------- Scrollable content -------- + scroll = QScrollArea() + scroll.setWidgetResizable(True) + + container = QWidget() + layout = QVBoxLayout(container) + + # ==== Original image + boxes (green/red) ==== + if res.get("error"): + lbl = QLabel(f"Error loading image:\n{res['error']}") + lbl.setWordWrap(True) + layout.addWidget(lbl) + else: + if res.get("image_bytes"): + qimg = QImage.fromData(res["image_bytes"]) + if not qimg.isNull(): + pix = QPixmap.fromImage(qimg) + + leaves = res.get("leaves") or [] + boxes = res.get("boxes") or [] + + painter = QPainter(pix) + + # bigger, bold font for indices so they are clearly visible + font = painter.font() + font.setPointSize(11) + font.setBold(True) + painter.setFont(font) + + if leaves: + # draw boxes + index number per leaf + for leaf in leaves: + x1, y1, x2, y2 = leaf.bbox + + # box color by status + color_tuple = COLOR_BY_STATUS.get( + leaf.status, COLOR_BY_STATUS["healthy"] + ) + pen = QPen(QColor(*color_tuple)) + pen.setWidth(3) + painter.setPen(pen) + painter.drawRect(x1, y1, x2 - x1, y2 - y1) + + # draw index badge in top-left corner of the box + idx_text = str(leaf.det_index) + + # bright yellow background for the index badge + bg_color = QColor(250, 204, 21, 230) # amber + badge_width = 26 + badge_height = 20 + painter.fillRect(x1, y1, badge_width, badge_height, bg_color) + + # black text for strong contrast + painter.setPen(QColor(0, 0, 0)) + painter.drawText(x1 + 6, y1 + 15, idx_text) + elif boxes: + # fallback: if we only have raw boxes – just draw green boxes (no indices) + pen = QPen(QColor(*COLOR_BY_STATUS["healthy"])) + pen.setWidth(3) + painter.setPen(pen) + for (x1, y1, x2, y2) in boxes: + painter.drawRect(x1, y1, x2 - x1, y2 - y1) + + painter.end() + + lbl_img = QLabel() + lbl_img.setPixmap( + pix.scaledToWidth( + 500, Qt.TransformationMode.SmoothTransformation + ) + ) + layout.addWidget(lbl_img) + else: + layout.addWidget(QLabel("Cannot display image (QImage is null).")) + else: + layout.addWidget(QLabel("No image data to display.")) + + # ==== CROPs ==== + crops = res.get("crops") or [] + leaves = res.get("leaves") or [] + leaves_by_crop = {leaf.crop_key: leaf for leaf in leaves if leaf.crop_key} + + crops_header = QLabel(f"CROPs: {len(crops)}") + crops_header.setStyleSheet("font-weight: 600; margin-top: 8px;") + layout.addWidget(crops_header) + + if crops: + # --- filter controls row --- + filter_row = QHBoxLayout() + filter_label = QLabel("Filter:") + filter_combo = QComboBox() + filter_combo.addItems(["Sick only", "Healthy only", "All"]) + filter_combo.setCurrentIndex(0) # default: Sick only + + filter_row.addWidget(filter_label) + filter_row.addWidget(filter_combo) + filter_row.addStretch() + layout.addLayout(filter_row) + + # --- container for the crops grid --- + crops_container = QWidget() + crops_grid = QGridLayout(crops_container) + crops_grid.setContentsMargins(0, 4, 0, 0) + crops_grid.setHorizontalSpacing(10) + crops_grid.setVerticalSpacing(10) + layout.addWidget(crops_container) + + def clear_crops_grid(): + """Remove all widgets from the crops grid before re-populating.""" + while crops_grid.count(): + item = crops_grid.takeAt(0) + w = item.widget() + if w is not None: + w.setParent(None) + + def populate_crops(filter_mode: str): + """ + Rebuild the crops grid according to filter_mode: + - 'Sick only' + - 'Healthy only' + - 'All' + """ + clear_crops_grid() + + row_idx = 0 + col_idx = 0 + max_cols = 2 # 2 columns layout + + for c in crops: + leaf = leaves_by_crop.get(c["key"]) + status = None + if leaf is not None: + status = leaf.status + + # apply filter + if filter_mode == "Sick only" and not (leaf and status == "sick"): + continue + if filter_mode == "Healthy only" and not (leaf and status == "healthy"): + continue + # "All" -> no filter + + qimg_c = QImage.fromData(c["bytes"]) + + container_crop = QFrame() + container_crop.setObjectName("card") # use same card style + v = QVBoxLayout(container_crop) + v.setContentsMargins(6, 6, 6, 6) + v.setSpacing(4) + + # find matching leaf for this crop (if any) + idx_value = None + if leaf is not None: + idx_value = leaf.det_index + elif c.get("det") is not None: + # fallback: use det index from filename if LeafDet is missing + idx_value = c["det"] + + # number label (Leaf #N) + number_label = QLabel() + if idx_value is not None: + number_label.setText(f"Leaf #{idx_value}") + number_label.setStyleSheet("font-weight: 600;") + else: + number_label.setText("Leaf") + v.addWidget(number_label) + + # CROP image (smaller) + if not qimg_c.isNull(): + pix_c = QPixmap.fromImage(qimg_c) + lbl_c = QLabel() + lbl_c.setPixmap( + pix_c.scaledToWidth( + 140, Qt.TransformationMode.SmoothTransformation + ) + ) + v.addWidget(lbl_c) + else: + v.addWidget(QLabel("Cannot load CROP (QImage is null).")) + + # file name + lbl_name = QLabel(os.path.basename(c["key"])) + v.addWidget(lbl_name) + + # disease / status info + info_label = QLabel() + if leaf and status == "sick": + dn = leaf.disease_name or "sick" + info_label.setText(f"Sick: {dn}") + info_label.setStyleSheet("color: red; font-weight: 600;") + elif leaf and status == "healthy": + info_label.setText("Healthy") + info_label.setStyleSheet("color: green;") + else: + info_label.setText("Unknown") + info_label.setStyleSheet("color: #6b7280;") + v.addWidget(info_label) + + crops_grid.addWidget(container_crop, row_idx, col_idx) + col_idx += 1 + if col_idx >= max_cols: + col_idx = 0 + row_idx += 1 + + # initial populate: sick only + populate_crops("Sick only") + + def on_filter_changed(index: int): + mode = filter_combo.currentText() + populate_crops(mode) + + filter_combo.currentIndexChanged.connect(on_filter_changed) + + else: + txt = "No CROPs found for this image and selected date." + if res.get("crops_error"): + txt += f"\n(Error: {res['crops_error']})" + lbl_crops = QLabel(txt) + lbl_crops.setWordWrap(True) + layout.addWidget(lbl_crops) + + # ==== Technical info ==== + bucket = (self.bucket_input.text() or self.DEFAULT_BUCKET).strip() + meta_lbl = QLabel( + f"Bucket: {bucket}\nDate: {res.get('pwb_date')}" + ) + meta_lbl.setWordWrap(True) + meta_lbl.setStyleSheet("color: #6b7280; font-size: 9.5pt;") + layout.addWidget(meta_lbl) + + # connect container to scroll + container.setLayout(layout) + scroll.setWidget(container) + outer.addWidget(scroll) + + # Close button at bottom + btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + btns.rejected.connect(dlg.reject) + outer.addWidget(btns) + + dlg.exec() + + def _on_worker_error(self, err_tuple, ctx=""): + e, tb = err_tuple + print(f"[leaf_tab] worker error ({ctx}): {e}") + print(tb) + # לוודא שתמיד ניתן ללחוץ שוב על Refresh + try: + self.refresh_btn.setEnabled(True) + except Exception: + pass + +# helper to lookup no-bucket fallback quickly +def anom_nobucket_lookup(anomalies_by_key: Dict[str, dict], crop_key: str) -> Optional[dict]: + # build no-bucket map on first use + for k,v in list(anomalies_by_key.items()): + pass + nbmap = {k.split('/',1)[1]: v for k,v in anomalies_by_key.items() if '/' in k} + return nbmap.get(crop_key.lstrip('/')) \ No newline at end of file diff --git a/grafana/dashboards/leaf-disease-dashboard.json b/grafana/dashboards/leaf-disease-dashboard.json index 57b12b74e..b821c1cbb 100644 --- a/grafana/dashboards/leaf-disease-dashboard.json +++ b/grafana/dashboards/leaf-disease-dashboard.json @@ -13,10 +13,7 @@ "type": "stat", "title": "Total Cases (Selected Disease, Dynamic Range)", "gridPos": { "h": 6, "w": 12, "x": 0, "y": 0 }, - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, + "datasource": "Prometheus", "targets": [ { "refId": "A", @@ -59,7 +56,7 @@ "legendFormat": "Device {{device_id}}" } ], - "datasource": { "type": "prometheus", "uid": "prometheus" }, + "datasource": "Prometheus", "options": { "orientation": "horizontal", "displayMode": "gradient", @@ -95,7 +92,7 @@ "legendFormat": "{{disease_name}}" } ], - "datasource": { "type": "prometheus", "uid": "prometheus" }, + "datasource": "Prometheus", "options": { "legend": { "displayMode": "table", @@ -115,10 +112,7 @@ "type": "stat", "title": "Share of Selected Disease (%) — Dynamic Range", "gridPos": { "h": 6, "w": 12, "x": 12, "y": 0 }, - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, + "datasource": "Prometheus", "targets": [ { "refId": "A", @@ -162,7 +156,7 @@ "format": "table" } ], - "datasource": { "type": "prometheus", "uid": "prometheus" }, + "datasource": "Prometheus", "options": { "showHeader": true }, "fieldConfig": { "defaults": { @@ -188,7 +182,7 @@ { "name": "disease_id", "type": "query", - "datasource": { "type": "prometheus", "uid": "prometheus" }, + "datasource": "Prometheus", "query": "label_values(leaf_reports_by_disease_count, disease_id)", "refresh": 1, "regex": "", diff --git a/services/db_api_service/.env.example b/services/db_api_service/.env.example index a8f7feb5c..543bd481a 100644 --- a/services/db_api_service/.env.example +++ b/services/db_api_service/.env.example @@ -4,6 +4,6 @@ PORT=8080 CONTRACTS_DIR=app/contracts -ALLOWED_TABLES=["event_logs_sensors","devices","image_new_aerial_connections","sound_new_sounds_connections","sounds_metadata","sounds_ultra_metadata","sound_new_plants_connections","aerial_images_metadata","aerial_image_object_detections","aerial_image_anomaly_detections","aerial_images_complete_metadata","field_polygons","aerial_image_segmentation","image_new_security_connections","alerts"] +ALLOWED_TABLES=["event_logs_sensors","devices","image_new_aerial_connections","sound_new_sounds_connections","sounds_metadata","sounds_ultra_metadata","sound_new_plants_connections","aerial_images_metadata","aerial_image_object_detections","aerial_image_anomaly_detections","aerial_images_complete_metadata","field_polygons","aerial_image_segmentation","image_new_security_connections","alerts","anomalies","anomalies_types","leaf_reports","leaf_disease_types"] STRICT_UNKNOWN_FIELDS=true