| from __future__ import annotations |
|
|
| import csv |
| import json |
| import logging |
| import os |
| import uuid |
| from datetime import datetime, timedelta |
| from pathlib import Path |
| from functools import lru_cache |
| from threading import RLock |
|
|
| import numpy as np |
| from PIL import Image, ImageOps |
| from flask import Flask, jsonify, render_template, request, send_from_directory, url_for |
| from werkzeug.utils import secure_filename |
|
|
| |
| |
| |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' |
| ) |
| logger = logging.getLogger(__name__) |
|
|
| class AppConfig: |
| """Production-grade configuration""" |
| MAX_UPLOAD_SIZE = 30 * 1024 * 1024 |
| PREDICTION_TIMEOUT = 120 |
| GENERATED_FILES_RETENTION_DAYS = 7 |
| GRADCAM_OVERLAY_BASE_WEIGHT = 0.58 |
| GRADCAM_OVERLAY_HEATMAP_WEIGHT = 0.42 |
| MODEL_CACHE_SIZE = 10 |
|
|
| |
| |
| |
|
|
| BASE_DIR = Path(__file__).resolve().parent |
| PROJECT_DIR = BASE_DIR.parent |
| KAGGLE_OUTPUT_DIR = Path("/kaggle/working/nn_ecommerce_outputs") |
|
|
| |
| |
| def _find_default_artifact_dir(): |
| candidates = [ |
| BASE_DIR / "nn_ecommerce_outputs", |
| PROJECT_DIR / "nn_ecommerce_outputs", |
| KAGGLE_OUTPUT_DIR, |
| ] |
| for candidate in candidates: |
| if candidate.exists(): |
| return candidate |
| return BASE_DIR / "nn_ecommerce_outputs" |
|
|
| DEFAULT_ARTIFACT_DIR = _find_default_artifact_dir() |
|
|
| ARTIFACT_DIR = Path(os.getenv("NN_ARTIFACT_DIR", DEFAULT_ARTIFACT_DIR)).resolve() |
| MODEL_DIR = Path(os.getenv("NN_MODEL_DIR", ARTIFACT_DIR / "models")).resolve() |
| METADATA_DIR = Path(os.getenv("NN_METADATA_DIR", ARTIFACT_DIR / "metadata")).resolve() |
| TABLE_DIR = Path(os.getenv("NN_TABLE_DIR", ARTIFACT_DIR / "tables")).resolve() |
| FIGURE_DIR = Path(os.getenv("NN_FIGURE_DIR", ARTIFACT_DIR / "figures")).resolve() |
| GENERATED_DIR = BASE_DIR / "static" / "generated" |
| DATASET_IMAGE_DIR = ARTIFACT_DIR / "dataset_images" |
| DATASET_IMAGE_MANIFEST_PATH = TABLE_DIR / "dataset_images_manifest.csv" |
| LOCAL_DATASET_DIR = PROJECT_DIR / "ECOMMERCE_PRODUCT_IMAGES" |
|
|
| GENERATED_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} |
|
|
| |
| |
| |
|
|
| _cache_lock = RLock() |
| _model_cache = {} |
| _dataset_cache = None |
| _dataset_cache_time = None |
| _tf_module = None |
| _keras_module = None |
|
|
| CACHE_TTL_SECONDS = 3600 |
|
|
| |
| |
| |
|
|
| app = Flask(__name__) |
| app.config["MAX_CONTENT_LENGTH"] = AppConfig.MAX_UPLOAD_SIZE |
| app.config["JSON_SORT_KEYS"] = False |
|
|
| |
| |
| |
|
|
| def validate_file_extension(filename, allowed=ALLOWED_EXTENSIONS): |
| """Validate file extension""" |
| if not filename: |
| return False |
| ext = Path(secure_filename(filename)).suffix.lower() |
| return ext in allowed |
|
|
| def cleanup_old_generated_files(): |
| """Remove generated files older than retention period""" |
| if not GENERATED_DIR.exists(): |
| return 0 |
| |
| cutoff = datetime.now() - timedelta(days=AppConfig.GENERATED_FILES_RETENTION_DAYS) |
| removed = 0 |
| |
| try: |
| for file in GENERATED_DIR.glob("*.png"): |
| try: |
| if datetime.fromtimestamp(file.stat().st_mtime) < cutoff: |
| file.unlink() |
| removed += 1 |
| except Exception as e: |
| logger.warning(f"Failed to delete {file}: {e}") |
| except Exception as e: |
| logger.error(f"Cleanup failed: {e}") |
| |
| return removed |
|
|
| def load_json(path, fallback): |
| """Safely load JSON file""" |
| try: |
| if path.exists(): |
| with path.open("r", encoding="utf-8") as f: |
| return json.load(f) |
| except Exception as e: |
| logger.warning(f"Failed to load {path}: {e}") |
| return fallback |
|
|
| def normalize_artifact_path(value, allowed_extensions=None): |
| """Normalize and validate artifact path (security)""" |
| artifact_path = str(value or "").replace("\\", "/").lstrip("/") |
| parts = Path(artifact_path).parts |
| |
| |
| if not artifact_path or ".." in parts or artifact_path.startswith("/"): |
| return None |
| |
| |
| if allowed_extensions: |
| ext = Path(artifact_path).suffix.lower() |
| if ext not in allowed_extensions: |
| return None |
| |
| |
| try: |
| file_path = (ARTIFACT_DIR / artifact_path).resolve() |
| artifact_root = ARTIFACT_DIR.resolve() |
| if not str(file_path).startswith(str(artifact_root)): |
| return None |
| except Exception: |
| return None |
| |
| return artifact_path |
|
|
| def artifact_path_to_file(artifact_path): |
| """Convert normalized path to file with security checks""" |
| normalized = normalize_artifact_path(artifact_path) |
| if not normalized: |
| raise ValueError("Invalid artifact image path.") |
| |
| file_path = (ARTIFACT_DIR / normalized).resolve() |
| artifact_root = ARTIFACT_DIR.resolve() |
| if file_path != artifact_root and artifact_root not in file_path.parents: |
| raise ValueError("Artifact image path is outside the output folder.") |
| return file_path |
|
|
| |
| |
| |
|
|
| def class_names(): |
| """Load class names with caching""" |
| names = load_json(METADATA_DIR / "class_names.json", []) |
| return names if isinstance(names, list) else [] |
|
|
| def model_manifest(): |
| """Load model manifest with fallback""" |
| manifest = load_json(METADATA_DIR / "model_manifest.json", {}) |
| models = manifest.get("models", {}) if isinstance(manifest, dict) else {} |
| |
| if not models and MODEL_DIR.exists(): |
| logger.info("Building model manifest from filesystem") |
| for path in sorted(MODEL_DIR.glob("*.keras")): |
| key = path.stem.lower().replace(" ", "_") |
| models[key] = { |
| "file": path.name, |
| "safe_name": key, |
| "display_name": path.stem.replace("_", " ").title(), |
| "last_conv_layer": None, |
| } |
| |
| return { |
| "input_size": manifest.get("input_size", [224, 224]) if isinstance(manifest, dict) else [224, 224], |
| "models": models, |
| } |
|
|
| def read_csv_table(path, max_rows=12): |
| """Read CSV table with error handling""" |
| if not path.exists(): |
| return None |
| |
| try: |
| with path.open("r", encoding="utf-8", newline="") as handle: |
| reader = csv.DictReader(handle) |
| rows = list(next(iter([reader]), []))[:max_rows] |
| return { |
| "columns": reader.fieldnames or [], |
| "rows": rows, |
| "url": url_for("output_file", filename=f"tables/{path.name}"), |
| } |
| except Exception as e: |
| logger.warning(f"Failed to read table {path}: {e}") |
| return None |
|
|
| |
| |
| |
|
|
| def dataset_images_manifest(): |
| """Load dataset images with caching""" |
| global _dataset_cache, _dataset_cache_time |
| |
| |
| if _dataset_cache is not None and _dataset_cache_time is not None: |
| if (datetime.now() - _dataset_cache_time).total_seconds() < CACHE_TTL_SECONDS: |
| return _dataset_cache |
| |
| rows = [] |
| |
| |
| if DATASET_IMAGE_MANIFEST_PATH.exists(): |
| try: |
| with DATASET_IMAGE_MANIFEST_PATH.open("r", encoding="utf-8", newline="") as handle: |
| reader = csv.DictReader(handle) |
| for index, row in enumerate(reader): |
| artifact_path = normalize_artifact_path(row.get("artifact_path")) |
| if not artifact_path: |
| continue |
| file_path = ARTIFACT_DIR / artifact_path |
| if not file_path.exists(): |
| continue |
| rows.append({ |
| "id": str(row.get("id") or index), |
| "source": "artifact", |
| "label": row.get("label") or file_path.parent.name, |
| "label_id": row.get("label_id"), |
| "filename": row.get("filename") or file_path.name, |
| "artifact_path": artifact_path, |
| }) |
| except Exception as e: |
| logger.warning(f"Failed to read manifest: {e}") |
| |
| |
| if not rows and DATASET_IMAGE_DIR.exists(): |
| try: |
| image_files = sorted( |
| path for path in DATASET_IMAGE_DIR.rglob("*") |
| if path.suffix.lower() in ALLOWED_EXTENSIONS |
| ) |
| for index, file_path in enumerate(image_files): |
| rows.append({ |
| "id": str(index), |
| "source": "artifact", |
| "label": file_path.parent.name, |
| "label_id": None, |
| "filename": file_path.name, |
| "artifact_path": file_path.relative_to(ARTIFACT_DIR).as_posix(), |
| }) |
| except Exception as e: |
| logger.warning(f"Failed to scan artifact directory: {e}") |
| |
| |
| if not rows and LOCAL_DATASET_DIR.exists(): |
| try: |
| image_files = sorted( |
| path for path in LOCAL_DATASET_DIR.rglob("*") |
| if path.suffix.lower() in ALLOWED_EXTENSIONS |
| ) |
| for index, file_path in enumerate(image_files): |
| rows.append({ |
| "id": str(index), |
| "source": "local", |
| "label": file_path.parent.name, |
| "label_id": None, |
| "filename": file_path.name, |
| "local_path": file_path.relative_to(LOCAL_DATASET_DIR).as_posix(), |
| }) |
| except Exception as e: |
| logger.warning(f"Failed to scan local directory: {e}") |
| |
| _dataset_cache = rows |
| _dataset_cache_time = datetime.now() |
| logger.info(f"Loaded {len(rows)} dataset images") |
| return rows |
|
|
| def dataset_image_by_id(image_id): |
| """Find dataset image by ID""" |
| image_id = str(image_id) |
| for row in dataset_images_manifest(): |
| if row["id"] == image_id: |
| return row |
| return None |
|
|
| def dataset_image_url(row): |
| """Get URL for dataset image""" |
| if row.get("source") == "local": |
| return url_for("dataset_image_file", image_id=row["id"]) |
| return url_for("output_file", filename=row["artifact_path"]) |
|
|
| def dataset_image_file_path(row): |
| """Get file path for dataset image with validation""" |
| if row.get("source") == "local": |
| local_path = normalize_artifact_path(row.get("local_path")) |
| if not local_path: |
| raise ValueError("Invalid local dataset image path.") |
| |
| file_path = (LOCAL_DATASET_DIR / local_path).resolve() |
| dataset_root = LOCAL_DATASET_DIR.resolve() |
| if file_path != dataset_root and dataset_root not in file_path.parents: |
| raise ValueError("Local dataset image path outside dataset folder.") |
| return file_path |
| |
| return artifact_path_to_file(row["artifact_path"]) |
|
|
| |
| |
| |
|
|
| def tensorflow_modules(): |
| """Load TensorFlow with thread safety""" |
| global _tf_module, _keras_module |
| |
| with _cache_lock: |
| if _tf_module is not None and _keras_module is not None: |
| return _tf_module, _keras_module |
| |
| try: |
| logger.info("Loading TensorFlow...") |
| import tensorflow as tf |
| |
| |
| |
| |
| try: |
| import keras |
| logger.info(f"Using standalone Keras {keras.__version__}") |
| except ImportError: |
| from tensorflow import keras |
| logger.info("Using tensorflow.keras (standalone keras not found)") |
| logger.info(f"TensorFlow {tf.__version__} loaded") |
| |
| try: |
| keras.mixed_precision.set_global_policy("mixed_float16") |
| logger.info("Mixed precision policy set: mixed_float16") |
| except Exception as policy_exc: |
| logger.warning(f"Could not set mixed_float16 policy: {policy_exc}") |
| except ImportError: |
| logger.error("TensorFlow not installed") |
| raise RuntimeError("TensorFlow not installed. Run: pip install tensorflow") |
| except Exception as exc: |
| logger.error(f"TensorFlow initialization failed: {exc}") |
| raise RuntimeError(f"TensorFlow init failed: {exc}") from exc |
| |
| _tf_module = tf |
| _keras_module = keras |
| return _tf_module, _keras_module |
|
|
| def model_path_for(model_info): |
| """Get model file path""" |
| return MODEL_DIR / model_info["file"] |
|
|
| def load_model(model_key): |
| """Load model with thread-safe caching""" |
| with _cache_lock: |
| if model_key in _model_cache: |
| logger.debug(f"Using cached model: {model_key}") |
| return _model_cache[model_key] |
| |
| manifest = model_manifest() |
| models = manifest["models"] |
| |
| if model_key not in models: |
| logger.error(f"Unknown model: {model_key}") |
| raise KeyError(f"Unknown model: {model_key}") |
| |
| model_info = models[model_key] |
| model_path = model_path_for(model_info) |
| |
| if not model_path.exists(): |
| logger.error(f"Model file missing: {model_path}") |
| raise FileNotFoundError(f"Model not found: {model_path}") |
| |
| try: |
| logger.info(f"Loading model: {model_key} from {model_path}") |
| tf, keras = tensorflow_modules() |
| |
| |
| |
| |
| custom_objects = {} |
| try: |
| if model_key == "mobilenetv2": |
| from keras.applications.mobilenet_v2 import preprocess_input as _pre |
| custom_objects["preprocess_input"] = _pre |
| elif model_key == "resnet50": |
| from keras.applications.resnet50 import preprocess_input as _pre |
| custom_objects["preprocess_input"] = _pre |
| except Exception as pre_exc: |
| logger.warning(f"Could not import preprocess_input for {model_key}: {pre_exc}") |
| |
| |
| try: |
| model = keras.models.load_model( |
| str(model_path), safe_mode=False, compile=False, |
| custom_objects=custom_objects or None, |
| ) |
| except TypeError: |
| model = keras.models.load_model( |
| str(model_path), custom_objects=custom_objects or None, |
| ) |
| logger.info(f"Model loaded: {model_key}") |
| |
| _model_cache[model_key] = (model, model_info) |
| |
| |
| if len(_model_cache) > AppConfig.MODEL_CACHE_SIZE: |
| oldest = next(iter(_model_cache)) |
| del _model_cache[oldest] |
| logger.debug(f"Removed oldest cached model: {oldest}") |
| |
| return model, model_info |
| except Exception as e: |
| logger.error(f"Failed to load model {model_key}: {e}") |
| raise |
|
|
| |
| |
| |
|
|
| def prepare_image(file, input_size): |
| """Prepare uploaded image for prediction""" |
| try: |
| image = Image.open(file).convert("RGB") |
| original_size = image.size |
| |
| image = ImageOps.fit(image, input_size, Image.Resampling.LANCZOS) |
| |
| |
| image_array = np.array(image, dtype="float32") |
| |
| |
| preview_name = f"preview_{uuid.uuid4().hex}.png" |
| preview_path = GENERATED_DIR / preview_name |
| image.save(preview_path) |
| |
| logger.info(f"Image processed: {original_size} -> {input_size}") |
| return image_array, url_for("static", filename=f"generated/{preview_name}") |
| except Exception as e: |
| logger.error(f"Image preparation failed: {e}") |
| raise ValueError(f"Invalid image file: {e}") |
|
|
| def prepare_artifact_image(image_path, input_size, image_url): |
| """Prepare artifact image for prediction""" |
| try: |
| image = Image.open(image_path).convert("RGB") |
| image = ImageOps.fit(image, input_size, Image.Resampling.LANCZOS) |
| image_array = np.array(image, dtype="float32") |
| return image_array, image_url |
| except Exception as e: |
| logger.error(f"Artifact image preparation failed: {e}") |
| raise ValueError(f"Failed to load image: {e}") |
|
|
| |
| |
| |
|
|
| def colorize_heatmap(heatmap): |
| """Convert grayscale heatmap to color""" |
| try: |
| import matplotlib |
| try: |
| cmap = matplotlib.colormaps["jet"] |
| except AttributeError: |
| import matplotlib.cm as cm |
| cmap = cm.get_cmap("jet") |
| return cmap(heatmap)[:, :, :3] |
| except Exception: |
| |
| colored = np.zeros((*heatmap.shape, 3)) |
| colored[:, :, 0] = heatmap |
| return colored |
|
|
| def _find_last_4d_layer_name(model): |
| """Last layer whose output is 4D (B,H,W,C) — matches the notebook.""" |
| for layer in reversed(model.layers): |
| try: |
| if len(layer.output.shape) == 4: |
| return layer.name |
| except Exception: |
| continue |
| return None |
|
|
| def make_gradcam_heatmap(model, img_array, pred_index, preferred_layer_name=None): |
| """Generate Grad-CAM heatmap""" |
| try: |
| tf, keras = tensorflow_modules() |
| |
| |
| |
| last_conv_layer_name = None |
| if preferred_layer_name: |
| try: |
| model.get_layer(preferred_layer_name) |
| last_conv_layer_name = preferred_layer_name |
| except Exception: |
| logger.info(f"Manifest layer '{preferred_layer_name}' not found; auto-detecting.") |
| if not last_conv_layer_name: |
| last_conv_layer_name = _find_last_4d_layer_name(model) |
| |
| if not last_conv_layer_name: |
| logger.warning("No 4D feature layer found for Grad-CAM") |
| return None, None |
| |
| last_conv_layer = model.get_layer(last_conv_layer_name) |
| grad_model = keras.models.Model( |
| model.inputs, [last_conv_layer.output, model.output] |
| ) |
| |
| with tf.GradientTape() as tape: |
| conv_outputs, predictions = grad_model(np.expand_dims(img_array, axis=0), training=False) |
| predictions = tf.cast(predictions, tf.float32) |
| loss = predictions[:, pred_index] |
| |
| grads = tape.gradient(loss, conv_outputs) |
| |
| conv_outputs = tf.cast(conv_outputs, tf.float32) |
| grads = tf.cast(grads, tf.float32) |
| pooled_grads = tf.reduce_mean(grads, axis=(0, 1, 2)) |
| |
| conv_outputs = conv_outputs[0] |
| heatmap = conv_outputs @ pooled_grads[..., tf.newaxis] |
| heatmap = tf.squeeze(heatmap) |
| heatmap = tf.nn.relu(heatmap) |
| heatmap /= tf.math.reduce_max(heatmap) + 1e-10 |
| |
| return heatmap.numpy(), last_conv_layer_name |
| except Exception as e: |
| logger.warning(f"Grad-CAM generation failed: {e}") |
| return None, None |
|
|
| def save_gradcam_overlay(image_array, heatmap, model_key): |
| """Save Grad-CAM overlay visualization""" |
| try: |
| heatmap_image = Image.fromarray(np.uint8(heatmap * 255)).resize( |
| (image_array.shape[1], image_array.shape[0]), |
| Image.Resampling.BILINEAR, |
| ) |
| heatmap_resized = np.asarray(heatmap_image).astype("float32") / 255.0 |
| colored_heatmap = colorize_heatmap(heatmap_resized) |
| |
| base = np.clip(image_array / 255.0, 0, 1) |
| overlay = np.clip( |
| (AppConfig.GRADCAM_OVERLAY_BASE_WEIGHT * base) + |
| (AppConfig.GRADCAM_OVERLAY_HEATMAP_WEIGHT * colored_heatmap), |
| 0, 1 |
| ) |
| |
| overlay_name = f"gradcam_{model_key}_{uuid.uuid4().hex}.png" |
| overlay_path = GENERATED_DIR / overlay_name |
| Image.fromarray(np.uint8(overlay * 255)).save(overlay_path) |
| |
| logger.debug(f"Grad-CAM saved: {overlay_name}") |
| return url_for("static", filename=f"generated/{overlay_name}") |
| except Exception as e: |
| logger.error(f"Grad-CAM save failed: {e}") |
| return None |
|
|
| def top_predictions(predictions, labels, top_n=5): |
| """Get top N predictions""" |
| top_indices = np.argsort(predictions)[-top_n:][::-1] |
| return [ |
| { |
| "label": labels[idx] if idx < len(labels) else f"Unknown {idx}", |
| "confidence": float(predictions[idx]), |
| "confidence_pct": int(predictions[idx] * 100), |
| } |
| for idx in top_indices if predictions[idx] > 0 |
| ] |
|
|
| def shap_figure_url(model_key): |
| """Return URL for the precomputed SHAP reference figure, if present.""" |
| candidate = FIGURE_DIR / f"shap_{model_key}.png" |
| if candidate.exists(): |
| return url_for("figure_file", filename=candidate.name) |
| return None |
|
|
| def predict_with_models(image_array, image_url, selected_models): |
| """Run predictions with all selected models""" |
| labels = class_names() |
| manifest = model_manifest() |
| |
| if not selected_models: |
| selected_models = list(manifest["models"].keys()) |
| |
| results = [] |
| for model_key in selected_models: |
| try: |
| logger.info(f"Running inference: {model_key}") |
| model, model_info = load_model(model_key) |
| predictions = model.predict(np.expand_dims(image_array, axis=0), verbose=0)[0] |
| top_rows = top_predictions(predictions, labels) |
| best_index = int(np.argmax(predictions)) |
| |
| heatmap, layer_name = make_gradcam_heatmap( |
| model, image_array, best_index, |
| preferred_layer_name=model_info.get("last_conv_layer"), |
| ) |
| heatmap_url = save_gradcam_overlay(image_array, heatmap, model_key) if heatmap is not None else None |
| |
| results.append({ |
| "model_key": model_key, |
| "display_name": model_info.get("display_name", model_key), |
| "top_label": top_rows[0]["label"], |
| "confidence": top_rows[0]["confidence"], |
| "confidence_pct": top_rows[0]["confidence_pct"], |
| "top_predictions": top_rows, |
| "heatmap_url": heatmap_url, |
| "gradcam_layer": layer_name, |
| "model_file": model_info.get("file"), |
| "error": None, |
| }) |
| logger.info(f"Inference complete: {model_key} -> {top_rows[0]['label']}") |
| |
| except KeyError as e: |
| logger.warning(f"Model config error {model_key}: {e}") |
| results.append({ |
| "model_key": model_key, |
| "display_name": manifest["models"].get(model_key, {}).get("display_name", model_key), |
| "error": f"Model not found: {model_key}", |
| }) |
| except FileNotFoundError as e: |
| logger.warning(f"Model file missing {model_key}: {e}") |
| results.append({ |
| "model_key": model_key, |
| "display_name": manifest["models"].get(model_key, {}).get("display_name", model_key), |
| "error": "Model file missing. Check server configuration.", |
| }) |
| except Exception as e: |
| logger.exception(f"Unexpected error in {model_key}") |
| results.append({ |
| "model_key": model_key, |
| "display_name": manifest["models"].get(model_key, {}).get("display_name", model_key), |
| "error": f"Inference failed: {type(e).__name__}: {e}", |
| }) |
| |
| return {"image_url": image_url, "results": results} |
|
|
| |
| |
| |
|
|
| def app_status(): |
| """Get application status""" |
| labels = class_names() |
| manifest = model_manifest() |
| dataset_images = dataset_images_manifest() |
| models = [] |
| |
| for key, info in manifest["models"].items(): |
| path = model_path_for(info) |
| models.append({ |
| "key": key, |
| "display_name": info.get("display_name", key), |
| "file": info.get("file"), |
| "exists": path.exists(), |
| "last_conv_layer": info.get("last_conv_layer"), |
| "total_parameters": info.get("total_parameters"), |
| }) |
| |
| tensorflow_available = True |
| tensorflow_message = "Ready" |
| try: |
| tensorflow_modules() |
| except Exception as exc: |
| tensorflow_available = False |
| tensorflow_message = str(exc) |
| logger.warning(f"TensorFlow unavailable: {exc}") |
| |
| comparison_path = TABLE_DIR / "final_comparison_table.csv" |
| metrics_path = TABLE_DIR / "model_metrics.csv" |
| report_path = ARTIFACT_DIR / "ecommerce_nn_explainability_report.pdf" |
| |
| ready = bool(labels) and any(model["exists"] for model in models) and tensorflow_available |
| |
| return { |
| "ready": ready, |
| "artifact_dir": str(ARTIFACT_DIR), |
| "model_dir": str(MODEL_DIR), |
| "metadata_dir": str(METADATA_DIR), |
| "class_count": len(labels), |
| "models": models, |
| "tensorflow_available": tensorflow_available, |
| "tensorflow_message": tensorflow_message, |
| "tables": { |
| "final_comparison": read_csv_table(comparison_path), |
| "metrics": read_csv_table(metrics_path), |
| }, |
| "dataset_images": { |
| "count": len(dataset_images), |
| "classes": sorted({row["label"] for row in dataset_images}), |
| "dir": str(DATASET_IMAGE_DIR), |
| "local_dir": str(LOCAL_DATASET_DIR), |
| "manifest": str(DATASET_IMAGE_MANIFEST_PATH), |
| }, |
| "report_url": url_for("output_file", filename="ecommerce_nn_explainability_report.pdf") |
| if report_path.exists() else None, |
| } |
|
|
| |
| |
| |
|
|
| @app.route("/") |
| def index(): |
| """Serve main HTML""" |
| return render_template("index.html") |
|
|
| @app.route("/api/status") |
| def status(): |
| """Get app status""" |
| return jsonify(app_status()) |
|
|
| @app.route("/api/dataset-images") |
| def dataset_images(): |
| """Get paginated dataset images""" |
| try: |
| limit = max(1, min(int(request.args.get("limit", 24)), 96)) |
| offset = max(0, int(request.args.get("offset", 0))) |
| except ValueError: |
| limit, offset = 24, 0 |
| |
| label_filter = (request.args.get("label") or "").strip() |
| all_images = dataset_images_manifest() |
| labels = sorted({row["label"] for row in all_images}) |
| filtered = [row for row in all_images if not label_filter or row["label"] == label_filter] |
| page_rows = filtered[offset : offset + limit] |
| |
| items = [] |
| for row in page_rows: |
| item = dict(row) |
| item["url"] = dataset_image_url(row) |
| items.append(item) |
| |
| next_offset = offset + len(items) if offset + len(items) < len(filtered) else None |
| |
| return jsonify({ |
| "items": items, |
| "total": len(filtered), |
| "offset": offset, |
| "limit": limit, |
| "next_offset": next_offset, |
| "classes": labels, |
| }) |
|
|
| @app.route("/dataset-images/<image_id>") |
| def dataset_image_file(image_id): |
| """Serve dataset image""" |
| image_info = dataset_image_by_id(image_id) |
| if image_info is None: |
| return jsonify({"error": "Image not found"}), 404 |
| |
| try: |
| image_path = dataset_image_file_path(image_info) |
| except Exception as exc: |
| logger.error(f"Dataset image error: {exc}") |
| return jsonify({"error": str(exc)}), 400 |
| |
| return send_from_directory(image_path.parent, image_path.name) |
|
|
| @app.route("/api/predict", methods=["POST"]) |
| def predict(): |
| """Predict on uploaded image""" |
| labels = class_names() |
| if not labels: |
| logger.error("class_names.json not found") |
| return jsonify({"error": "Classifier not configured. Run Kaggle notebook first."}), 400 |
| |
| image_file = request.files.get("image") |
| if not image_file or not image_file.filename: |
| return jsonify({"error": "No image uploaded."}), 400 |
| |
| if not validate_file_extension(image_file.filename): |
| return jsonify({"error": "Invalid image format. Use JPG, PNG, WEBP, or BMP."}), 400 |
| |
| try: |
| manifest = model_manifest() |
| selected_models = request.form.getlist("models") or list(manifest["models"].keys()) |
| |
| logger.info(f"Prediction request: {image_file.filename} with models {selected_models}") |
| image_array, image_url = prepare_image(image_file, manifest["input_size"]) |
| return jsonify(predict_with_models(image_array, image_url, selected_models)) |
| except ValueError as e: |
| logger.warning(f"Invalid image: {e}") |
| return jsonify({"error": str(e)}), 400 |
| except Exception as e: |
| logger.exception("Prediction error") |
| return jsonify({"error": "Prediction failed. Check server logs."}), 500 |
|
|
| @app.route("/api/predict-dataset", methods=["POST"]) |
| def predict_dataset(): |
| """Predict on dataset image""" |
| labels = class_names() |
| if not labels: |
| return jsonify({"error": "Classifier not configured."}), 400 |
| |
| payload = request.get_json(silent=True) or {} |
| selected_models = payload.get("models") or [] |
| if isinstance(selected_models, str): |
| selected_models = [selected_models] |
| |
| image_info = dataset_image_by_id(payload.get("image_id")) |
| if image_info is None: |
| return jsonify({"error": "Dataset image not found."}), 404 |
| |
| try: |
| manifest = model_manifest() |
| image_url = dataset_image_url(image_info) |
| image_path = dataset_image_file_path(image_info) |
| |
| logger.info(f"Dataset prediction: {image_info.get('filename')} with models {selected_models}") |
| image_array, image_url = prepare_artifact_image(image_path, manifest["input_size"], image_url) |
| return jsonify(predict_with_models(image_array, image_url, selected_models)) |
| except Exception as e: |
| logger.exception("Dataset prediction error") |
| return jsonify({"error": "Dataset prediction failed."}), 500 |
|
|
| @app.route("/figures/<path:filename>") |
| def figure_file(filename): |
| """Serve precomputed reference figures (SHAP, confusion matrices, etc.)""" |
| safe = os.path.basename(filename) |
| target = (FIGURE_DIR / safe).resolve() |
| if not str(target).startswith(str(FIGURE_DIR.resolve())) or not target.exists(): |
| return jsonify({"error": "Figure not found"}), 404 |
| return send_from_directory(FIGURE_DIR, safe) |
|
|
| @app.route("/outputs/<path:filename>") |
| def output_file(filename): |
| """Serve output files""" |
| try: |
| |
| if not normalize_artifact_path(filename): |
| return jsonify({"error": "Invalid file path"}), 400 |
| return send_from_directory(ARTIFACT_DIR, filename) |
| except Exception as e: |
| logger.warning(f"Output file error: {e}") |
| return jsonify({"error": "File not found"}), 404 |
|
|
| @app.before_request |
| def periodic_cleanup(): |
| """Clean old generated files (every 10 requests)""" |
| if not hasattr(app, "request_count"): |
| app.request_count = 0 |
| |
| app.request_count += 1 |
| if app.request_count % 10 == 0: |
| removed = cleanup_old_generated_files() |
| if removed > 0: |
| logger.info(f"Cleaned {removed} old files") |
|
|
| @app.errorhandler(413) |
| def request_entity_too_large(error): |
| """Handle file too large""" |
| return jsonify({"error": "File too large. Max 30 MB."}), 413 |
|
|
| @app.errorhandler(500) |
| def internal_error(error): |
| """Handle server errors""" |
| logger.exception("Internal server error") |
| return jsonify({"error": "Server error. Check logs."}), 500 |
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| logger.info("=== Starting Ecommerce Product Classifier ===") |
| logger.info(f"Artifact dir: {ARTIFACT_DIR}") |
| logger.info(f"Model dir: {MODEL_DIR}") |
| |
| |
| try: |
| tensorflow_modules() |
| except RuntimeError as e: |
| logger.warning(f"TensorFlow not available at startup: {e}") |
| |
| debug = os.getenv("FLASK_DEBUG", "0") == "1" |
| port = int(os.getenv("PORT", "5000")) |
| host = os.getenv("HOST", "0.0.0.0") |
| |
| logger.info(f"Starting server on {host}:{port} (debug={debug})") |
| app.run(host=host, port=port, debug=debug, use_reloader=False) |
|
|