Spaces:
Sleeping
Sleeping
| import os | |
| import base64 | |
| import traceback | |
| import numpy as np | |
| import cv2 | |
| import joblib | |
| from skimage.feature import graycomatrix, graycoprops, local_binary_pattern | |
| from flask import Flask, request, jsonify, render_template | |
| app = Flask(__name__) | |
| # βββ Konfigurasi Path Model (Gunakan Absolute Path agar aman) ββββββββββββββββ | |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| MODEL_PATH = os.path.join(BASE_DIR, "models", "fruit_svm_model.pkl") | |
| SCALER_PATH = os.path.join(BASE_DIR, "models", "fruit_scaler.pkl") | |
| model = None | |
| scaler = None | |
| classes = None | |
| def load_model(): | |
| global model, scaler, classes | |
| if not os.path.exists(MODEL_PATH): | |
| print(f"[WARN] Model tidak ditemukan di {MODEL_PATH}") | |
| return False | |
| try: | |
| model = joblib.load(MODEL_PATH) | |
| scaler = joblib.load(SCALER_PATH) | |
| # Ambil list kelas langsung dari atribut bawaan model Scikit-Learn | |
| classes = model.classes_.tolist() | |
| print(f"[OK] Model loaded: {MODEL_PATH}") | |
| print(f"[OK] Classes found: {classes}") | |
| return True | |
| except Exception as e: | |
| print(f"[ERROR] Gagal load model: {e}") | |
| return False | |
| model_loaded = load_model() | |
| # βββ Helper: Mapping Label Kaggle ke Format Frontend βββββββββββββββββββββββββ | |
| def map_kaggle_label(raw_label): | |
| mapping = { | |
| 'RipeBanana': ('banana', 'ripe'), | |
| 'RottenBanana': ('banana', 'rotten'), | |
| 'UnripeBanana': ('banana', 'unripe'), | |
| 'RipeStrawberry': ('strawberry', 'ripe'), | |
| 'RottenStrawberry':('strawberry', 'rotten'), | |
| 'UnripeStrawberry':('strawberry', 'unripe'), | |
| 'RipeOrange': ('orange', 'ripe'), | |
| 'RottenOrange': ('orange', 'rotten'), | |
| 'UnripeOrange': ('orange', 'unripe'), | |
| } | |
| ft, rs = mapping.get(raw_label, (raw_label, 'unknown')) | |
| return f"{ft}_{rs}", ft, rs | |
| # βββ Feature extraction β exact copy dari notebook (GrabCut version) βββββββββ | |
| def extract_features_from_array(img_array, size=(128, 128)): | |
| img_resized = cv2.resize(img_array, size) | |
| gray = cv2.cvtColor(img_resized, cv2.COLOR_BGR2GRAY) | |
| blurred = cv2.GaussianBlur(gray, (5, 5), 0) | |
| # ββ Segmentation: GrabCut (matches notebook) ββββββββββββββββββββββββββββββ | |
| h, w = blurred.shape[:2] | |
| margin = int(min(h, w) * 0.085) | |
| rect = (margin, margin, w - margin * 2, h - margin * 2) | |
| mask = np.zeros(blurred.shape[:2], np.uint8) | |
| bgd_model = np.zeros((1, 65), np.float64) | |
| fgd_model = np.zeros((1, 65), np.float64) | |
| cv2.grabCut(img_resized, mask, rect, bgd_model, fgd_model, | |
| iterCount=20, mode=cv2.GC_INIT_WITH_RECT) | |
| mask2 = np.where((mask == 2) | (mask == 0), 0, 255).astype('uint8') | |
| # ββ Shape features ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| contours, _ = cv2.findContours(mask2, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| aspect_ratio, extent = 0, 0 | |
| if contours: | |
| c = max(contours, key=cv2.contourArea) | |
| x, y, bw, bh = cv2.boundingRect(c) | |
| aspect_ratio = float(bw) / bh if bh != 0 else 0 | |
| area = cv2.contourArea(c) | |
| rect_area = bw * bh | |
| extent = float(area) / rect_area if rect_area != 0 else 0 | |
| fp = mask2 > 0 | |
| # ββ HSV color features (6) ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| hsv = cv2.cvtColor(img_resized, cv2.COLOR_BGR2HSV) | |
| h_ch, s_ch, v_ch = cv2.split(hsv) | |
| hsv_feats = [ | |
| np.mean(h_ch[fp]) if fp.any() else 0, | |
| np.mean(s_ch[fp]) if fp.any() else 0, | |
| np.mean(v_ch[fp]) if fp.any() else 0, | |
| np.std(h_ch[fp]) if fp.any() else 0, | |
| np.std(s_ch[fp]) if fp.any() else 0, | |
| np.std(v_ch[fp]) if fp.any() else 0, | |
| ] | |
| # ββ LAB color features (5) ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| lab = cv2.cvtColor(img_resized, cv2.COLOR_BGR2LAB) | |
| l_ch, a_ch, b_ch = cv2.split(lab) | |
| lab_feats = [ | |
| np.mean(l_ch[fp]) if fp.any() else 0, | |
| np.mean(a_ch[fp]) if fp.any() else 0, | |
| np.mean(b_ch[fp]) if fp.any() else 0, | |
| np.std(a_ch[fp]) if fp.any() else 0, | |
| np.std(b_ch[fp]) if fp.any() else 0, | |
| ] | |
| # ββ Hue histogram 18 bins (18) ββββββββββββββββββββββββββββββββββββββββββββ | |
| # NOTE: uses mask2 (uint8 0/255) as the cv2.calcHist mask β matches notebook | |
| h_hist = cv2.calcHist([h_ch], [0], mask2, [18], [0, 180]) | |
| h_hist = cv2.normalize(h_hist, h_hist).flatten().tolist() | |
| # ββ GLCM texture (6) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Crop to bounding box of mask, inpaint background pixels, then quantise. | |
| # This exactly replicates the notebook's GLCM pipeline. | |
| x, y, bw, bh = cv2.boundingRect(mask2) | |
| if bw > 0 and bh > 0: | |
| gray_crop = gray[y:y + bh, x:x + bw] | |
| mask_crop = mask2[y:y + bh, x:x + bw] | |
| masked_gray_raw = np.where(mask_crop > 0, gray_crop, 0).astype(np.uint8) | |
| inv_mask_crop = cv2.bitwise_not(mask_crop) | |
| if np.count_nonzero(inv_mask_crop) > 0: | |
| inpainted = cv2.inpaint(masked_gray_raw, inv_mask_crop, | |
| inpaintRadius=1, flags=cv2.INPAINT_TELEA) | |
| masked_gray = inpainted if inpainted is not None else masked_gray_raw | |
| else: | |
| masked_gray = masked_gray_raw | |
| else: | |
| # GrabCut returned empty mask β fall back to full grayscale | |
| masked_gray = gray | |
| masked_gray_q = (masked_gray // 8).astype(np.uint8) | |
| valid_pixels = masked_gray_q[masked_gray_q > 0] | |
| if valid_pixels.size < 100: | |
| # Fallback: use full unmasked grayscale | |
| glcm_input = (gray // 8).astype(np.uint8) | |
| else: | |
| glcm_input = masked_gray_q | |
| glcm = graycomatrix(glcm_input, distances=[1, 3, 5], | |
| angles=[0, np.pi/4, np.pi/2, 3*np.pi/4], | |
| levels=32, symmetric=True, normed=True) | |
| glcm_feats = [ | |
| graycoprops(glcm, 'contrast').mean(), | |
| graycoprops(glcm, 'correlation').mean(), | |
| graycoprops(glcm, 'energy').mean(), | |
| graycoprops(glcm, 'homogeneity').mean(), | |
| graycoprops(glcm, 'dissimilarity').mean(), | |
| graycoprops(glcm, 'ASM').mean(), | |
| ] | |
| # ββ LBP texture 10 bins (10) ββββββββββββββββββββββββββββββββββββββββββββββ | |
| lbp = local_binary_pattern(gray, P=8, R=1, method='uniform') | |
| lbp_pixels = lbp[fp] if fp.any() else lbp.ravel() | |
| lbp_hist, _ = np.histogram(lbp_pixels, bins=10, range=(0, 10), density=True) | |
| features = hsv_feats + lab_feats + h_hist + glcm_feats + lbp_hist.tolist() + [aspect_ratio, extent] | |
| # raw dict for frontend display | |
| raw = { | |
| 'h_mean': hsv_feats[0], 's_mean': hsv_feats[1], 'v_mean': hsv_feats[2], | |
| 'h_std': hsv_feats[3], 's_std': hsv_feats[4], 'v_std': hsv_feats[5], | |
| 'contrast': glcm_feats[0], 'correlation': glcm_feats[1], | |
| 'energy': glcm_feats[2], 'homogeneity': glcm_feats[3], | |
| 'aspect_ratio': aspect_ratio, 'extent': extent, | |
| } | |
| return features, raw | |
| # βββ CORS headers helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def add_cors(response): | |
| response.headers['Access-Control-Allow-Origin'] = '*' | |
| response.headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS' | |
| response.headers['Access-Control-Allow-Headers'] = 'Content-Type' | |
| return response | |
| def index(): | |
| return render_template('index.html') | |
| def after_request(response): | |
| return add_cors(response) | |
| def options(): | |
| resp = jsonify({}) | |
| return add_cors(resp) | |
| # βββ Routes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def health(): | |
| # Frontend sekarang akan menerima mapped classes | |
| mapped_classes = [map_kaggle_label(c)[0] for c in classes] if classes else [] | |
| return jsonify({ | |
| 'status': 'ok', | |
| 'model_loaded': model_loaded, | |
| 'classes': mapped_classes, | |
| 'model_path': MODEL_PATH, | |
| }) | |
| def get_classes(): | |
| mapped_classes = [map_kaggle_label(c)[0] for c in classes] if classes else [] | |
| return jsonify({'classes': mapped_classes}) | |
| def predict(): | |
| if not model_loaded or model is None: | |
| return jsonify({'error': 'Model belum diload.'}), 503 | |
| try: | |
| if request.is_json: | |
| data = request.get_json() | |
| image_b64 = data.get('image', '') | |
| if ',' in image_b64: | |
| image_b64 = image_b64.split(',', 1)[1] | |
| img_bytes = base64.b64decode(image_b64) | |
| nparr = np.frombuffer(img_bytes, np.uint8) | |
| img_array = cv2.imdecode(nparr, cv2.IMREAD_COLOR) | |
| else: | |
| return jsonify({'error': 'Input tidak valid.'}), 400 | |
| if img_array is None: | |
| return jsonify({'error': 'Gagal decode gambar.'}), 400 | |
| # Ekstraksi & Scaling | |
| features, raw = extract_features_from_array(img_array) | |
| features_scaled = scaler.transform([features]) | |
| # Prediksi dari model | |
| prediction_raw = model.predict(features_scaled)[0] | |
| # Format ke frontend | |
| frontend_prediction, fruit_type, ripeness_stage = map_kaggle_label(prediction_raw) | |
| probs = {} | |
| confidence = None | |
| if hasattr(model, 'predict_proba'): | |
| # Jika training dengan probability=True | |
| prob_array = model.predict_proba(features_scaled)[0] | |
| for cls_raw, p in zip(classes, prob_array): | |
| front_cls, _, _ = map_kaggle_label(cls_raw) | |
| probs[front_cls] = float(p) | |
| confidence = float(max(prob_array)) | |
| else: | |
| # Fallback jika model SVC tidak dilatih dengan probability=True | |
| dec = model.decision_function(features_scaled)[0] | |
| dec_shifted = dec - dec.min() | |
| total = dec_shifted.sum() | |
| for cls_raw, sc in zip(classes, dec_shifted): | |
| front_cls, _, _ = map_kaggle_label(cls_raw) | |
| probs[front_cls] = float(sc / total) if total > 0 else 0.0 | |
| confidence = float(probs.get(frontend_prediction, 0.0)) | |
| return jsonify({ | |
| 'prediction': frontend_prediction, | |
| 'fruit_type': fruit_type, | |
| 'ripeness_stage': ripeness_stage, | |
| 'confidence': confidence, | |
| 'probabilities': probs, | |
| 'features': raw, | |
| 'source': 'python_svm_model', | |
| }) | |
| except Exception as e: | |
| traceback.print_exc() | |
| return jsonify({'error': str(e)}), 500 | |
| # βββ Run ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == '__main__': | |
| print("=" * 60) | |
| print("RIPE.AI β Flask API Server") | |
| print("=" * 60) | |
| print(f"Model loaded: {model_loaded}") | |
| app.run(host='0.0.0.0', port=7860, debug=False) |