diff --git a/.dockerignore b/.dockerignore index f0d37aa..9f88819 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,6 @@ venv/ **/venv/ **/__pycache__/ *.pyc -ui/ docs/ .git/ .github/ diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml deleted file mode 100644 index 988ba6a..0000000 --- a/.github/workflows/cd-prod.yml +++ /dev/null @@ -1,60 +0,0 @@ -# .github/workflows/cd-prod.yml -name: CD - Продукција -on: - push: - branches: [ main ] - workflow_dispatch: - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - environment: production # везује за producton окружење - - steps: - - name: Code download - uses: actions/checkout@v4 - - - name: Settings Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: 'pip' - - - name: Dependacy installation - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - # Ако користите setuptools за паковање: - python setup.py sdist bdist_wheel - # или ако само желите да спакујете цео код: - tar -czf app.tar.gz src/ static/ templates/ requirements.txt - - - name: Server deployment (SCP) - uses: appleboy/scp-action@v0.1.4 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: ${{ secrets.DEPLOY_USER }} - key: ${{ secrets.DEPLOY_SSH_KEY }} - port: ${{ secrets.DEPLOY_PORT }} - source: "app.tar.gz" # име архиве - target: ${{ secrets.DEPLOY_PATH }} - rm: true # брише старе фајлове пре слања - - - name: Start application on server - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: ${{ secrets.DEPLOY_USER }} - key: ${{ secrets.DEPLOY_SSH_KEY }} - port: ${{ secrets.DEPLOY_PORT }} - script: | - cd ${{ secrets.DEPLOY_PATH }} - tar -xzf app.tar.gz - rm app.tar.gz - # Активирај виртуелно окружење и инсталирај зависности (ако треба) - python3 -m venv venv - source venv/bin/activate - pip install -r requirements.txt - # Поново покрени сервис (нпр. systemd) - sudo systemctl restart my-python-app \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..b39b1e2 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,89 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + workflow_dispatch: # used to trigger the OPTIONAL publish/deploy jobs by hand + +env: + API_IMAGE: plant-monitor-api + GUI_IMAGE: plant-monitor-gui + +jobs: + # LINT — runs on every push and pull request + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install lint tools + run: pip install flake8 black + + - name: Lint with flake8 (syntax errors only) + run: | + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics \ + --exclude=.git,__pycache__,.venv,venv,build,dist + + # Reports formatting issues but does NOT fail the build. + # Remove "continue-on-error" once your code is fully black-formatted. + - name: Check formatting with black (informational) + run: black --check --diff . + continue-on-error: true + + # BUILD IMAGES — proves both Docker images build, No push + build-images: + needs: lint + runs-on: ubuntu-latest + strategy: + matrix: + service: [api, gui] + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.service }} image (no push) + uses: docker/build-push-action@v5 + with: + context: . + file: ./docker/Dockerfile.${{ matrix.service }} + push: false + tags: ${{ matrix.service }}:ci + + + # publish images to Docker Hub. + publish: + needs: build-images + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + strategy: + matrix: + service: [api, gui] + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push ${{ matrix.service }} + uses: docker/build-push-action@v5 + with: + context: . + file: ./docker/Dockerfile.${{ matrix.service }} + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.service == 'api' && env.API_IMAGE || env.GUI_IMAGE }}:latest + ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.service == 'api' && env.API_IMAGE || env.GUI_IMAGE }}:${{ github.sha }} \ No newline at end of file diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml deleted file mode 100644 index 5d81cc9..0000000 --- a/.github/workflows/ci-dev.yml +++ /dev/null @@ -1,39 +0,0 @@ -# .github/workflows/ci-dev.yml -name: CI - Development branch -on: - push: - branches: [ develop ] - pull_request: - branches: [ develop ] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] # прилагодите верзије - - steps: - - name: Code download - uses: actions/checkout@v4 - - - name: Python settings ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' # кешира pip пакете, убрзава рад - - - name: Dependancy installation - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pytest pytest-cov # ако нису већ у requirements.txt - - - name: Starting tests with coverage - run: pytest --cov=./ --cov-report=xml --cov-report=html - - - name: Coverage report - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: htmlcov/index.html \ No newline at end of file diff --git a/.gitignore b/.gitignore index 683dd6b..aa5a535 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ __pycache__/ *.json !plants_data.json __pycache__/ +ui/plants_data.json diff --git a/API/api.py b/API/api.py index 2f344f1..a0d9bcf 100644 --- a/API/api.py +++ b/API/api.py @@ -9,6 +9,14 @@ from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field import uvicorn +from growth_color_models import GrowthPredictor, ColorPredictor +try: + import torch + TORCH_OK = True + +except ImportError: + TORCH_OK = False + print("[Torch] PyTorch not installed — growth/colour prediction unavailable.") # Plant data PLANTS_CSV_DATA = """name,pet_safe,space,water,sunlight,temperature,pollen_allergies,existing_plants @@ -155,22 +163,25 @@ class PlantRecommendation(BaseModel): score: float # Plant health detection +# Full class set kept for the colour-ensemble fallback. CNN_CLASSES = [ "Healthy", "Nutrient Deficiency", "Disease Detected", "Overwatered", "Needs Water", "Pest Infestation", "Root Rot", "Dead", ] CNN_META = { - "Healthy": ("Low", "#00e5a0", "OK", "Strong green foliage, no stress signs."), - "Needs Water": ("Medium", "#4fc3f7", "H2O", "Pale appearance indicates dehydration."), - "Overwatered": ("High", "#b48eff", "WET", "Dark, waterlogged tissue detected."), - "Disease Detected": ("High", "#ff5c6a", "BIO", "Browning indicates leaf blight or fungal infection."), - "Pest Infestation": ("Medium", "#ffd166", "BUG", "Yellow spots with brown specks — pest damage."), - "Nutrient Deficiency": ("Medium", "#ffd166", "NUT", "Yellowing indicates nutrient deficiency."), - "Root Rot": ("High", "#ff5c6a", "ROT", "Dark tissue at base — root rot."), - "Dead": ("Critical", "#888888", "RIP", "Very little living tissue detected."), + "Healthy": ("Low", "#00e5a0", "OK", "Strong green foliage, no stress signs."), + "Unhealthy": ("Medium", "#ffd166", "WARN", "Signs of stress — wilting or discolouration."), + "Needs Water": ("Medium", "#4fc3f7", "H2O", "Pale appearance indicates dehydration."), + "Overwatered": ("High", "#b48eff", "WET", "Dark, waterlogged tissue detected."), + "Disease Detected": ("High", "#ff5c6a", "BIO", "Browning indicates leaf blight or fungal infection."), + "Pest Infestation": ("Medium", "#ffd166", "BUG", "Yellow spots with brown specks — pest damage."), + "Nutrient Deficiency": ("Medium", "#ffd166", "NUT", "Yellowing indicates nutrient deficiency."), + "Root Rot": ("High", "#ff5c6a", "ROT", "Dark tissue at base — root rot."), + "Dead": ("Critical", "#888888", "RIP", "Very little living tissue detected."), } CNN_SYMPTOMS = { "Healthy": ["Green healthy foliage", "No yellowing or browning", "Good colour saturation"], + "Unhealthy": ["Wilting or drooping foliage", "Discolouration or loss of firmness", "Reduced green tissue"], "Needs Water": ["Pale, washed-out leaf surface", "Low colour saturation", "Possible wilting"], "Overwatered": ["Dark/blackened tissue", "Possible soft stem", "Brown edges"], "Disease Detected": ["Brown/necrotic tissue", "Possible lesions or tip burn", "Reduced green tissue"], @@ -181,6 +192,7 @@ class PlantRecommendation(BaseModel): } CNN_RECS = { "Healthy": ["Continue current care.", "Rotate every 2 weeks.", "Wipe leaves monthly."], + "Unhealthy": ["Check soil moisture and water if dry.", "Move to appropriate light.", "Inspect for pests or disease.", "Remove damaged leaves."], "Needs Water": ["Water thoroughly until drainage.", "Move to bright indirect light.", "Check soil moisture daily."], "Overwatered": ["Stop watering immediately.", "Inspect and trim rotted roots.", "Repot in well-draining mix."], "Disease Detected": ["Isolate the plant.", "Remove brown leaves with sterilised scissors.", "Apply fungicide/neem oil."], @@ -190,7 +202,9 @@ class PlantRecommendation(BaseModel): "Dead": ["Check for any surviving green stems.", "Cut away dead material.", "Propagate any healthy cuttings."], } -CNN_MODEL_PATH = Path("plant_health_cnn.keras") +CNN_MODEL_PATH = Path(__file__).resolve().parent.parent / "plant_health_cnn.keras" +# The trained .keras model is binary: index 0 = Healthy, 1 = Unhealthy. +CNN_BINARY_CLASSES = ["Healthy", "Unhealthy"] _model_cache = None _model_lock = threading.Lock() @@ -207,9 +221,12 @@ def _get_model(): if CNN_MODEL_PATH.exists(): try: _model_cache = (tf.keras.models.load_model(str(CNN_MODEL_PATH)), True) + print(f"[CNN] Loaded trained model from {CNN_MODEL_PATH}") return _model_cache - except Exception: - pass + except Exception as ex: + print(f"[CNN] Failed to load {CNN_MODEL_PATH}: {ex}") + else: + print(f"[CNN] Model file not found at {CNN_MODEL_PATH}") base = EfficientNetB0(input_shape=(224, 224, 3), include_top=False, weights="imagenet") base.trainable = False inputs = tf.keras.Input(shape=(224, 224, 3)) @@ -257,9 +274,23 @@ def _infer(pil_img): from tensorflow.keras.applications.efficientnet import decode_predictions, preprocess_input img_resized = pil_img.convert("RGB").resize((224,224)) arr = np.expand_dims(img_to_array(img_resized), 0) + n_out = len(CNN_CLASSES) + estimated = False if finetuned: p = model.predict(arr, verbose=0); idx = int(np.argmax(p[0])) - cls = CNN_CLASSES[idx]; conf = max(50, min(93, int(p[0][idx]*100))); acc = "~91-93%" + n_out = int(p.shape[-1]) + conf = max(50, min(93, int(p[0][idx]*100))); acc = "~90%" + if n_out == len(CNN_BINARY_CLASSES): + if idx == 0: + cls = "Healthy" + else: + # Trained model says Unhealthy. That call is the reliable, trained + # part; here we ESTIMATE the likely cause from a colour heuristic. + cause, _ = _colour_diagnosis(pil_img) + cls = "Unhealthy" if cause == "Healthy" else cause + estimated = True + else: + cls = CNN_CLASSES[idx] else: c_cls, c_sc = _colour_diagnosis(pil_img) try: @@ -283,61 +314,54 @@ def _infer(pil_img): cls = max(v,key=v.get); norm = v[cls]/(W_C+W_N) conf = max(88,min(93,int(88+(norm-0.50)*10))); acc = "~88-91%" urgency,color,badge,summary = CNN_META.get(cls,("Low","#888","?","Unknown.")) + if estimated and cls != "Unhealthy": + summary = "Estimated cause (colour analysis): " + summary + model_name = "EfficientNetB0 (fine-tuned)" + if finetuned and estimated: + model_name = "EfficientNetB0 (fine-tuned) + colour-heuristic cause estimate" + elif not finetuned: + model_name = "EfficientNetB0 + Colour Ensemble" return { "status": cls, "confidence": conf, "urgency": urgency, "color": color, "badge": badge, "summary": summary, "symptoms": CNN_SYMPTOMS.get(cls,[]), "recommendations": CNN_RECS.get(cls,[]), - "_cnn_info": {"model": "EfficientNetB0 (fine-tuned)" if finetuned else "EfficientNetB0 + Colour Ensemble", - "input_size": "224x224", "classes": len(CNN_CLASSES), - "is_pretrained": finetuned, "accuracy_est": acc}, + "_cnn_info": {"model": model_name, + "input_size": "224x224", "classes": n_out, + "is_pretrained": finetuned, "estimated_cause": estimated, + "accuracy_est": acc}, } # Growth / colour model classes (inline — no external file needed) -try: - import torch - import torch.nn as nn - - class GrowthPredictor(nn.Module): - """Simple MLP that predicts height growth (cm) from 9 numeric features.""" - def __init__(self, input_size: int = 9): - super().__init__() - self.net = nn.Sequential( - nn.Linear(input_size, 128), nn.ReLU(), - nn.Linear(128, 64), nn.ReLU(), - nn.Linear(64, 32), nn.ReLU(), - nn.Linear(32, 1), - ) - self.register_buffer("X_mean", torch.zeros(input_size)) - self.register_buffer("X_std", torch.ones(input_size)) - - def forward(self, x: "torch.Tensor") -> "torch.Tensor": - return self.net(x).squeeze(-1) - - class ColorPredictor(nn.Module): - """Simple MLP that predicts plant colour class (5 classes) from 14 features.""" - def __init__(self, input_size: int = 14, hidden_size: int = 32): - super().__init__() - self.net = nn.Sequential( - nn.Linear(input_size, hidden_size), nn.ReLU(), - nn.Linear(hidden_size, hidden_size), nn.ReLU(), - nn.Linear(hidden_size, 5), - ) - self.register_buffer("X_mean", torch.zeros(input_size)) - self.register_buffer("X_std", torch.ones(input_size)) - - def forward(self, x: "torch.Tensor") -> "torch.Tensor": - return self.net(x) - TORCH_OK = True - print("[Torch] GrowthPredictor and ColorPredictor defined successfully.") +class DataVector(BaseModel): + days_passed: float + avg_direct_light: float + avg_indirect_light: float + avg_nighttime: float + avg_temp: float + min_temp: float + max_temp: float + times_watered: float + initial_height: float + color_before: List[int] -except ImportError: - TORCH_OK = False - print("[Torch] PyTorch not installed — growth/colour prediction unavailable.") +# Loading grwoth/color models + +growth_model = GrowthPredictor() +color_model = ColorPredictor() + +_API_DIR = Path(__file__).resolve().parent +color_checkpoint = torch.load(_API_DIR / "weights/health_model.pth", weights_only=True) +growth_checpoint = torch.load(_API_DIR / "weights/regression_model.pth", weights_only=True) +color_model.load_state_dict(color_checkpoint['model_state']) +growth_model.out.load_state_dict(growth_checpoint) + +X_mean = color_checkpoint['X_mean'] +X_std = color_checkpoint['X_std'] + # Pydantic model – growth / colour prediction class DataVector(BaseModel): - user_id: Optional[int] = None days_passed: float avg_direct_light: float avg_indirect_light: float @@ -349,45 +373,6 @@ class DataVector(BaseModel): initial_height: float color_before: List[int] -# Load / initialise growth models at startup -_growth_model = None -_color_model = None - -GROWTH_MODEL_PATH = Path("growth_predictor.pt") -COLOR_MODEL_PATH = Path("color_predictor.pt") - -def _load_torch_models(): - global _growth_model, _color_model - if not TORCH_OK: - print("[Torch] Skipping model load — PyTorch not available.") - return - try: - import torch - gm = GrowthPredictor(9) - cm = ColorPredictor(14, 32) - - # Load saved weights if checkpoint files exist - if GROWTH_MODEL_PATH.exists(): - state = torch.load(str(GROWTH_MODEL_PATH), map_location="cpu") - gm.load_state_dict(state, strict=False) - print(f"[Torch] Loaded growth weights from {GROWTH_MODEL_PATH}") - else: - print("[Torch] No growth checkpoint found — using untrained weights (random predictions).") - - if COLOR_MODEL_PATH.exists(): - state = torch.load(str(COLOR_MODEL_PATH), map_location="cpu") - cm.load_state_dict(state, strict=False) - print(f"[Torch] Loaded colour weights from {COLOR_MODEL_PATH}") - else: - print("[Torch] No colour checkpoint found — using untrained weights (random predictions).") - - gm.eval(); cm.eval() - _growth_model = gm - _color_model = cm - print("[Torch] Growth and colour models ready.") - except Exception as e: - print(f"[Torch] Model load failed: {e}") - # FastAPI app app = FastAPI(title="Plant Care Unified API", version="2.0.0") @@ -398,10 +383,6 @@ def _load_torch_models(): allow_headers=["*"], ) -@app.on_event("startup") -def _startup(): - _load_torch_models() - @app.get("/", include_in_schema=False) def root(): return RedirectResponse(url="/docs") @@ -411,9 +392,9 @@ def health(): return { "success": True, "status": "ok", "version": "2.0.0", "models": { - "detection": "loaded", - "growth": "loaded" if _growth_model else "unavailable", - "colour": "loaded" if _color_model else "unavailable", + "detection": "trained" if CNN_MODEL_PATH.exists() else "fallback", + "growth": "loaded" if growth_model else "unavailable", + "colour": "loaded" if color_model else "unavailable", }, } @@ -466,47 +447,40 @@ def detect_b64(body: DetectBase64Request): return res except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/fwd") + +# --- Prediction endpoints --- @app.post("/growth") async def predict_growth(vector: DataVector): if not TORCH_OK: raise HTTPException(status_code=503, detail="PyTorch is not installed. Run: pip install torch") - if _growth_model is None or _color_model is None: + if growth_model is None or color_model is None: raise HTTPException(status_code=503, detail="Growth/colour models failed to initialise. Check server logs.") - import torch - numeric = [ - vector.days_passed, vector.avg_direct_light, vector.avg_indirect_light, - vector.avg_nighttime, vector.avg_temp, vector.min_temp, vector.max_temp, - vector.times_watered, vector.initial_height, - ] - color_oh = list(vector.color_before) + flat_vector = list(vector.model_dump().values()) + flat_vector.pop() + inp = torch.tensor(flat_vector, dtype=torch.float32).unsqueeze(0) - inp_growth = torch.tensor(numeric, dtype=torch.float32).unsqueeze(0) - inp_color = torch.tensor(numeric + color_oh, dtype=torch.float32).unsqueeze(0) - - try: - inp_growth_n = (inp_growth - _growth_model.X_mean) / (_growth_model.X_std + 1e-8) - except Exception: - inp_growth_n = inp_growth - try: - inp_color_n = (inp_color - _color_model.X_mean) / (_color_model.X_std + 1e-8) - except Exception: - inp_color_n = inp_color + inp_norm = (inp - X_mean) / (X_std) + + inp_c = torch.tensor(flat_vector).unsqueeze(0) + inp_cb = torch.tensor(vector.color_before).unsqueeze(0) + inp_c_norm = (inp_c - X_mean) / (X_std) + inp_c_final = torch.cat([inp_c_norm, inp_cb], dim=1) + inp_norm = torch.cat([inp_norm, inp_cb], dim=1) + growth_model.eval() + color_model.eval() with torch.no_grad(): - growth_pred = float(_growth_model(inp_growth_n).item()) - logits = _color_model(inp_color_n) - color_idx = int(torch.argmax(torch.softmax(logits, dim=1), dim=1).item()) + pred = growth_model(inp_norm).item() + + logits = color_model(inp_c_final) + probs = torch.softmax(logits, dim=1) + color = torch.argmax(probs, dim=1).item() + + return {"guess" : pred, "color": color} - return { - "guess": round(growth_pred, 3), - "color": color_idx, - "inputs": {"numeric": numeric, "color_before": color_oh}, - } # Entry point if __name__ == "__main__": diff --git a/API/growth_color_models.py b/API/growth_color_models.py new file mode 100644 index 0000000..5a5510e --- /dev/null +++ b/API/growth_color_models.py @@ -0,0 +1,25 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + +class GrowthPredictor(nn.Module): + def __init__(self): + super().__init__() + self.out = nn.Linear(14, 1) + + def forward(self, x): + return self.out(x) + +class ColorPredictor(nn.Module): + def __init__(self): + super().__init__() + self.net = nn.Sequential( + nn.Linear(14, 64), + nn.ReLU(), + nn.Linear(64, 32), + nn.ReLU(), + nn.Linear(32, 5) + ) + + def forward(self, x): + return self.net(x) diff --git a/API/weights/health_model.pth b/API/weights/health_model.pth new file mode 100644 index 0000000..7e42abe Binary files /dev/null and b/API/weights/health_model.pth differ diff --git a/API/weights/regression_model.pth b/API/weights/regression_model.pth new file mode 100644 index 0000000..80311ca Binary files /dev/null and b/API/weights/regression_model.pth differ diff --git a/docker/requirements-gui.txt b/docker/requirements-gui.txt index 875a849..3cae530 100644 --- a/docker/requirements-gui.txt +++ b/docker/requirements-gui.txt @@ -2,4 +2,5 @@ matplotlib numpy pillow scipy -requests \ No newline at end of file +requests +tensorflow>=2.12.0 \ No newline at end of file diff --git a/models/detection.py b/models/detection.py index b139e85..c8da255 100644 --- a/models/detection.py +++ b/models/detection.py @@ -22,15 +22,21 @@ def api_detect(image_path: str) -> dict | None: pass return None -# CNN classes +# CNN classes (full set kept for the colour-ensemble fallback) CNN_CLASSES = [ "Healthy","Nutrient Deficiency","Disease Detected", "Overwatered","Needs Water","Pest Infestation","Root Rot","Dead", ] + +# The trained .keras model is binary: index 0 = Healthy, 1 = Unhealthy. +# (Trained on whole-plant houseplant images: healthy vs wilted/unhealthy.) +CNN_BINARY_CLASSES = ["Healthy", "Unhealthy"] + # (colour, emoji, urgency, summary) — colours stored as hex so this module # carries no dependency on the UI palette. CNN_CLASS_META = { "Healthy": ("#00e5a0", "✅", "Low", "The plant shows strong, vibrant green foliage with no visible signs of stress."), + "Unhealthy": ("#fbbf24", "⚠", "Medium", "The plant shows signs of stress — wilting, drooping, or discolouration."), "Needs Water": ("#38bdf8", "💧", "Medium", "Pale appearance indicates dehydration or too much direct sunlight."), "Overwatered": ("#a78bfa", "🌊", "High", "Dark, waterlogged tissue — consistent with overwatering."), "Disease Detected": ("#f43f5e", "🦠", "High", "Extensive browning indicates leaf blight or fungal infection."), @@ -41,6 +47,7 @@ def api_detect(image_path: str) -> dict | None: } CNN_SYMPTOMS = { "Healthy": ["Predominantly green, healthy-looking foliage","No significant yellowing or browning","Good colour saturation indicates active chlorophyll"], + "Unhealthy": ["Wilting or drooping foliage","Discolouration or loss of firmness","Reduced healthy green tissue"], "Needs Water": ["Washed-out, pale colouration across leaf surface","Low colour saturation","Possible wilting or curling"], "Overwatered": ["Dark/blackened tissue detected","Possible soft stem","Brown edges consistent with root rot damage"], "Disease Detected": ["Brown/necrotic tissue across leaf surface","Possible lesions or tip burn","Reduced healthy green tissue"], @@ -51,6 +58,7 @@ def api_detect(image_path: str) -> dict | None: } CNN_RECS = { "Healthy": ["Continue your current watering and light schedule.","Rotate the plant every 2 weeks for even light exposure.","Wipe leaves monthly to maximise light absorption.","Monitor for any emerging spots or colour changes."], + "Unhealthy": ["Check soil moisture and water if the top 2–3 cm are dry.","Move the plant to appropriate light for its species.","Inspect for pests or disease on leaves and stems.","Remove damaged or dead leaves and monitor for recovery."], "Needs Water": ["Water thoroughly until it drains from the bottom.","If in direct sun, move to bright indirect light.","Check the top 2–3 cm of soil — water when dry.","Consider raising humidity with a pebble tray or misting."], "Overwatered": ["Stop watering immediately and let the soil dry out completely.","Remove the plant from the pot and inspect roots.","Repot in fresh, well-draining mix.","Ensure the new pot has adequate drainage holes."], "Disease Detected": ["Isolate the plant immediately to prevent spread.","Remove all visibly brown leaves with sterilised scissors.","Apply a copper-based fungicide or neem oil.","Reduce overhead watering — water at the base only."], @@ -101,9 +109,12 @@ def _load_or_build_model(model_path: str = CNN_MODEL_PATH): if os.path.exists(model_path): try: model = tf.keras.models.load_model(model_path) + print(f"[CNN] Loaded trained model from {model_path}") return model, True - except Exception: - pass + except Exception as ex: + print(f"[CNN] Failed to load {model_path}: {ex}") + else: + print(f"[CNN] Model file not found at {model_path}") model = _build_cnn_model() return model, False @@ -195,27 +206,50 @@ def _cnn_predict_image(image_path, model, is_pretrained, decode_fn): from tensorflow.keras.preprocessing import image as keras_image img = keras_image.load_img(image_path, target_size=(224,224)) arr = np.expand_dims(keras_image.img_to_array(img), axis=0) + n_out = len(CNN_CLASSES) + estimated = False if is_pretrained: preds = model.predict(arr, verbose=0) + n_out = int(preds.shape[-1]) class_idx = int(np.argmax(preds[0])) confidence= int(round(float(preds[0][class_idx])*100)) - status = CNN_CLASSES[class_idx] if confidence > 95: confidence = 93 elif confidence < 50: confidence = max(50, confidence+5) + # Trained model is binary (2 outputs); a freshly built model has len(CNN_CLASSES). + if n_out == len(CNN_BINARY_CLASSES): + if class_idx == 0: + status = "Healthy" + else: + # Trained model says Unhealthy. The healthy/unhealthy call is the + # reliable, trained part; here we ESTIMATE the likely cause from a + # colour heuristic (not a trained prediction). + cause, _ = _pixel_colour_diagnosis(image_path) + status = "Unhealthy" if cause == "Healthy" else cause + estimated = True + else: + status = CNN_CLASSES[class_idx] else: from tensorflow.keras.applications.efficientnet import decode_predictions as eff_decode status, confidence = _ensemble_predict(image_path, eff_decode) color, badge_emoji, urgency, summary = CNN_CLASS_META.get(status,("#8b95a8","?","Low","Unknown.")) + if estimated and status != "Unhealthy": + summary = "Estimated cause (colour analysis): " + summary + model_name = "EfficientNetB0 (fine-tuned)" + if is_pretrained and estimated: + model_name = "EfficientNetB0 (fine-tuned) + colour-heuristic cause estimate" + elif not is_pretrained: + model_name = "EfficientNetB0 + Colour Ensemble (zero-shot)" return { "status": status, "confidence": confidence, "urgency": urgency, "summary": summary, "symptoms": CNN_SYMPTOMS.get(status,[]), "recommendations": CNN_RECS.get(status,[]), "_cnn_info": { - "model": "EfficientNetB0 (fine-tuned)" if is_pretrained else "EfficientNetB0 + Colour Ensemble (zero-shot)", - "input_size": "224x224", - "classes": len(CNN_CLASSES), - "is_pretrained":is_pretrained, - "accuracy_est": "~91-93%" if is_pretrained else "~88-91%", + "model": model_name, + "input_size": "224x224", + "classes": n_out, + "is_pretrained": is_pretrained, + "estimated_cause":estimated, + "accuracy_est": "~90%" if is_pretrained else "~88-91%", }, } diff --git a/models/recommendation.py b/models/recommendation.py index 15448d3..eafe169 100644 --- a/models/recommendation.py +++ b/models/recommendation.py @@ -1,37 +1,182 @@ +""" +Plant recommendation engine with fuzzy matching and optional +light/temperature data import from .bin / .npz files. +""" + +import numpy as np +import os from database.plants import df_plants +# ---------------------------------------------------------------------- +# Original fuzzy‑matching helpers +# ---------------------------------------------------------------------- def _triangular_membership(x, center, spread): - if spread <= 0: return 1.0 if x == center else 0.0 - return max(0.0, 1.0 - abs(x-center)/spread) + if spread <= 0: + return 1.0 if x == center else 0.0 + return max(0.0, 1.0 - abs(x - center) / spread) def _fuzzy_match(user_val, plant_val, spread): return _triangular_membership(plant_val, user_val, spread) +# ---------------------------------------------------------------------- +# Public recommendation function +# ---------------------------------------------------------------------- def recommend_plants(user_prefs, top_n=5): - if df_plants is None: return [] + """ + Return a list of (plant_name, score) tuples sorted by score. + user_prefs is a dict that may contain: + water, sunlight, temp, pet_safe, space, + allergy_concern, existing_plants + """ + if df_plants is None: + return [] + scores = [] for _, plant in df_plants.iterrows(): - w_match = _fuzzy_match(user_prefs["water"], plant["water"], 2.0) - s_match = _fuzzy_match(user_prefs["sunlight"], plant["sunlight"], 2.0) - t_match = _fuzzy_match(user_prefs["temp"], plant["temperature"], 4.0) - p_match = (1.0 if plant["pet_safe"] == user_prefs["pet_safe"] else 0.0) if user_prefs.get("pet_safe") is not None else None - space_match = (1.0 if plant["space"] == user_prefs["space"] else 0.0) if user_prefs.get("space") is not None else None + w_match = _fuzzy_match(user_prefs["water"], plant["water"], 2.0) + s_match = _fuzzy_match(user_prefs["sunlight"], plant["sunlight"], 2.0) + t_match = _fuzzy_match(user_prefs["temp"], plant["temperature"], 4.0) + + p_match = (1.0 if plant["pet_safe"] == user_prefs["pet_safe"] else 0.0) \ + if user_prefs.get("pet_safe") is not None else None + + space_match = (1.0 if plant["space"] == user_prefs["space"] else 0.0) \ + if user_prefs.get("space") is not None else None + if user_prefs.get("allergy_concern") is not None: - a_match = (1.0 if not plant["pollen_allergies"] else 0.0) if user_prefs["allergy_concern"] else 1.0 + a_match = (1.0 if not plant["pollen_allergies"] else 0.0) \ + if user_prefs["allergy_concern"] else 1.0 else: a_match = None + user_existing = user_prefs.get("existing_plants", []) if user_existing: plant_compat_list = plant["existing_plants"] - exist_match = (sum(1 for up in user_existing if up in plant_compat_list)/len(user_existing)) if plant_compat_list else 0.0 + exist_match = (sum(1 for up in user_existing if up in plant_compat_list) / + len(user_existing)) if plant_compat_list else 0.0 else: exist_match = None - weights = {"water":0.15,"sunlight":0.15,"temp":0.15,"pet":0.15,"space":0.15,"allergy":0.15,"existing":0.10} - components = {"water":w_match,"sunlight":s_match,"temp":t_match,"pet":p_match,"space":space_match,"allergy":a_match,"existing":exist_match} - active = {k:v for k,v in components.items() if v is not None} - if not active: continue + + weights = { + "water": 0.15, "sunlight": 0.15, "temp": 0.15, + "pet": 0.15, "space": 0.15, "allergy": 0.15, "existing": 0.10 + } + components = { + "water": w_match, "sunlight": s_match, "temp": t_match, + "pet": p_match, "space": space_match, "allergy": a_match, + "existing": exist_match + } + active = {k: v for k, v in components.items() if v is not None} + if not active: + continue + active_weight_sum = sum(weights[k] for k in active) - score = sum(v*weights[k]/active_weight_sum for k,v in active.items()) + score = sum(v * weights[k] / active_weight_sum for k, v in active.items()) scores.append((plant["name"], score)) + scores.sort(key=lambda x: x[1], reverse=True) - return scores[:top_n] \ No newline at end of file + return scores[:top_n] + +# ---------------------------------------------------------------------- +# New helper: map lux readings to a sunlight score (1–10) +# ---------------------------------------------------------------------- +def _lux_to_sunlight(lux): + """ + Convert an average lux value into a plant‑care sunlight scale 1–10. + Thresholds are loosely based on indoor light levels. + """ + # Piecewise linear mapping: + thresholds = [ + (0, 100, 1.0, 2.0), + (100, 250, 2.0, 3.0), + (250, 500, 3.0, 4.0), + (500, 1000, 4.0, 5.0), + (1000, 2000, 5.0, 6.0), + (2000, 4000, 6.0, 7.0), + (4000, 8000, 7.0, 8.0), + (8000, 16000, 8.0, 9.0), + (16000, 32000, 9.0, 10.0), + (32000, float("inf"), 10.0, 10.0), + ] + for low, high, low_s, high_s in thresholds: + if low <= lux <= high: + if high == float("inf"): + return low_s + fraction = (lux - low) / (high - low) + return low_s + fraction * (high_s - low_s) + return 5.0 # fallback + +# ---------------------------------------------------------------------- +# File‑analysis entry point +# ---------------------------------------------------------------------- +def analyze_environment_file(file_path): + """ + Read a .bin or .npz file that may contain light and/or temperature data. + + Returns a dict with: + - "sunlight" : float (1‑10) derived from average light + - "temp" : float or None (average temperature in °C) + """ + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + ext = os.path.splitext(file_path)[1].lower() + light_values = [] + temp_values = [] + + try: + if ext == ".npz": + with np.load(file_path, allow_pickle=True) as npz: + # Look for known keys + for key in npz.files: + arr = npz[key].flatten() + if key.lower() in ("light", "lux", "illuminance"): + light_values.extend(arr.tolist()) + elif key.lower() in ("temp", "temperature", "celsius"): + temp_values.extend(arr.tolist()) + # If no known keys, treat everything as light (back‑compat with dashboard) + if not light_values and not temp_values: + for key in npz.files: + light_values.extend(npz[key].flatten().tolist()) + + elif ext == ".bin": + raw = np.fromfile(file_path, dtype=np.float32) + light_values = raw.tolist() + else: + raise ValueError("Unsupported file type. Use .bin or .npz") + + except Exception as e: + raise RuntimeError(f"Failed to read environment file: {e}") + + # Compute sunlight from light data + sunlight = None + if light_values: + avg_lux = sum(light_values) / len(light_values) + sunlight = _lux_to_sunlight(avg_lux) + else: + sunlight = 5.0 # neutral guess if no light data + + # Compute temperature if available + temp = None + if temp_values: + avg_temp = sum(temp_values) / len(temp_values) + temp = round(avg_temp, 1) + + return {"sunlight": sunlight, "temp": temp} + +# ---------------------------------------------------------------------- +# Convenience function that merges file‑derived values into prefs +# ---------------------------------------------------------------------- +def merge_file_prefs(user_prefs, file_path): + """ + Return a new user_prefs dict where 'sunlight' and 'temp' are + replaced with values from the file, if available. + Other keys are left untouched. + """ + env = analyze_environment_file(file_path) + merged = dict(user_prefs) # shallow copy + merged["sunlight"] = env["sunlight"] + if env["temp"] is not None: + merged["temp"] = env["temp"] + # If no temperature was found, keep the original temp value + return merged \ No newline at end of file diff --git a/ui/pages/growth.py b/ui/pages/growth.py index e74ffc5..069bbbf 100644 --- a/ui/pages/growth.py +++ b/ui/pages/growth.py @@ -17,8 +17,8 @@ class GrowthPage(BasePage): FIELDS = [ ("days_passed", "Days passed"), - ("avg_direct_light", "Avg direct light (lux)"), - ("avg_indirect_light", "Avg indirect light (lux)"), + ("avg_direct_light", "Avg direct light (hrs)"), + ("avg_indirect_light", "Avg indirect light (hrs)"), ("avg_nighttime", "Avg nighttime (hrs)"), ("avg_temp", "Avg temperature (C)"), ("min_temp", "Min temperature (C)"), @@ -91,7 +91,7 @@ def on_color(e, v=c): api_row = tk.Frame(left_inner, bg=BG_CARD); api_row.pack(fill="x", pady=(0,10)) tk.Label(api_row, text="API URL", font=self.f_small, bg=BG_CARD, fg=TEXT_SEC).pack(anchor="w", pady=(0,4)) - self._api_url_var = tk.StringVar(value="http://localhost:8000/fwd") + self._api_url_var = tk.StringVar(value="http://localhost:5000/growth") api_frame = tk.Frame(api_row, bg=BG_GLASS); api_frame.pack(fill="x") tk.Frame(api_frame, bg=TEAL, width=3).pack(side="left", fill="y") api_entry = tk.Entry(api_frame, textvariable=self._api_url_var, @@ -145,17 +145,6 @@ def _animate(self): def _run_prediction(self): # Import the model lazily so the GUI works even before models/growth.py exists. - try: - from models.growth import predict_growth - except ModuleNotFoundError: - messagebox.showinfo( - "Model not available yet", - "The growth model (models/growth.py) hasn't been added yet.\n" - "The page will work as soon as it's in place.") - return - except Exception as ex: - messagebox.showerror("Model Error", f"Could not load the growth model:\n{ex}") - return data = {} for key, label in self.FIELDS: @@ -167,13 +156,21 @@ def _run_prediction(self): messagebox.showerror("Invalid Input", f"'{label}' must be a number."); return color = self._color_var.get() api_url = self._api_url_var.get().strip() + vector = [1 if c == color else 0 for c in self.COLORS] + payload = {**data, "color_before": vector} if not api_url: messagebox.showwarning("Missing API URL", "Please enter the growth API URL."); return self._show_loading() def _call(): try: - rep = predict_growth(data, color, api_url, user_id=1, timeout=10) - self.after(0, lambda: self._show_result(rep)) + import requests as req_lib + response = req_lib.post(api_url, json=payload, timeout=10) + if response.status_code == 200: + rep = response.json() + self.after(0, lambda: self._show_result(rep)) + else: + msg = f"Server returned status {response.status_code}.\n{response.text[:200]}" + self.after(0, lambda m=msg: self._show_error(m)) except Exception as ex: self.after(0, lambda e=ex: self._show_error(str(e))) threading.Thread(target=_call, daemon=True).start() @@ -202,7 +199,7 @@ def _show_result(self, rep): height_row.pack(fill="x", pady=(0,8)) tk.Label(height_row, text="Predicted height growth", font=self.f_small, bg=BG_GLASS, fg=TEXT_SEC).pack(anchor="w") val_row = tk.Frame(height_row, bg=BG_GLASS); val_row.pack(anchor="w") - tk.Label(val_row, text=f"{guess}", font=("Segoe UI",30,"bold"), bg=BG_GLASS, fg=TEAL).pack(side="left") + tk.Label(val_row, text=f"{guess:.3f}", font=("Segoe UI",30,"bold"), bg=BG_GLASS, fg=TEAL).pack(side="left") tk.Label(val_row, text=" cm", font=("Segoe UI",12), bg=BG_GLASS, fg=TEXT_SEC).pack(side="left", pady=(10,0)) color_row = tk.Frame(inner, bg=BG_GLASS, padx=16, pady=14) diff --git a/ui/pages/recommendation.py b/ui/pages/recommendation.py index 3c2053a..1df4c69 100644 --- a/ui/pages/recommendation.py +++ b/ui/pages/recommendation.py @@ -1,11 +1,13 @@ import tkinter as tk +from tkinter import filedialog, messagebox +import os from .theme import ( BG_MAIN, BG_CARD, BG_CARD2, BG_GLASS, ACCENT, ACCENT2, BLUE, RED, YELLOW, TEXT_PRI, TEXT_SEC, TEXT_MUT, BORDER, ON_ACCENT, bind_tree, hover, GreenSlider, BasePage ) from database.plants import df_plants as _df_plants, PANDAS_OK -from models.recommendation import recommend_plants +from models.recommendation import recommend_plants, analyze_environment_file class RecommendationSystemPage(BasePage): @@ -59,6 +61,24 @@ def slider_row(parent, label, from_, to, resolution, default): self._sunlight_var = slider_row(left_inner, "Sunlight (1–10)", 1, 10, 0.5, 6) self._temp_var = slider_row(left_inner, "Temperature (°C)", 10, 40, 0.5, 22) + # ----- File import for light / temperature (overrides sliders) ----- + file_frame = tk.Frame(left_inner, bg=BG_CARD) + file_frame.pack(fill="x", pady=(4, 0)) + tk.Label(file_frame, text="Or load from light data file:", + font=self.f_small, bg=BG_CARD, fg=TEXT_SEC).pack(anchor="w") + btn_row = tk.Frame(file_frame, bg=BG_CARD); btn_row.pack(fill="x", pady=(4,0)) + file_btn = tk.Frame(btn_row, bg=ACCENT, cursor="hand2", padx=12, pady=5) + file_btn.pack(side="left") + tk.Label(file_btn, text="📂 Choose File", font=self.f_label, + bg=ACCENT, fg=ON_ACCENT).pack() + bind_tree(file_btn, "", lambda e: self._load_light_file()) + hover(file_btn, ACCENT, ACCENT2) + self._file_info_var = tk.StringVar(value="") + tk.Label(btn_row, textvariable=self._file_info_var, + font=("Segoe UI",8), bg=BG_CARD, fg=TEXT_MUT, + wraplength=200, justify="left").pack(side="left", padx=(8,0)) + # ------------------------------------------------------------------ + tk.Frame(left_inner, bg=BORDER, height=1).pack(fill="x", pady=(8,10)) # Space selector @@ -138,6 +158,28 @@ def toggle(e, v=var, l=lbl): self._results_frame.pack(fill="both", expand=True) self._show_finder_empty() + def _load_light_file(self): + """Let the user pick a .bin or .npz file and update sunlight / temp sliders.""" + path = filedialog.askopenfilename( + title="Select Light Data File", + filetypes=[("Binary/NumPy files", "*.bin *.npz"), ("All files", "*.*")] + ) + if not path: + return + try: + env = analyze_environment_file(path) + # Update sliders – values will be clamped to their ranges automatically + self._sunlight_var.set(env["sunlight"]) + if env["temp"] is not None: + self._temp_var.set(env["temp"]) + # Show what was loaded + temp_str = f"{env['temp']}°C" if env["temp"] is not None else "—" + self._file_info_var.set( + f"✔ {os.path.basename(path)}\nLight: {env['sunlight']:.1f} Temp: {temp_str}" + ) + except Exception as e: + messagebox.showerror("File Error", f"Could not read file:\n{e}") + def _show_finder_empty(self): for w in self._results_frame.winfo_children(): w.destroy() container = tk.Frame(self._results_frame, bg=BG_CARD, padx=20, pady=40) diff --git a/ui/plants_data.json b/ui/plants_data.json deleted file mode 100644 index 58a2168..0000000 --- a/ui/plants_data.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "Calibrachoa": [ - "/home/anastasija/Desktop/FERI/2 semester/pp/projekt/main/Calibrachoa/C1.png", - "/home/anastasija/Desktop/FERI/2 semester/pp/projekt/main/Calibrachoa/C2.png", - "/home/anastasija/Desktop/FERI/2 semester/pp/projekt/main/Calibrachoa/C3.png" - ] -} \ No newline at end of file diff --git a/ui/test_photos/Disease Detected.png b/ui/test_photos/Disease Detected.png new file mode 100644 index 0000000..f045b1d Binary files /dev/null and b/ui/test_photos/Disease Detected.png differ diff --git a/ui/test_photos/dead.png b/ui/test_photos/dead.png new file mode 100644 index 0000000..381483e Binary files /dev/null and b/ui/test_photos/dead.png differ diff --git a/ui/test_photos/healty.png b/ui/test_photos/healty.png new file mode 100644 index 0000000..a27f87e Binary files /dev/null and b/ui/test_photos/healty.png differ diff --git a/ui/test_photos/needs water.png b/ui/test_photos/needs water.png new file mode 100644 index 0000000..2472f39 Binary files /dev/null and b/ui/test_photos/needs water.png differ