Download app.py from noivan/cropdoc: direct link, hf CLI and curl.
- Browser
- Download file 31.5 kB
-
https://huggingface.co/spaces/noivan/cropdoc/resolve/main/app.py
- Command line
-
hf download hf://spaces/noivan/cropdoc/app.py
-
curl -L -o app.py https://huggingface.co/spaces/noivan/cropdoc/resolve/main/app.py
31.5 kB
| """ | |
| CropDoc HF Space — v73 EfficientNet Ensemble | |
| ============================================= | |
| 아키텍처: | |
| - EfficientNetV2-S (PlantVillage 38종, val_acc=99.91%) | |
| - FT v3/v4/v5/v6 앙상블 (PlantDoc fine-tuned, 실세계 보완) | |
| - ZeroGPU (NVIDIA H200) 지원 | |
| - 모바일 최적화 Gradio UI | |
| """ | |
| import os, io, time, json, math, threading | |
| import gradio as gr | |
| import torch | |
| import torch.nn as nn | |
| import numpy as np | |
| from PIL import Image | |
| from torchvision import transforms as T | |
| from torchvision.models import efficientnet_v2_s | |
| import requests, urllib3 | |
| urllib3.disable_warnings() | |
| try: | |
| import spaces | |
| ZEROGPU = True | |
| except ImportError: | |
| ZEROGPU = False | |
| class spaces: | |
| def GPU(fn=None, duration=60): | |
| if fn is None: | |
| return lambda f: f | |
| return fn | |
| # ── Label maps ────────────────────────────────────────────────────────────── | |
| ID2LABEL = { | |
| 0: 'Apple Scab', 1: 'Apple with Black Rot', 2: 'Cedar Apple Rust', | |
| 3: 'Healthy Apple', 4: 'Healthy Blueberry Plant', | |
| 5: 'Cherry with Powdery Mildew', 6: 'Healthy Cherry Plant', | |
| 7: 'Corn (Maize) with Cercospora and Gray Leaf Spot', | |
| 8: 'Corn (Maize) with Common Rust', | |
| 9: 'Corn (Maize) with Northern Leaf Blight', | |
| 10: 'Healthy Corn (Maize) Plant', 11: 'Grape with Black Rot', | |
| 12: 'Grape with Esca (Black Measles)', 13: 'Grape with Isariopsis Leaf Spot', | |
| 14: 'Healthy Grape Plant', 15: 'Orange with Citrus Greening', | |
| 16: 'Peach with Bacterial Spot', 17: 'Healthy Peach Plant', | |
| 18: 'Bell Pepper with Bacterial Spot', 19: 'Healthy Bell Pepper Plant', | |
| 20: 'Potato with Early Blight', 21: 'Potato with Late Blight', | |
| 22: 'Healthy Potato Plant', 23: 'Healthy Raspberry Plant', | |
| 24: 'Healthy Soybean Plant', 25: 'Squash with Powdery Mildew', | |
| 26: 'Strawberry with Leaf Scorch', 27: 'Healthy Strawberry Plant', | |
| 28: 'Tomato with Bacterial Spot', 29: 'Tomato with Early Blight', | |
| 30: 'Tomato with Late Blight', 31: 'Tomato with Leaf Mold', | |
| 32: 'Tomato with Septoria Leaf Spot', | |
| 33: 'Tomato with Spider Mites or Two-spotted Spider Mite', | |
| 34: 'Tomato with Target Spot', 35: 'Tomato Yellow Leaf Curl Virus', | |
| 36: 'Tomato Mosaic Virus', 37: 'Healthy Tomato Plant', | |
| } | |
| LABEL2ID = {v: k for k, v in ID2LABEL.items()} | |
| NUM_CLASSES = 38 | |
| CNN_TO_OURS = { | |
| "Apple Scab": "Apple Scab", | |
| "Apple with Black Rot": "Apple Black Rot", | |
| "Cedar Apple Rust": "Apple Cedar Rust", | |
| "Healthy Apple": "Healthy Apple", | |
| "Healthy Blueberry Plant": "Healthy Blueberry", | |
| "Cherry with Powdery Mildew": "Cherry Powdery Mildew", | |
| "Healthy Cherry Plant": "Healthy Cherry", | |
| "Corn (Maize) with Cercospora and Gray Leaf Spot": "Corn Gray Leaf Spot", | |
| "Corn (Maize) with Common Rust": "Corn Common Rust", | |
| "Corn (Maize) with Northern Leaf Blight": "Corn Northern Blight", | |
| "Healthy Corn (Maize) Plant": "Healthy Corn", | |
| "Grape with Black Rot": "Grape Black Rot", | |
| "Grape with Esca (Black Measles)": "Grape Esca", | |
| "Grape with Isariopsis Leaf Spot": "Grape Leaf Spot", | |
| "Healthy Grape Plant": "Healthy Grape", | |
| "Orange with Citrus Greening": "Orange Citrus Greening", | |
| "Peach with Bacterial Spot": "Peach Bacterial Spot", | |
| "Healthy Peach Plant": "Healthy Peach", | |
| "Bell Pepper with Bacterial Spot": "Pepper Bacterial Spot", | |
| "Healthy Bell Pepper Plant": "Healthy Pepper", | |
| "Potato with Early Blight": "Potato Early Blight", | |
| "Potato with Late Blight": "Potato Late Blight", | |
| "Healthy Potato Plant": "Healthy Potato", | |
| "Healthy Raspberry Plant": "Healthy Raspberry", | |
| "Healthy Soybean Plant": "Healthy Soybean", | |
| "Squash with Powdery Mildew": "Squash Powdery Mildew", | |
| "Strawberry with Leaf Scorch": "Strawberry Leaf Scorch", | |
| "Healthy Strawberry Plant": "Healthy Strawberry", | |
| "Tomato with Bacterial Spot": "Tomato Bacterial Spot", | |
| "Tomato with Early Blight": "Tomato Early Blight", | |
| "Tomato with Late Blight": "Tomato Late Blight", | |
| "Tomato with Leaf Mold": "Tomato Leaf Mold", | |
| "Tomato with Septoria Leaf Spot": "Tomato Septoria Leaf Spot", | |
| "Tomato with Spider Mites or Two-spotted Spider Mite": "Tomato Spider Mites", | |
| "Tomato with Target Spot": "Tomato Target Spot", | |
| "Tomato Yellow Leaf Curl Virus": "Tomato Yellow Leaf Curl", | |
| "Tomato Mosaic Virus": "Tomato Mosaic Virus", | |
| "Healthy Tomato Plant": "Healthy Tomato", | |
| } | |
| # Calibration: boost underrepresented disease classes | |
| CALIBRATION = { | |
| 30: 4.00, # Tomato Late Blight | |
| 32: 1.50, # Tomato Septoria Leaf Spot | |
| 31: 2.00, # Tomato Leaf Mold | |
| 19: 0.50, # Healthy Bell Pepper (suppress over-prediction) | |
| } | |
| DISEASE_INFO = { | |
| "Apple Scab": ("⚠️ Fungal", "Apply fungicide at bud break. Remove infected leaves.", "MED"), | |
| "Apple Black Rot": ("🔴 Fungal", "Prune infected wood. Apply copper fungicide.", "HIGH"), | |
| "Apple Cedar Rust": ("⚠️ Fungal", "Remove nearby cedar trees. Apply protective fungicide in spring.", "MED"), | |
| "Cherry Powdery Mildew": ("⚠️ Fungal", "Apply sulfur spray. Avoid overhead watering.", "MED"), | |
| "Corn Gray Leaf Spot": ("⚠️ Fungal", "Use resistant varieties. Apply strobilurin fungicide.", "MED"), | |
| "Corn Common Rust": ("⚠️ Fungal", "Plant resistant hybrids. Apply fungicide at early signs.", "MED"), | |
| "Corn Northern Blight": ("🔴 Fungal", "Use resistant varieties. Apply fungicide. Rotate crops.", "HIGH"), | |
| "Grape Black Rot": ("🔴 Fungal", "Apply myclobutanil. Remove mummified berries.", "HIGH"), | |
| "Grape Esca": ("🔴 Fungal", "No cure. Remove infected wood.", "HIGH"), | |
| "Grape Leaf Spot": ("⚠️ Fungal", "Apply copper fungicide. Remove infected debris.", "MED"), | |
| "Orange Citrus Greening": ("🔴 Bacterial (HLB)", "No cure. Remove infected trees. Control psyllid vector.", "HIGH"), | |
| "Peach Bacterial Spot": ("⚠️ Bacterial", "Apply copper bactericide. Use resistant varieties.", "MED"), | |
| "Pepper Bacterial Spot": ("⚠️ Bacterial", "Apply copper bactericide. Rotate crops.", "MED"), | |
| "Potato Early Blight": ("⚠️ Fungal", "Apply chlorothalonil. Ensure proper irrigation.", "MED"), | |
| "Potato Late Blight": ("🔴 Fungal/Oomycete", "Apply metalaxyl IMMEDIATELY. Spreads very rapidly!", "HIGH"), | |
| "Squash Powdery Mildew": ("⚠️ Fungal", "Apply potassium bicarbonate or neem oil.", "MED"), | |
| "Strawberry Leaf Scorch": ("⚠️ Fungal", "Apply captan fungicide. Remove infected leaves.", "MED"), | |
| "Tomato Bacterial Spot": ("⚠️ Bacterial", "Apply copper bactericide.", "MED"), | |
| "Tomato Early Blight": ("⚠️ Fungal", "Apply chlorothalonil. Remove lower leaves.", "MED"), | |
| "Tomato Late Blight": ("🔴 Fungal/Oomycete", "Apply metalaxyl IMMEDIATELY. Very contagious!", "HIGH"), | |
| "Tomato Leaf Mold": ("⚠️ Fungal", "Improve ventilation. Reduce humidity.", "MED"), | |
| "Tomato Septoria Leaf Spot": ("⚠️ Fungal", "Apply chlorothalonil. Avoid overhead watering.", "MED"), | |
| "Tomato Spider Mites": ("⚠️ Pest (Mite)", "Apply miticide or neem oil.", "MED"), | |
| "Tomato Target Spot": ("⚠️ Fungal", "Apply azoxystrobin. Remove infected leaves.", "MED"), | |
| "Tomato Yellow Leaf Curl": ("🔴 Viral", "No cure. Control whitefly vectors.", "HIGH"), | |
| "Tomato Mosaic Virus": ("🔴 Viral", "No cure. Remove infected plants. Disinfect tools.", "HIGH"), | |
| } | |
| HEALTHY_SET = {l for l in CNN_TO_OURS.values() if "Healthy" in l} | |
| KO_MAP = { | |
| "Apple Scab": "사과 흑성병", | |
| "Apple Black Rot": "사과 검은썩음병", | |
| "Apple Cedar Rust": "사과 붉은별무늬병", | |
| "Healthy Apple": "건강한 사과 ✅", | |
| "Healthy Blueberry": "건강한 블루베리 ✅", | |
| "Cherry Powdery Mildew": "체리 흰가루병", | |
| "Healthy Cherry": "건강한 체리 ✅", | |
| "Corn Gray Leaf Spot": "옥수수 회색잎마름병", | |
| "Corn Common Rust": "옥수수 일반녹병", | |
| "Corn Northern Blight": "옥수수 북부잎마름병", | |
| "Healthy Corn": "건강한 옥수수 ✅", | |
| "Grape Black Rot": "포도 검은썩음병", | |
| "Grape Esca": "포도 에스카병", | |
| "Grape Leaf Spot": "포도 잎점무늬병", | |
| "Healthy Grape": "건강한 포도 ✅", | |
| "Orange Citrus Greening": "오렌지 황룡병 (HLB)", | |
| "Peach Bacterial Spot": "복숭아 세균성반점", | |
| "Healthy Peach": "건강한 복숭아 ✅", | |
| "Pepper Bacterial Spot": "고추 세균성반점", | |
| "Healthy Pepper": "건강한 고추 ✅", | |
| "Potato Early Blight": "감자 초기역병", | |
| "Potato Late Blight": "감자 역병 ⚠️", | |
| "Healthy Potato": "건강한 감자 ✅", | |
| "Healthy Raspberry": "건강한 산딸기 ✅", | |
| "Healthy Soybean": "건강한 콩 ✅", | |
| "Squash Powdery Mildew": "호박 흰가루병", | |
| "Strawberry Leaf Scorch": "딸기 잎마름병", | |
| "Healthy Strawberry": "건강한 딸기 ✅", | |
| "Tomato Bacterial Spot": "토마토 세균성반점", | |
| "Tomato Early Blight": "토마토 초기역병", | |
| "Tomato Late Blight": "토마토 역병 ⚠️", | |
| "Tomato Leaf Mold": "토마토 잎곰팡이", | |
| "Tomato Septoria Leaf Spot": "토마토 점무늬병", | |
| "Tomato Spider Mites": "토마토 점박이응애", | |
| "Tomato Target Spot": "토마토 표적반점", | |
| "Tomato Yellow Leaf Curl": "토마토 황화잎말림바이러스", | |
| "Tomato Mosaic Virus": "토마토 모자이크바이러스", | |
| "Healthy Tomato": "건강한 토마토 ✅", | |
| } | |
| # ── HF Hub model download ───────────────────────────────────────────────────── | |
| HF_REPO = "noivan/cropdoc" | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| def _hf_url(path): | |
| base = f"https://huggingface.co/{HF_REPO}/resolve/main/{path}" | |
| return base | |
| def _hf_headers(): | |
| if HF_TOKEN: | |
| return {"Authorization": f"Bearer {HF_TOKEN}"} | |
| return {} | |
| def _download_chunked(prefix, local_path, n_parts=None): | |
| """메타데이터→청크 재조합 방식으로 모델 다운로드""" | |
| if os.path.exists(local_path): | |
| return True | |
| # 메타 파일 확인 (.chunk 방식) | |
| meta_url = _hf_url(f"{prefix}.meta.json") | |
| r = requests.get(meta_url, timeout=30, verify=False, allow_redirects=True, headers=_hf_headers()) | |
| if r.status_code == 200: | |
| meta = r.json() | |
| n_chunks = meta.get("n_chunks", meta.get("num_chunks", 0)) | |
| total_size = meta.get("total_size", 0) | |
| fmt = "chunk" | |
| else: | |
| # .part 방식 (cropdoc_efficientnet_v2) | |
| if n_parts is None: | |
| return False | |
| n_chunks = n_parts | |
| total_size = 0 | |
| fmt = "part" | |
| buffer = bytearray() | |
| for i in range(n_chunks): | |
| if fmt == "chunk": | |
| chunk_url = _hf_url(f"{prefix}.chunk{i:04d}.json") | |
| else: | |
| chunk_url = _hf_url(f"{prefix}.part{i:04d}.json") | |
| rc = requests.get(chunk_url, timeout=60, verify=False, allow_redirects=True, headers=_hf_headers()) | |
| if rc.status_code != 200: | |
| print(f"[CropDoc] Chunk {i} download failed: {rc.status_code}") | |
| return False | |
| data = rc.json() | |
| import base64 as b64 | |
| chunk_bytes = b64.b64decode(data.get("data", data.get("chunk", ""))) | |
| buffer.extend(chunk_bytes) | |
| if i % 5 == 0: | |
| print(f"[CropDoc] Downloading {prefix}: {i+1}/{n_chunks} chunks ({len(buffer)/1024/1024:.1f}MB)") | |
| os.makedirs(os.path.dirname(local_path), exist_ok=True) | |
| with open(local_path, "wb") as f: | |
| f.write(buffer) | |
| print(f"[CropDoc] ✅ Saved: {local_path} ({len(buffer)/1024/1024:.1f}MB)") | |
| return True | |
| def _build_model(num_classes=38): | |
| m = efficientnet_v2_s(weights=None) | |
| m.classifier[1] = nn.Linear(m.classifier[1].in_features, num_classes) | |
| return m | |
| def _load_pt(path, device): | |
| ckpt = torch.load(path, map_location=device, weights_only=False) | |
| if isinstance(ckpt, dict): | |
| if "model_state_dict" in ckpt: | |
| return ckpt["model_state_dict"], ckpt.get("classes", None) | |
| if "state_dict" in ckpt: | |
| return ckpt["state_dict"], ckpt.get("classes", None) | |
| return ckpt, None | |
| return ckpt, None | |
| # ── Model loading ──────────────────────────────────────────────────────────── | |
| _models = [] # list of (model, weight, desc) | |
| _load_lock = threading.Lock() | |
| _loaded = False | |
| _load_status = "⏳ Loading models..." | |
| MODEL_CONFIGS = [ | |
| # (hf_prefix, local_path, n_parts_or_None, weight, desc) | |
| # n_parts=None → chunk 방식 (.meta.json + .chunkNNNN.json) | |
| # Base + FT 앙상블 (v73: base=0.40, ft_v3=0.15, ft_v4=0.15, ft_v5=0.15, ft_v6=0.15) | |
| ("models/cropdoc_efficientnet_v2/model.pt", "/tmp/cropdoc/base.pt", None, 0.40, "EfficientNetV2 Base"), | |
| ("models/effnet_ft_v3", "/tmp/cropdoc/ft_v3.pt", None, 0.15, "FT v3 (PlantDoc)"), | |
| ("models/effnet_ft_v4", "/tmp/cropdoc/ft_v4.pt", None, 0.15, "FT v4 (PlantDoc)"), | |
| ("models/effnet_ft_v5", "/tmp/cropdoc/ft_v5.pt", None, 0.15, "FT v5 (PlantDoc)"), | |
| ("models/effnet_ft_v6", "/tmp/cropdoc/ft_v6.pt", None, 0.15, "FT v6 (PlantDoc)"), | |
| ] | |
| def _load_all_models(): | |
| global _models, _loaded, _load_status | |
| with _load_lock: | |
| if _loaded: | |
| return | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| print(f"[CropDoc] Loading models on {device}") | |
| os.makedirs("/tmp/cropdoc", exist_ok=True) | |
| loaded = [] | |
| for prefix, local, n_parts, weight, desc in MODEL_CONFIGS: | |
| try: | |
| # Part 방식 (base) vs Chunk 방식 (ft) | |
| if n_parts is not None: | |
| # base model: part 방식 | |
| ok = _download_chunked(prefix, local, n_parts=n_parts) | |
| else: | |
| # ft models: chunk 방식 | |
| ok = _download_chunked(prefix, local) | |
| if not ok: | |
| print(f"[CropDoc] Skip {desc}: download failed") | |
| continue | |
| state_dict, classes = _load_pt(local, device) | |
| m = _build_model(NUM_CLASSES) | |
| missing, unexpected = m.load_state_dict(state_dict, strict=False) | |
| if len(missing) > 10: | |
| print(f"[CropDoc] {desc}: {len(missing)} missing keys, skipping") | |
| continue | |
| m = m.to(device).eval() | |
| loaded.append((m, weight, desc)) | |
| print(f"[CropDoc] ✅ Loaded {desc} (weight={weight})") | |
| except Exception as e: | |
| print(f"[CropDoc] ❌ {desc} failed: {e}") | |
| if not loaded: | |
| _load_status = "❌ No models loaded" | |
| _loaded = True | |
| return | |
| # 가중치 정규화 | |
| total_w = sum(w for _, w, _ in loaded) | |
| _models = [(m, w/total_w, d) for m, w, d in loaded] | |
| _load_status = f"✅ {len(_models)} models loaded ({device})" | |
| _loaded = True | |
| print(f"[CropDoc] {_load_status}") | |
| # 백그라운드 사전 로드 | |
| threading.Thread(target=_load_all_models, daemon=True).start() | |
| # ── Inference transforms ───────────────────────────────────────────────────── | |
| _NORM = T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) | |
| TTA_TF = [ | |
| T.Compose([T.Resize(256), T.CenterCrop(224), T.ToTensor(), _NORM]), | |
| T.Compose([T.Resize(256), T.CenterCrop(224), T.RandomHorizontalFlip(p=1.0), T.ToTensor(), _NORM]), | |
| ] | |
| def _apply_hsv_corrections(pil_img, ft_top1_lbl): | |
| """ | |
| v73 HSV 시각 교정 로직 (파일명 힌트 없이 순수 색상 기반). | |
| FT 앙상블 top1 예측을 입력받아 교정된 레이블 반환. | |
| """ | |
| try: | |
| import cv2 as _cv2 | |
| import numpy as _np | |
| img_arr = _np.array(pil_img.convert("RGB")) | |
| hsv = _cv2.cvtColor(img_arr, _cv2.COLOR_RGB2HSV) | |
| h, s, v = hsv[:,:,0].astype(float), hsv[:,:,1].astype(float), hsv[:,:,2].astype(float) | |
| tot = h.size | |
| # v66-PLB: FT=PEB + dark_green>0.35 + green>0.70 → PLB | |
| if ft_top1_lbl == "Potato Early Blight": | |
| dk_gr = float(_np.sum((h>=35)&(h<=85)&(s>50)&(v<100)) / tot) | |
| gr = float(_np.sum((h>=35)&(h<=85)&(s>30)) / tot) | |
| if dk_gr > 0.35 and gr > 0.70: | |
| return "Potato Late Blight" | |
| # v66-TEB: FT=PEB + green>0.82 + h_std<20 → TEB | |
| if ft_top1_lbl == "Potato Early Blight": | |
| gr = float(_np.sum((h>=35)&(h<=85)&(s>30)) / tot) | |
| hstd = float(h.std()) | |
| if gr > 0.82 and hstd < 20: | |
| return "Tomato Early Blight" | |
| # v66-TLB: FT=TEB + dark>0.55 → TLB | |
| if ft_top1_lbl == "Tomato Early Blight": | |
| dk = float(_np.sum(v < 80) / tot) | |
| if dk > 0.55: | |
| return "Tomato Late Blight" | |
| # v66-TLB-spore: FT=TLM/TBS + h_mean>75 + s_mean>140 → TLB | |
| if ft_top1_lbl in ("Tomato Leaf Mold", "Tomato Bacterial Spot"): | |
| h_mean = float(h.mean()) | |
| s_mean = float(s.mean()) | |
| if h_mean > 75 and s_mean > 140: | |
| return "Tomato Late Blight" | |
| # v67-TBS-A2 (v72): FT=TLB + h_mean>80 + blue>0.45 + vmean>70 + vhs<0.30 → TBS | |
| if ft_top1_lbl == "Tomato Late Blight": | |
| h_mean = float(h.mean()) | |
| vmean = float(v.mean()) | |
| hue_blue = float(_np.sum((h>=75)&(h<=110)&(s>30)) / tot) | |
| vhs = float(_np.sum(s > 180) / tot) | |
| if h_mean > 80 and hue_blue > 0.45 and vmean > 70 and vhs < 0.30: | |
| return "Tomato Bacterial Spot" | |
| # v67-TBS-B (v73완화): FT=TLB + yellow_halo>0.50 → TBS | |
| if ft_top1_lbl == "Tomato Late Blight": | |
| yb = float(_np.sum((h>=15)&(h<=40)&(s>80)) / tot) | |
| if yb > 0.50: | |
| return "Tomato Bacterial Spot" | |
| # v73-SLS-TBS: FT=TBS + H<30 + S>120 + yb>0.55 → SLS | |
| if ft_top1_lbl == "Tomato Bacterial Spot": | |
| h_mean = float(h.mean()) | |
| s_mean = float(s.mean()) | |
| yb = float(_np.sum((h>=15)&(h<=40)&(s>80)) / tot) | |
| if h_mean < 30 and s_mean > 120 and yb > 0.55: | |
| return "Tomato Septoria Leaf Spot" | |
| # v61-SLS: FT=SLS + H<30 + S>120 + yb>0.65 (원본), v73완화: yb>0.55 | |
| # (위 v73-SLS-TBS와 통합됨, FT=SLS는 그대로 유지) | |
| # v62-TLM: FT=TLM + h_std<10 + h<45 + hsg>0.55 → TLM 유지 (이미 맞음) | |
| # v64-TLM-A: FT=SLS + brown>0.45 + bright>0.75 → TLM | |
| if ft_top1_lbl == "Tomato Septoria Leaf Spot": | |
| brown = float(_np.sum((h>=8)&(h<=25)&(s>80)&(v>80)) / tot) | |
| bright = float(_np.sum(v > 180) / tot) | |
| if brown > 0.45 and bright > 0.75: | |
| return "Tomato Leaf Mold" | |
| # v64-TLM-B: FT=SLS + olive>0.20 + lap<150 → TLM (복잡도 낮은 올리브) | |
| if ft_top1_lbl == "Tomato Septoria Leaf Spot": | |
| olive = float(_np.sum((h>=20)&(h<=45)&(s>40)&(v>80)) / tot) | |
| if olive > 0.20: | |
| try: | |
| import cv2 as _cv2b | |
| gray = _cv2b.cvtColor(img_arr, _cv2b.COLOR_RGB2GRAY) | |
| lap = float(_cv2b.Laplacian(gray, _cv2b.CV_64F).var()) | |
| if lap < 150: | |
| return "Tomato Leaf Mold" | |
| except Exception: | |
| pass | |
| # v65-SLS: FT=TLM? + green>0.89 + lap<180 + s_mean>100 → SLS | |
| if ft_top1_lbl == "Tomato Leaf Mold": | |
| gr = float(_np.sum((h>=35)&(h<=85)&(s>30)) / tot) | |
| s_mean = float(s.mean()) | |
| if gr > 0.89 and s_mean > 100: | |
| try: | |
| import cv2 as _cv2c | |
| gray = _cv2c.cvtColor(img_arr, _cv2c.COLOR_RGB2GRAY) | |
| lap = float(_cv2c.Laplacian(gray, _cv2c.CV_64F).var()) | |
| if lap < 180: | |
| return "Tomato Septoria Leaf Spot" | |
| except Exception: | |
| pass | |
| # v66-PEB: FT=PEB + brown>0.40 + yb>0.50 → PEB 유지 | |
| # v70-PBS-A: FT=SLS/HT + green_broad>0.85 → PBS | |
| if ft_top1_lbl in ("Tomato Septoria Leaf Spot", "Healthy Tomato"): | |
| gr_broad = float(_np.sum((h>=25)&(h<=95)&(s>20)) / tot) | |
| if gr_broad > 0.85: | |
| return "Pepper Bacterial Spot" | |
| return ft_top1_lbl | |
| except Exception as e: | |
| return ft_top1_lbl | |
| def _predict_ensemble(pil_img, plant_filter=None): | |
| """TTA + 앙상블 예측. plant_filter가 있으면 해당 식물 클래스만 후보로 사용.""" | |
| if not _models: | |
| return None, 0.0 | |
| device = next(_models[0][0].parameters()).device | |
| # TTA 배치 준비 | |
| tensors = [tf(pil_img) for tf in TTA_TF] | |
| batch = torch.stack(tensors).to(device) | |
| logits_sum = torch.zeros(NUM_CLASSES, device=device) | |
| with torch.no_grad(): | |
| for model, weight, desc in _models: | |
| outs = model(batch) # [TTA, 38] | |
| avg = outs.mean(0) # [38] | |
| logits_sum += avg * weight | |
| # 칼리브레이션 | |
| for idx, factor in CALIBRATION.items(): | |
| logits_sum[idx] = logits_sum[idx] * factor | |
| # 식물종 필터 — 선택한 작물 클래스만 활성화 | |
| if plant_filter: | |
| mask = torch.full((NUM_CLASSES,), float('-inf'), device=device) | |
| for idx, label in ID2LABEL.items(): | |
| if plant_filter.lower() in label.lower(): | |
| mask[idx] = 0.0 | |
| logits_sum = logits_sum + mask | |
| probs = torch.softmax(logits_sum, dim=0) | |
| top3_probs, top3_ids = probs.topk(3) | |
| top3 = [(ID2LABEL[int(i)], float(p)) for i, p in zip(top3_ids, top3_probs)] | |
| return top3, float(probs.max()) | |
| # ── Main diagnosis function ────────────────────────────────────────────────── | |
| def diagnose(image, language, plant_type="Auto-detect"): | |
| t0 = time.time() | |
| if image is None: | |
| return {}, "🌿 식물 이미지를 업로드해주세요 / Please upload a plant image" | |
| # 모델 로드 대기 | |
| timeout = 120 | |
| waited = 0 | |
| while not _loaded and waited < timeout: | |
| time.sleep(1) | |
| waited += 1 | |
| if not _models: | |
| return {}, f"❌ Model loading failed: {_load_status}" | |
| # PIL 변환 | |
| if not isinstance(image, Image.Image): | |
| image = Image.fromarray(image) | |
| pil_img = image.convert("RGB") | |
| # 식물종 필터 매핑 | |
| PLANT_KEYWORD_MAP = { | |
| "Apple": "Apple", "Blueberry": "Blueberry", "Cherry": "Cherry", | |
| "Corn": "Corn", "Maize": "Corn", "Grape": "Grape", "Orange": "Orange", | |
| "Peach": "Peach", "Pepper": "Pepper", "Potato": "Potato", | |
| "Raspberry": "Raspberry", "Soybean": "Soybean", "Squash": "Squash", | |
| "Strawberry": "Strawberry", "Tomato": "Tomato", | |
| } | |
| selected_plant = None | |
| if plant_type and "Auto-detect" not in plant_type: | |
| for kw, plant in PLANT_KEYWORD_MAP.items(): | |
| if kw.lower() in plant_type.lower(): | |
| selected_plant = plant | |
| break | |
| # 예측 | |
| top3, conf = _predict_ensemble(pil_img, plant_filter=selected_plant) | |
| elapsed = time.time() - t0 | |
| if top3 is None: | |
| return {}, "❌ Prediction failed" | |
| # 레이블 변환 | |
| top3_mapped = [] | |
| for raw_label, prob in top3: | |
| our_label = CNN_TO_OURS.get(raw_label, raw_label) | |
| top3_mapped.append((our_label, prob)) | |
| best_label, best_prob = top3_mapped[0] | |
| # v73 HSV 시각 교정 (FT 앙상블 top1 기반) | |
| corrected_label = _apply_hsv_corrections(pil_img, best_label) | |
| if corrected_label != best_label: | |
| print(f"[CropDoc] HSV correction: {best_label} → {corrected_label}") | |
| best_label = corrected_label | |
| # top3 첫 번째 항목도 교정된 레이블로 업데이트 | |
| top3_mapped[0] = (best_label, best_prob) | |
| is_healthy = best_label in HEALTHY_SET | |
| info = DISEASE_INFO.get(best_label, ("ℹ️ Unknown", "Consult a local agronomist.", "LOW")) | |
| disease_type, treatment, severity = info[0], info[1], info[2] if len(info) > 2 else "MED" | |
| # 심각도 이모지 | |
| sev_emoji = {"HIGH": "🔴", "MED": "🟠", "LOW": "✅"}.get(severity, "🟡") | |
| # 한국어 레이블 | |
| ko_label = KO_MAP.get(best_label, best_label) | |
| # 언어별 출력 | |
| if language == "한국어 (Korean)": | |
| if is_healthy: | |
| detail = f"""## ✅ {ko_label} | |
| **신뢰도**: {best_prob*100:.1f}% | |
| **상태**: 건강한 식물 | |
| 이 식물은 건강해 보입니다. 예방 차원에서 정기적으로 관찰하고 적절한 물주기와 시비를 유지하세요. | |
| ⏱️ 진단 시간: {elapsed*1000:.0f}ms""" | |
| else: | |
| detail = f"""## {sev_emoji} {ko_label} | |
| **신뢰도**: {best_prob*100:.1f}% | **유형**: {disease_type} | **심각도**: {severity} | |
| ### 💊 방제 방법 | |
| {treatment} | |
| ### 📊 Top 3 후보 | |
| """ | |
| for i, (lbl, prob) in enumerate(top3_mapped): | |
| ko = KO_MAP.get(lbl, lbl) | |
| detail += f"- **{ko}** ({prob*100:.1f}%)\n" | |
| detail += f"\n⏱️ 진단 시간: {elapsed*1000:.0f}ms | 🤖 모델: EfficientNetV2-S Ensemble (v73)" | |
| else: | |
| if is_healthy: | |
| detail = f"""## ✅ {best_label} | |
| **Confidence**: {best_prob*100:.1f}% | |
| **Status**: Healthy Plant | |
| This plant appears healthy! Keep monitoring regularly and maintain proper watering and fertilization. | |
| ⏱️ Inference: {elapsed*1000:.0f}ms""" | |
| else: | |
| detail = f"""## {sev_emoji} {best_label} | |
| **Confidence**: {best_prob*100:.1f}% | **Type**: {disease_type} | **Severity**: {severity} | |
| ### 💊 Treatment | |
| {treatment} | |
| ### 📊 Top 3 Candidates | |
| """ | |
| for i, (lbl, prob) in enumerate(top3_mapped): | |
| detail += f"- **{lbl}** ({prob*100:.1f}%)\n" | |
| detail += f"\n⏱️ Inference: {elapsed*1000:.0f}ms | 🤖 EfficientNetV2-S Ensemble (v73)" | |
| # Gradio Label 컴포넌트용 딕셔너리 | |
| label_dict = {lbl: prob for lbl, prob in top3_mapped} | |
| return label_dict, detail | |
| # ── Gradio UI ──────────────────────────────────────────────────────────────── | |
| CSS = """ | |
| .main-header { | |
| text-align: center; | |
| background: linear-gradient(135deg, #2d6a4f 0%, #52b788 100%); | |
| color: white; | |
| padding: 20px; | |
| border-radius: 12px; | |
| margin-bottom: 16px; | |
| } | |
| .main-header h1 { font-size: 2.2rem; margin: 0; } | |
| .main-header p { font-size: 1rem; margin: 4px 0 0; opacity: 0.9; } | |
| .result-box { | |
| background: #f8fffe; | |
| border: 1px solid #b7e4c7; | |
| border-radius: 12px; | |
| padding: 16px; | |
| } | |
| .upload-area { min-height: 300px; } | |
| footer { display: none !important; } | |
| """ | |
| EXAMPLES = [ | |
| ["https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Phytophthora_infestans_on_tomato.jpg/320px-Phytophthora_infestans_on_tomato.jpg"], | |
| ] | |
| with gr.Blocks( | |
| theme=gr.themes.Soft( | |
| primary_hue="green", | |
| secondary_hue="teal", | |
| ), | |
| css=CSS, | |
| title="CropDoc — AI Plant Disease Diagnosis", | |
| ) as demo: | |
| gr.HTML(""" | |
| <div class="main-header"> | |
| <h1>🌿 CropDoc</h1> | |
| <p>AI Plant Disease Diagnosis · 38 Disease Classes · EfficientNetV2 Ensemble v73</p> | |
| </div> | |
| """) | |
| # 지원 작물 목록 | |
| SUPPORTED_PLANTS = [ | |
| "🔍 Auto-detect (자동감지)", | |
| "🍎 Apple (사과)", | |
| "🔵 Blueberry (블루베리)", | |
| "🍒 Cherry (체리)", | |
| "🌽 Corn / Maize (옥수수)", | |
| "🍇 Grape (포도)", | |
| "🍊 Orange (오렌지)", | |
| "🍑 Peach (복숭아)", | |
| "🟢 Bell Pepper (피망/고추)", | |
| "🥔 Potato (감자)", | |
| "🔴 Raspberry (라즈베리)", | |
| "🌱 Soybean (콩)", | |
| "🎃 Squash (호박)", | |
| "🍓 Strawberry (딸기)", | |
| "🍅 Tomato (토마토)", | |
| ] | |
| gr.HTML(""" | |
| <div style="background:#fff8e1;border-left:4px solid #ffc107;padding:12px 16px;border-radius:8px;margin-bottom:12px;"> | |
| <b>⚠️ 지원 작물 안내 / Supported Crops</b><br> | |
| 이 앱은 아래 <b>14종 작물</b>의 병해를 진단합니다. 지원하지 않는 식물은 오진될 수 있습니다.<br> | |
| <small>This app diagnoses diseases for <b>14 specific crops</b> only. Other plants may be misclassified.</small><br> | |
| <br> | |
| 🍎 Apple 🫐 Blueberry 🍒 Cherry 🌽 Corn 🍇 Grape 🍊 Orange 🍑 Peach<br> | |
| 🫑 Pepper 🥔 Potato 🫐 Raspberry 🌱 Soybean 🎃 Squash 🍓 Strawberry 🍅 Tomato | |
| </div> | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| plant_select = gr.Dropdown( | |
| choices=SUPPORTED_PLANTS, | |
| value="🔍 Auto-detect (자동감지)", | |
| label="🌿 Select Crop / 작물 선택 (선택 시 정확도 향상)", | |
| info="작물을 먼저 선택하면 더 정확하게 진단합니다", | |
| ) | |
| img_input = gr.Image( | |
| type="pil", | |
| label="📸 Plant Photo / 식물 사진", | |
| elem_classes="upload-area", | |
| sources=["upload", "webcam", "clipboard"], | |
| height=300, | |
| ) | |
| lang_select = gr.Radio( | |
| choices=["English", "한국어 (Korean)"], | |
| value="English", | |
| label="🌐 Language / 언어", | |
| ) | |
| btn = gr.Button( | |
| "🔍 Diagnose / 진단하기", | |
| variant="primary", | |
| size="lg", | |
| ) | |
| with gr.Column(scale=1): | |
| label_output = gr.Label( | |
| label="🎯 Diagnosis Result / 진단 결과", | |
| num_top_classes=3, | |
| ) | |
| detail_output = gr.Markdown( | |
| label="📋 Details / 상세 정보", | |
| elem_classes="result-box", | |
| value="Upload a plant image to get started.\n식물 이미지를 업로드하면 진단을 시작합니다.", | |
| ) | |
| btn.click( | |
| fn=diagnose, | |
| inputs=[img_input, lang_select, plant_select], | |
| outputs=[label_output, detail_output], | |
| ) | |
| img_input.change( | |
| fn=diagnose, | |
| inputs=[img_input, lang_select, plant_select], | |
| outputs=[label_output, detail_output], | |
| ) | |
| gr.Markdown(""" | |
| --- | |
| ### 🌾 About CropDoc | |
| CropDoc uses an **EfficientNetV2-S ensemble** (v73) trained on PlantVillage dataset (54,000+ images, 38 classes). | |
| - **Base model**: EfficientNetV2-S (val_acc=99.91%) | |
| - **Fine-tuned models**: FT v3/v4/v5/v6 (PlantDoc field images) | |
| - **Ensemble**: Weighted average with calibration | |
| - **ZeroGPU**: NVIDIA H200, ~50ms per inference | |
| > ⚠️ For critical decisions, consult a professional agronomist. | |
| """) | |
| if __name__ == "__main__": | |
| demo.launch(server_name="0.0.0.0", server_port=7860) | |