Spaces:
Sleeping
Sleeping
Commit ·
7a5bb5d
0
Parent(s):
Initial commit: AI-powered Oil Spill Detection and Monitoring System
Browse files- .gitignore +29 -0
- Colab_Training_Notebook.ipynb +68 -0
- README.md +62 -0
- backend/Dockerfile +18 -0
- backend/inference.py +75 -0
- backend/main.py +72 -0
- backend/requirements.txt +7 -0
- docker-compose.yml +26 -0
- frontend/.gitignore +24 -0
- frontend/Dockerfile +14 -0
- frontend/README.md +73 -0
- frontend/eslint.config.js +23 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +31 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/icons.svg +24 -0
- frontend/src/App.css +327 -0
- frontend/src/App.tsx +392 -0
- frontend/src/assets/hero.png +0 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/assets/vite.svg +1 -0
- frontend/src/index.css +111 -0
- frontend/src/main.tsx +10 -0
- frontend/tsconfig.app.json +25 -0
- frontend/tsconfig.json +7 -0
- frontend/tsconfig.node.json +24 -0
- frontend/vite.config.ts +7 -0
- model/loss_metrics.py +37 -0
- model/train.py +103 -0
- model/unet.py +52 -0
- utils/data_loader.py +80 -0
.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Node / Frontend
|
| 2 |
+
node_modules/
|
| 3 |
+
dist/
|
| 4 |
+
build/
|
| 5 |
+
.env
|
| 6 |
+
.env.*
|
| 7 |
+
!.env.example
|
| 8 |
+
|
| 9 |
+
# Python / Backend
|
| 10 |
+
__pycache__/
|
| 11 |
+
*.py[cod]
|
| 12 |
+
*$py.class
|
| 13 |
+
.venv/
|
| 14 |
+
env/
|
| 15 |
+
venv/
|
| 16 |
+
ENV/
|
| 17 |
+
.env
|
| 18 |
+
|
| 19 |
+
# Models / Large Files
|
| 20 |
+
*.keras
|
| 21 |
+
*.h5
|
| 22 |
+
*.pkl
|
| 23 |
+
model/saved_models/*.keras
|
| 24 |
+
data/images/
|
| 25 |
+
data/masks/
|
| 26 |
+
|
| 27 |
+
# OS
|
| 28 |
+
.DS_Store
|
| 29 |
+
Thumbs.db
|
Colab_Training_Notebook.ipynb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"nbformat": 4,
|
| 3 |
+
"nbformat_minor": 4,
|
| 4 |
+
"metadata": {},
|
| 5 |
+
"cells": [
|
| 6 |
+
{
|
| 7 |
+
"cell_type": "markdown",
|
| 8 |
+
"metadata": {},
|
| 9 |
+
"source": [
|
| 10 |
+
"# DeepOceans - Satellite Semantic Segmentation Training\nMake sure to go to **Runtime > Change runtime type > T4 GPU** before running."
|
| 11 |
+
]
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"cell_type": "code",
|
| 15 |
+
"execution_count": null,
|
| 16 |
+
"metadata": {},
|
| 17 |
+
"outputs": [],
|
| 18 |
+
"source": [
|
| 19 |
+
"# 1. Mount Google Drive\nfrom google.colab import drive\ndrive.mount('/content/drive')\n"
|
| 20 |
+
]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"cell_type": "code",
|
| 24 |
+
"execution_count": null,
|
| 25 |
+
"metadata": {},
|
| 26 |
+
"outputs": [],
|
| 27 |
+
"source": [
|
| 28 |
+
"# 2. Imports and Setup\nimport os\nimport glob\nimport tensorflow as tf\nfrom tensorflow.keras import layers, models, backend as K\nfrom tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping, ReduceLROnPlateau\nfrom sklearn.model_selection import train_test_split\n\nprint(\"TensorFlow Version:\", tf.__version__)\nprint(\"GPU Available:\", tf.config.list_physical_devices('GPU'))\n"
|
| 29 |
+
]
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"cell_type": "code",
|
| 33 |
+
"execution_count": null,
|
| 34 |
+
"metadata": {},
|
| 35 |
+
"outputs": [],
|
| 36 |
+
"source": [
|
| 37 |
+
"# 3. U-Net Architecture Construction\ndef conv_block(input_tensor, num_filters):\n x = layers.Conv2D(num_filters, (3, 3), padding=\"same\")(input_tensor)\n x = layers.BatchNormalization()(x)\n x = layers.Activation(\"relu\")(x)\n x = layers.Conv2D(num_filters, (3, 3), padding=\"same\")(x)\n x = layers.BatchNormalization()(x)\n x = layers.Activation(\"relu\")(x)\n return x\n\ndef encoder_block(input_tensor, num_filters):\n x = conv_block(input_tensor, num_filters)\n p = layers.MaxPooling2D((2, 2))(x)\n return x, p\n\ndef decoder_block(input_tensor, concat_tensor, num_filters):\n x = layers.Conv2DTranspose(num_filters, (2, 2), strides=(2, 2), padding=\"same\")(input_tensor)\n x = layers.concatenate([x, concat_tensor])\n x = conv_block(x, num_filters)\n return x\n\ndef build_unet(input_shape=(256, 256, 3)):\n inputs = layers.Input(shape=input_shape)\n e1, p1 = encoder_block(inputs, 64)\n e2, p2 = encoder_block(p1, 128)\n e3, p3 = encoder_block(p2, 256)\n e4, p4 = encoder_block(p3, 512)\n b = conv_block(p4, 1024)\n d1 = decoder_block(b, e4, 512)\n d2 = decoder_block(d1, e3, 256)\n d3 = decoder_block(d2, e2, 128)\n d4 = decoder_block(d3, e1, 64)\n outputs = layers.Conv2D(1, (1, 1), padding=\"same\", activation=\"sigmoid\")(d4)\n model = models.Model(inputs, outputs, name=\"U-Net\")\n return model\n"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"cell_type": "code",
|
| 42 |
+
"execution_count": null,
|
| 43 |
+
"metadata": {},
|
| 44 |
+
"outputs": [],
|
| 45 |
+
"source": [
|
| 46 |
+
"# 4. Custom Loss Functions & Metrics (Dice + BCE)\ndef dice_coef(y_true, y_pred, smooth=1e-6):\n y_true_f = K.flatten(y_true)\n y_pred_f = K.flatten(y_pred)\n intersection = K.sum(y_true_f * y_pred_f)\n return (2. * intersection + smooth) / (K.sum(y_true_f) + K.sum(y_pred_f) + smooth)\n\ndef dice_loss(y_true, y_pred):\n return 1 - dice_coef(y_true, y_pred)\n\ndef bce_dice_loss(y_true, y_pred):\n bce = tf.keras.losses.binary_crossentropy(y_true, y_pred)\n bce = K.mean(bce) \n return bce + dice_loss(y_true, y_pred)\n\ndef iou_metric(y_true, y_pred, smooth=1e-6):\n y_true_f = K.flatten(y_true)\n y_pred_f = K.flatten(y_pred)\n intersection = K.sum(y_true_f * y_pred_f)\n union = K.sum(y_true_f) + K.sum(y_pred_f) - intersection\n return (intersection + smooth) / (union + smooth)\n\ndef get_metrics():\n return [\n dice_coef,\n iou_metric,\n tf.keras.metrics.Precision(name='precision'),\n tf.keras.metrics.Recall(name='recall')\n ]\n"
|
| 47 |
+
]
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"cell_type": "code",
|
| 51 |
+
"execution_count": null,
|
| 52 |
+
"metadata": {},
|
| 53 |
+
"outputs": [],
|
| 54 |
+
"source": [
|
| 55 |
+
"# 5. Data Pipeline & Augmentation\nIMG_SIZE = (256, 256)\n\ndef decode_image(image_file):\n image = tf.io.read_file(image_file)\n image = tf.image.decode_image(image, channels=3, expand_animations=False)\n image = tf.image.resize(image, IMG_SIZE)\n image = tf.cast(image, tf.float32) / 255.0\n return image\n\ndef decode_mask(mask_file):\n mask = tf.io.read_file(mask_file)\n mask = tf.image.decode_image(mask, channels=1, expand_animations=False)\n mask = tf.image.resize(mask, IMG_SIZE)\n mask = tf.cast(mask, tf.float32) / 255.0\n mask = tf.math.round(mask) \n return mask\n\ndef process_path(image_path, mask_path):\n image = decode_image(image_path)\n mask = decode_mask(mask_path)\n return image, mask\n\ndef augment(image, mask):\n if tf.random.uniform(()) > 0.5:\n image = tf.image.flip_left_right(image)\n mask = tf.image.flip_left_right(mask)\n if tf.random.uniform(()) > 0.5:\n image = tf.image.flip_up_down(image)\n mask = tf.image.flip_up_down(mask)\n image = tf.image.random_brightness(image, max_delta=0.2)\n image = tf.clip_by_value(image, 0.0, 1.0)\n return image, mask\n\ndef get_dataset(image_paths, mask_paths, batch_size=16, is_train=True):\n dataset = tf.data.Dataset.from_tensor_slices((image_paths, mask_paths))\n if is_train:\n dataset = dataset.shuffle(buffer_size=1000)\n dataset = dataset.map(process_path, num_parallel_calls=tf.data.AUTOTUNE)\n if is_train:\n dataset = dataset.map(augment, num_parallel_calls=tf.data.AUTOTUNE)\n dataset = dataset.batch(batch_size)\n dataset = dataset.prefetch(tf.data.AUTOTUNE)\n return dataset\n"
|
| 56 |
+
]
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"cell_type": "code",
|
| 60 |
+
"execution_count": null,
|
| 61 |
+
"metadata": {},
|
| 62 |
+
"outputs": [],
|
| 63 |
+
"source": [
|
| 64 |
+
"# 6. Execute Training\n\n# ==============================================================================\n# \u26a0\ufe0f ACTION REQUIRED: Verify this points to the folder containing your dataset\n# (Ensure there is an 'images/' and 'masks/' folder inside this directory)\n# ==============================================================================\nDATA_DIR = '/content/drive/MyDrive/1j35RM-5uZbTOfDv0mBTeRRmOUPvvg28t'\n# ==============================================================================\n\nEPOCHS = 50\nBATCH_SIZE = 16\nLEARNING_RATE = 1e-4\n\ndef get_file_paths(data_dir):\n image_dir = os.path.join(data_dir, 'images')\n mask_dir = os.path.join(data_dir, 'masks')\n \n image_paths = glob.glob(os.path.join(image_dir, '*.*'))\n image_paths.sort()\n \n final_img_paths = []\n final_mask_paths = []\n for img in image_paths:\n base = os.path.splitext(os.path.basename(img))[0]\n masks = glob.glob(os.path.join(mask_dir, f\"{base}.*\"))\n if len(masks) > 0:\n final_img_paths.append(img)\n final_mask_paths.append(masks[0])\n\n return final_img_paths, final_mask_paths\n\nprint(\"Hunting for dataset at folder:\", DATA_DIR)\nimg_paths, mask_paths = get_file_paths(DATA_DIR)\nprint(f\"Found {len(img_paths)} valid image/mask pairs.\\n\")\n\nif len(img_paths) == 0:\n print(\"\u274c ERROR: No images found. Check that DATA_DIR contains your 'images' and 'masks' folders.\")\nelse:\n # 80/20 train/validation split\n train_x, val_x, train_y, val_y = train_test_split(img_paths, mask_paths, test_size=0.2, random_state=42)\n \n train_dataset = get_dataset(train_x, train_y, batch_size=BATCH_SIZE, is_train=True)\n val_dataset = get_dataset(val_x, val_y, batch_size=BATCH_SIZE, is_train=False)\n \n model = build_unet(input_shape=(256, 256, 3))\n optimizer = tf.keras.optimizers.Adam(learning_rate=LEARNING_RATE)\n \n model.compile(optimizer=optimizer, loss=bce_dice_loss, metrics=get_metrics())\n \n # Save directly to the root of your Google Drive so it's easy to download\n model_save_path = '/content/drive/MyDrive/oil_spill_unet_best.keras'\n \n callbacks = [\n ModelCheckpoint(model_save_path, verbose=1, save_best_only=True, monitor='val_loss'),\n EarlyStopping(monitor='val_loss', patience=15, restore_best_weights=True),\n ReduceLROnPlateau(monitor='val_loss', factor=0.1, patience=5, verbose=1, min_lr=1e-6)\n ]\n \n print(\"\ud83d\ude80 Initializing Training Pipeline...\")\n history = model.fit(\n train_dataset,\n validation_data=val_dataset,\n epochs=EPOCHS,\n callbacks=callbacks\n )\n \n print(\"\\n\u2705 Training Complete!\")\n print(f\"Your trained model file has been successfully saved to: {model_save_path}\")\n print(\"\u2b07\ufe0f You can now download this file and move it to your local 'model/saved_models/' folder.\")\n"
|
| 65 |
+
]
|
| 66 |
+
}
|
| 67 |
+
]
|
| 68 |
+
}
|
README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Oil Spill Detection & Monitoring System
|
| 2 |
+
|
| 3 |
+
This is a full-stack AI-driven application that detects oil spills from satellite imagery using a deep learning segmentation model (U-Net).
|
| 4 |
+
|
| 5 |
+
## System Architecture
|
| 6 |
+
* **AI/ML**: TensorFlow / Keras (U-Net)
|
| 7 |
+
* **Backend**: FastAPI (REST API with Python)
|
| 8 |
+
* **Frontend**: React + Vite + TypeScript (Modern UI)
|
| 9 |
+
* **Containerization**: Docker & Docker Compose
|
| 10 |
+
|
| 11 |
+
## Repository Directory Structure
|
| 12 |
+
* `backend/` - FastAPI predict/health endpoints and Inference Engine
|
| 13 |
+
* `frontend/` - React Vite App for the UI
|
| 14 |
+
* `model/` - U-Net architecture, Custom losses, and Training logic
|
| 15 |
+
* `utils/` - Data loading and augmentation pipelines utilizing `tf.data.Dataset`
|
| 16 |
+
* `data/` - Holds your images and masks
|
| 17 |
+
* `notebooks/` - Place to run exploratory code
|
| 18 |
+
|
| 19 |
+
## Getting Data (Google Drive)
|
| 20 |
+
Download the dataset using the following Google Drive link provided:
|
| 21 |
+
`https://drive.google.com/drive/folders/1j35RM-5uZbTOfDv0mBTeRRmOUPvvg28t?usp=sharing`
|
| 22 |
+
|
| 23 |
+
1. Download the images and masks folders inside it.
|
| 24 |
+
2. Place them inside the `./data/` folder in the root directory like so:
|
| 25 |
+
```
|
| 26 |
+
d:/Oill_SPLIT/data/images/
|
| 27 |
+
d:/Oill_SPLIT/data/masks/
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## Local Setup (Without Docker)
|
| 31 |
+
|
| 32 |
+
### Backend
|
| 33 |
+
1. `cd backend`
|
| 34 |
+
2. `pip install -r requirements.txt`
|
| 35 |
+
3. `uvicorn main:app --reload`
|
| 36 |
+
The API will be available at `http://localhost:8000`
|
| 37 |
+
|
| 38 |
+
### Frontend
|
| 39 |
+
1. `cd frontend`
|
| 40 |
+
2. `npm install`
|
| 41 |
+
3. `npm run dev`
|
| 42 |
+
The React UI will be available at `http://localhost:5173`
|
| 43 |
+
|
| 44 |
+
## Setup (With Docker)
|
| 45 |
+
To run the entire suite using Docker:
|
| 46 |
+
```bash
|
| 47 |
+
docker-compose up --build
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Model Training Instructions (Google Colab / Kaggle)
|
| 51 |
+
You can train the model on Google Colab leveraging the free T4 GPU.
|
| 52 |
+
|
| 53 |
+
1. Zip the `model/`, `utils/`, and `data/` directories and upload them to Google Drive or directly to Colab/Kaggle.
|
| 54 |
+
2. Open a Colab Notebook.
|
| 55 |
+
3. Install dependencies: `!pip install tensorflow scikit-learn pillow numpy`
|
| 56 |
+
4. Run the training script:
|
| 57 |
+
```bash
|
| 58 |
+
!python model/train.py --data_dir ./data --save_dir ./model/saved_models --epochs 50 --batch_size 16
|
| 59 |
+
```
|
| 60 |
+
5. Once trained, download `oil_spill_unet_best.keras` from `./model/saved_models` and place it in your local folder structure.
|
| 61 |
+
|
| 62 |
+
*(Note: During initial development, if `oil_spill_unet_best.keras` isn't found, the backend will auto-initialize an untrained "stub" model so you can immediately test the UI and API offline).*
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies required for OpenCV/Pillow if needed
|
| 6 |
+
RUN apt-get update && apt-get install -y libglib2.0-0 libsm6 libxext6 libxrender-dev && rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
COPY backend/requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
# Copy the entire project for path resolution, or correctly scope it
|
| 12 |
+
COPY backend/ ./backend/
|
| 13 |
+
COPY model/ ./model/
|
| 14 |
+
COPY utils/ ./utils/
|
| 15 |
+
|
| 16 |
+
EXPOSE 8000
|
| 17 |
+
|
| 18 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
backend/inference.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tensorflow as tf
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import io
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 9 |
+
from model.unet import build_unet
|
| 10 |
+
from model.loss_metrics import get_metrics, bce_dice_loss
|
| 11 |
+
|
| 12 |
+
# Path to the saved model weights
|
| 13 |
+
MODEL_PATH = os.path.join(os.path.dirname(__file__), "../model/saved_models/oil_spill_unet_best.keras")
|
| 14 |
+
IMG_SIZE = (256, 256)
|
| 15 |
+
|
| 16 |
+
class OilSpillModel:
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.model = None
|
| 19 |
+
self.load_model()
|
| 20 |
+
|
| 21 |
+
def load_model(self):
|
| 22 |
+
print(f"Attempting to load model from: {os.path.abspath(MODEL_PATH)}")
|
| 23 |
+
if os.path.exists(MODEL_PATH):
|
| 24 |
+
try:
|
| 25 |
+
# Provide custom objects if model was saved with custom metrics/loss
|
| 26 |
+
custom_objects = {
|
| 27 |
+
'bce_dice_loss': bce_dice_loss,
|
| 28 |
+
'dice_coef': get_metrics()[0],
|
| 29 |
+
'iou_metric': get_metrics()[1]
|
| 30 |
+
}
|
| 31 |
+
self.model = tf.keras.models.load_model(MODEL_PATH, custom_objects=custom_objects)
|
| 32 |
+
print("Model loaded successfully.")
|
| 33 |
+
except Exception as e:
|
| 34 |
+
print(f"Failed to load model from file: {e}")
|
| 35 |
+
self._build_stub_model()
|
| 36 |
+
else:
|
| 37 |
+
print("Trained model weights not found. Building an untrained stub model for development.")
|
| 38 |
+
self._build_stub_model()
|
| 39 |
+
|
| 40 |
+
def _build_stub_model(self):
|
| 41 |
+
"""Used for development when trained weights aren't available yet."""
|
| 42 |
+
# Builds architecture with random weights
|
| 43 |
+
self.model = build_unet(input_shape=(256, 256, 3))
|
| 44 |
+
|
| 45 |
+
def predict(self, image_bytes):
|
| 46 |
+
"""
|
| 47 |
+
Takes raw image bytes, preprocesses, predicts, and returns the binary mask array and confidence score.
|
| 48 |
+
"""
|
| 49 |
+
# Load image
|
| 50 |
+
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
| 51 |
+
|
| 52 |
+
# Resize to network input shape
|
| 53 |
+
img = img.resize(IMG_SIZE)
|
| 54 |
+
|
| 55 |
+
# Convert to numpy array and normalize to [0, 1]
|
| 56 |
+
img_array = np.array(img, dtype=np.float32) / 255.0
|
| 57 |
+
|
| 58 |
+
# Expand dimension to create a batch size of 1
|
| 59 |
+
img_array = np.expand_dims(img_array, axis=0)
|
| 60 |
+
|
| 61 |
+
# Inference
|
| 62 |
+
pred_mask = self.model.predict(img_array)[0] # Shape is (256, 256, 1)
|
| 63 |
+
|
| 64 |
+
# Calculate confidence metric:
|
| 65 |
+
# We average the probability of pixels that the network thinks are part of the spill (>0.5 probability)
|
| 66 |
+
oil_pixels = pred_mask[pred_mask > 0.5]
|
| 67 |
+
confidence = float(np.mean(oil_pixels)) if len(oil_pixels) > 0 else 0.0
|
| 68 |
+
|
| 69 |
+
# Threshold to create binary mask (255 for oil, 0 for background)
|
| 70 |
+
binary_mask = (pred_mask > 0.5).astype(np.uint8) * 255
|
| 71 |
+
|
| 72 |
+
return binary_mask, confidence
|
| 73 |
+
|
| 74 |
+
# Singleton prediction engine
|
| 75 |
+
prediction_engine = OilSpillModel()
|
backend/main.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 2 |
+
from fastapi.responses import Response, JSONResponse
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import io
|
| 6 |
+
import numpy as np
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
import os
|
| 11 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 12 |
+
from backend.inference import prediction_engine
|
| 13 |
+
|
| 14 |
+
app = FastAPI(title="Oil Spill Detection API", version="1.0.0", description="API to detect oil spills from satellite images using U-Net")
|
| 15 |
+
|
| 16 |
+
# Setup CORS for the React frontend
|
| 17 |
+
app.add_middleware(
|
| 18 |
+
CORSMiddleware,
|
| 19 |
+
allow_origins=["*"], # Allow all for local dev
|
| 20 |
+
allow_credentials=True,
|
| 21 |
+
allow_methods=["*"],
|
| 22 |
+
allow_headers=["*"],
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
@app.get("/health")
|
| 26 |
+
def health_check():
|
| 27 |
+
"""Health check endpoint to ensure API and Model are ready."""
|
| 28 |
+
return {"status": "healthy", "model_loaded": prediction_engine.model is not None}
|
| 29 |
+
|
| 30 |
+
@app.post("/predict")
|
| 31 |
+
async def predict_spill(file: UploadFile = File(...)):
|
| 32 |
+
"""Receives an image, performs inference, and returns a PNG mask segmenting the oil spill."""
|
| 33 |
+
if not file.content_type.startswith("image/"):
|
| 34 |
+
raise HTTPException(status_code=400, detail="Invalid file format. Upload an image.")
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
start_time = time.time()
|
| 38 |
+
|
| 39 |
+
contents = await file.read()
|
| 40 |
+
|
| 41 |
+
# Perform inference
|
| 42 |
+
mask_array, confidence = prediction_engine.predict(contents)
|
| 43 |
+
|
| 44 |
+
# Convert mask array (256, 256, 1) -> (256, 256) for Pillow
|
| 45 |
+
if len(mask_array.shape) == 3 and mask_array.shape[-1] == 1:
|
| 46 |
+
mask_array = np.squeeze(mask_array, axis=-1)
|
| 47 |
+
|
| 48 |
+
mask_image = Image.fromarray(mask_array.astype(np.uint8), mode="L")
|
| 49 |
+
|
| 50 |
+
# Save Image to bytes
|
| 51 |
+
img_byte_arr = io.BytesIO()
|
| 52 |
+
mask_image.save(img_byte_arr, format='PNG')
|
| 53 |
+
img_byte_arr = img_byte_arr.getvalue()
|
| 54 |
+
|
| 55 |
+
latency_ms = int((time.time() - start_time) * 1000)
|
| 56 |
+
|
| 57 |
+
# Include metadata in headers so frontend can read confidence and latency
|
| 58 |
+
headers = {
|
| 59 |
+
"Access-Control-Expose-Headers": "X-Confidence-Score, X-Inference-Latency-Ms",
|
| 60 |
+
"X-Confidence-Score": str(round(confidence * 100, 2)),
|
| 61 |
+
"X-Inference-Latency-Ms": str(latency_ms)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return Response(content=img_byte_arr, media_type="image/png", headers=headers)
|
| 65 |
+
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f"Error during prediction: {e}")
|
| 68 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
import uvicorn
|
| 72 |
+
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
python-multipart
|
| 4 |
+
tensorflow
|
| 5 |
+
numpy
|
| 6 |
+
Pillow
|
| 7 |
+
scikit-learn
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
backend:
|
| 5 |
+
build:
|
| 6 |
+
context: .
|
| 7 |
+
dockerfile: backend/Dockerfile
|
| 8 |
+
ports:
|
| 9 |
+
- "8000:8000"
|
| 10 |
+
volumes:
|
| 11 |
+
- ./backend:/app/backend
|
| 12 |
+
- ./model:/app/model
|
| 13 |
+
environment:
|
| 14 |
+
- PYTHONUNBUFFERED=1
|
| 15 |
+
|
| 16 |
+
frontend:
|
| 17 |
+
build:
|
| 18 |
+
context: .
|
| 19 |
+
dockerfile: frontend/Dockerfile
|
| 20 |
+
ports:
|
| 21 |
+
- "5173:5173"
|
| 22 |
+
volumes:
|
| 23 |
+
- ./frontend:/app/frontend
|
| 24 |
+
- /app/frontend/node_modules
|
| 25 |
+
depends_on:
|
| 26 |
+
- backend
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:18-alpine
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY frontend/package.json ./
|
| 6 |
+
# If package-lock.json exists, copy it (using wildcard prevents failure if missing initially)
|
| 7 |
+
COPY frontend/package-lock.json* ./
|
| 8 |
+
RUN npm install
|
| 9 |
+
|
| 10 |
+
COPY frontend/ .
|
| 11 |
+
|
| 12 |
+
EXPOSE 5173
|
| 13 |
+
|
| 14 |
+
CMD ["npm", "run", "dev", "--", "--host"]
|
frontend/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"lucide-react": "^1.8.0",
|
| 14 |
+
"react": "^19.2.4",
|
| 15 |
+
"react-dom": "^19.2.4"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@eslint/js": "^9.39.4",
|
| 19 |
+
"@types/node": "^24.12.2",
|
| 20 |
+
"@types/react": "^19.2.14",
|
| 21 |
+
"@types/react-dom": "^19.2.3",
|
| 22 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 23 |
+
"eslint": "^9.39.4",
|
| 24 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 25 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 26 |
+
"globals": "^17.4.0",
|
| 27 |
+
"typescript": "~6.0.2",
|
| 28 |
+
"typescript-eslint": "^8.58.0",
|
| 29 |
+
"vite": "^8.0.4"
|
| 30 |
+
}
|
| 31 |
+
}
|
frontend/public/favicon.svg
ADDED
|
|
frontend/public/icons.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
/* Color Palette */
|
| 5 |
+
--bg-app: #F8FAFC;
|
| 6 |
+
--bg-surface: #FFFFFF;
|
| 7 |
+
--bg-surface-hover: #F1F5F9;
|
| 8 |
+
|
| 9 |
+
--text-primary: #0F172A;
|
| 10 |
+
--text-secondary: #475569;
|
| 11 |
+
--text-tertiary: #94A3B8;
|
| 12 |
+
|
| 13 |
+
--accent: #0F766E;
|
| 14 |
+
--accent-hover: #0D9488;
|
| 15 |
+
--accent-light: #F0FDFA;
|
| 16 |
+
|
| 17 |
+
--alert: #D97706;
|
| 18 |
+
--alert-light: #FEF3C7;
|
| 19 |
+
|
| 20 |
+
--border-light: #E2E8F0;
|
| 21 |
+
--border-medium: #CBD5E1;
|
| 22 |
+
|
| 23 |
+
/* Shadows - Premium Soft */
|
| 24 |
+
--shadow-sm: 0 1px 2px 0 rgba(15, 23, 42, 0.04);
|
| 25 |
+
--shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.05), 0 2px 4px -2px rgba(15, 23, 42, 0.05);
|
| 26 |
+
--shadow-lg: 0 10px 15px -3px rgba(15, 23, 42, 0.08), 0 4px 6px -4px rgba(15, 23, 42, 0.04);
|
| 27 |
+
--shadow-hover: 0 20px 25px -5px rgba(15, 23, 42, 0.1), 0 8px 10px -6px rgba(15, 23, 42, 0.04);
|
| 28 |
+
|
| 29 |
+
/* Geometry */
|
| 30 |
+
--radius-sm: 6px;
|
| 31 |
+
--radius-md: 12px;
|
| 32 |
+
--radius-lg: 20px;
|
| 33 |
+
--radius-full: 9999px;
|
| 34 |
+
|
| 35 |
+
/* Fonts */
|
| 36 |
+
--font-ui: 'Inter', system-ui, sans-serif;
|
| 37 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
body {
|
| 41 |
+
margin: 0;
|
| 42 |
+
font-family: var(--font-ui);
|
| 43 |
+
background-color: var(--bg-app);
|
| 44 |
+
color: var(--text-primary);
|
| 45 |
+
-webkit-font-smoothing: antialiased;
|
| 46 |
+
-moz-osx-font-smoothing: grayscale;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
* { box-sizing: border-box; }
|
| 50 |
+
h1, h2, h3, h4, p { margin: 0; }
|
| 51 |
+
|
| 52 |
+
/* SHARED ANIMATIONS & UTILS */
|
| 53 |
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
| 54 |
+
.fade-in { animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
| 55 |
+
|
| 56 |
+
@keyframes spin { 100% { transform: rotate(360deg); } }
|
| 57 |
+
.loader { width: 18px; height: 18px; border: 2px solid #FFF; border-bottom-color: transparent; border-radius: 50%; display: inline-block; animation: spin 1s linear infinite; }
|
| 58 |
+
|
| 59 |
+
.badge-pill {
|
| 60 |
+
display: inline-flex;
|
| 61 |
+
align-items: center;
|
| 62 |
+
padding: 4px 12px;
|
| 63 |
+
background: var(--bg-app);
|
| 64 |
+
color: var(--text-secondary);
|
| 65 |
+
border-radius: var(--radius-full);
|
| 66 |
+
font-size: 0.75rem;
|
| 67 |
+
font-weight: 600;
|
| 68 |
+
letter-spacing: 0.05em;
|
| 69 |
+
text-transform: uppercase;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
/* ========================================================================= */
|
| 74 |
+
/* LANDING PAGE */
|
| 75 |
+
/* ========================================================================= */
|
| 76 |
+
|
| 77 |
+
.landing-container { background: #FFFFFF; display: flex; flex-direction: column; }
|
| 78 |
+
|
| 79 |
+
/* Nav */
|
| 80 |
+
.nav-bar { display: flex; justify-content: space-between; align-items: center; padding: 20px 6%; position: absolute; width: 100%; top: 0; z-index: 100; }
|
| 81 |
+
.logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.25rem; color: var(--text-primary); letter-spacing: -0.02em; }
|
| 82 |
+
.logo-icon { width: 32px; height: 32px; background: var(--text-primary); border-radius: 8px; display: flex; align-items: center; justify-content: center; }
|
| 83 |
+
|
| 84 |
+
.nav-actions { display: flex; align-items: center; gap: 24px; }
|
| 85 |
+
.btn-text { background: none; border: none; font-family: var(--font-ui); font-weight: 500; font-size: 0.95rem; color: var(--text-secondary); cursor: pointer; transition: color 0.2s; }
|
| 86 |
+
.btn-text:hover { color: var(--text-primary); }
|
| 87 |
+
|
| 88 |
+
.btn-primary { background: var(--text-primary); color: #FFF; border: none; padding: 10px 20px; border-radius: var(--radius-md); font-weight: 500; font-family: var(--font-ui); cursor: pointer; transition: all 0.2s; }
|
| 89 |
+
.btn-primary:hover { background: var(--accent); box-shadow: var(--shadow-md); transform: translateY(-1px); }
|
| 90 |
+
|
| 91 |
+
/* Hero */
|
| 92 |
+
.hero-section { padding: 160px 6% 120px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 32px; position: relative; overflow: hidden; }
|
| 93 |
+
.hero-badge { display: inline-block; padding: 6px 16px; background: var(--accent-light); color: var(--accent); border-radius: var(--radius-full); font-weight: 600; font-size: 0.85rem; border: 1px solid rgba(15,118,110,0.1); }
|
| 94 |
+
.hero-title { font-size: 4.5rem; font-weight: 800; line-height: 1.1; letter-spacing: -0.03em; max-width: 900px; color: var(--text-primary); z-index: 10; }
|
| 95 |
+
.hero-description { font-size: 1.25rem; line-height: 1.6; color: var(--text-secondary); max-width: 650px; z-index: 10; }
|
| 96 |
+
|
| 97 |
+
.hero-buttons { display: flex; gap: 16px; z-index: 10; }
|
| 98 |
+
.btn-primary-lg { background: var(--accent); color: #FFF; border: none; padding: 16px 32px; border-radius: var(--radius-full); font-size: 1.1rem; font-weight: 600; display: flex; align-items: center; gap: 8px; cursor: pointer; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
|
| 99 |
+
.btn-primary-lg:hover { background: var(--accent-hover); transform: translateY(-2px); box-shadow: 0 12px 24px -4px rgba(15,118,110,0.2); }
|
| 100 |
+
.btn-secondary-lg { background: #FFF; color: var(--text-primary); border: 1px solid var(--border-medium); padding: 16px 32px; border-radius: var(--radius-full); font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.3s ease; }
|
| 101 |
+
.btn-secondary-lg:hover { background: var(--bg-app); border-color: var(--text-primary); }
|
| 102 |
+
|
| 103 |
+
.hero-background-glow {
|
| 104 |
+
position: absolute; top: 10%; left: 50%; transform: translateX(-50%); width: 80%; height: 800px;
|
| 105 |
+
background: radial-gradient(circle, rgba(15,118,110,0.06) 0%, rgba(255,255,255,0) 70%); z-index: 0; pointer-events: none;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* Features */
|
| 109 |
+
.features-section { padding: 80px 6%; background: var(--bg-surface); }
|
| 110 |
+
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 40px; max-width: 1400px; margin: 0 auto; }
|
| 111 |
+
.feature-card { display: flex; flex-direction: column; gap: 16px; padding: 16px; }
|
| 112 |
+
.feature-icon-wrapper { width: 56px; height: 56px; background: var(--accent-light); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; color: var(--accent); }
|
| 113 |
+
.feature-card h3 { font-size: 1.25rem; font-weight: 600; color: var(--text-primary); }
|
| 114 |
+
.feature-card p { font-size: 1rem; color: var(--text-secondary); line-height: 1.6; }
|
| 115 |
+
|
| 116 |
+
/* How it Works */
|
| 117 |
+
.how-it-works-section { padding: 120px 6%; background: var(--bg-app); }
|
| 118 |
+
.section-header { text-align: center; margin-bottom: 80px; }
|
| 119 |
+
.section-header h2 { font-size: 2.5rem; font-weight: 700; margin-bottom: 16px; letter-spacing: -0.02em; }
|
| 120 |
+
.section-header p { font-size: 1.1rem; color: var(--text-secondary); }
|
| 121 |
+
|
| 122 |
+
.steps-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 32px; max-width: 1400px; margin: 0 auto; }
|
| 123 |
+
.step-card { background: var(--bg-surface); padding: 48px 32px; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); border: 1px solid var(--border-light); transition: all 0.3s ease; position: relative; overflow: hidden; }
|
| 124 |
+
.step-card:hover { transform: translateY(-5px); box-shadow: var(--shadow-hover); }
|
| 125 |
+
.step-numeral { position: absolute; top: 16px; right: 24px; font-size: 5rem; font-weight: 800; color: var(--bg-app); opacity: 0.5; font-family: var(--font-mono); }
|
| 126 |
+
.step-card h4 { font-size: 1.25rem; font-weight: 600; margin-bottom: 16px; position: relative; z-index: 10; }
|
| 127 |
+
.step-card p { color: var(--text-secondary); line-height: 1.6; position: relative; z-index: 10; }
|
| 128 |
+
|
| 129 |
+
/* Preview & CTA */
|
| 130 |
+
.preview-section { padding: 120px 6% 40px; background: var(--bg-surface); display: flex; justify-content: center; }
|
| 131 |
+
.preview-app-window { max-width: 1000px; width: 100%; box-shadow: var(--shadow-lg); border-radius: 12px; border: 1px solid var(--border-medium); overflow: hidden; background: #fff; }
|
| 132 |
+
.app-window-header { height: 38px; background: #F1F5F9; border-bottom: 1px solid var(--border-light); display: flex; align-items: center; padding: 0 16px; position: relative; }
|
| 133 |
+
.mac-dots { display: flex; gap: 8px; }
|
| 134 |
+
.mac-dot { width: 12px; height: 12px; border-radius: 50%; }
|
| 135 |
+
.mac-dot.red { background: #FF5F56; } .mac-dot.yellow { background: #FFBD2E; } .mac-dot.green { background: #27C93F; }
|
| 136 |
+
.app-title { position: absolute; left: 50%; transform: translateX(-50%); font-family: var(--font-ui); font-size: 0.75rem; color: var(--text-tertiary); font-weight: 500; letter-spacing: 0.05em; }
|
| 137 |
+
.app-window-body { height: 500px; padding: 16px; background: var(--bg-app); }
|
| 138 |
+
.app-mockup-skeleton { width: 100%; height: 100%; display: flex; gap: 16px; }
|
| 139 |
+
.mock-nav { width: 200px; background: #FFF; border-radius: 8px; border: 1px solid var(--border-light); }
|
| 140 |
+
.mock-layout { flex: 1; display: flex; flex-direction: column; gap: 16px; }
|
| 141 |
+
.mock-top { display: flex; gap: 16px; height: 100px; }
|
| 142 |
+
.mock-stat { flex: 1; background: #FFF; border-radius: 8px; border: 1px solid var(--border-light); }
|
| 143 |
+
.mock-main { flex: 1; display: flex; gap: 16px; }
|
| 144 |
+
.mock-side { width: 250px; background: #FFF; border-radius: 8px; border: 1px solid var(--border-light); }
|
| 145 |
+
.mock-center { flex: 1; background: #FFF; border-radius: 8px; border: 1px solid var(--border-light); }
|
| 146 |
+
|
| 147 |
+
.cta-section { padding: 120px 6%; text-align: center; background: var(--bg-surface); display: flex; flex-direction: column; align-items: center; gap: 24px; }
|
| 148 |
+
.cta-section h2 { font-size: 3rem; font-weight: 800; letter-spacing: -0.02em; }
|
| 149 |
+
.cta-section p { font-size: 1.25rem; color: var(--text-secondary); margin-bottom: 24px; }
|
| 150 |
+
|
| 151 |
+
.landing-footer { background: var(--bg-app); padding: 40px 6%; text-align: center; border-top: 1px solid var(--border-light); }
|
| 152 |
+
.footer-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; color: var(--text-tertiary); font-size: 0.9rem; }
|
| 153 |
+
@media (max-width: 600px) { .footer-content { flex-direction: column; gap: 16px; } }
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
/* ========================================================================= */
|
| 157 |
+
/* DASHBOARD APPLICATION */
|
| 158 |
+
/* ========================================================================= */
|
| 159 |
+
|
| 160 |
+
.app-layout {
|
| 161 |
+
min-height: 100vh;
|
| 162 |
+
display: flex;
|
| 163 |
+
flex-direction: column;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.app-header {
|
| 167 |
+
height: 64px;
|
| 168 |
+
background: var(--bg-surface);
|
| 169 |
+
border-bottom: 1px solid var(--border-light);
|
| 170 |
+
display: flex;
|
| 171 |
+
justify-content: space-between;
|
| 172 |
+
align-items: center;
|
| 173 |
+
padding: 0 32px;
|
| 174 |
+
position: sticky;
|
| 175 |
+
top: 0;
|
| 176 |
+
z-index: 50;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.app-logo { display: flex; align-items: center; gap: 12px; font-weight: 700; font-size: 1rem; color: var(--text-primary); }
|
| 180 |
+
.app-badge { font-weight: 500; color: var(--text-tertiary); background: var(--bg-app); padding: 4px 8px; border-radius: 6px; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
|
| 181 |
+
|
| 182 |
+
.header-actions { display: flex; align-items: center; gap: 24px; }
|
| 183 |
+
.connection-status { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); font-size: 0.8rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
| 184 |
+
.status-orb { width: 8px; height: 8px; background: var(--accent); border-radius: 50%; box-shadow: 0 0 8px var(--accent); }
|
| 185 |
+
|
| 186 |
+
.icon-btn, .profile-btn { background: none; border: none; color: var(--text-secondary); cursor: pointer; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: all 0.2s; }
|
| 187 |
+
.icon-btn:hover { background: var(--bg-app); color: var(--text-primary); }
|
| 188 |
+
.profile-btn { background: var(--text-primary); color: #FFF; }
|
| 189 |
+
.profile-btn:hover { background: var(--accent); }
|
| 190 |
+
|
| 191 |
+
.dashboard-content {
|
| 192 |
+
flex: 1;
|
| 193 |
+
padding: 32px;
|
| 194 |
+
width: 100%;
|
| 195 |
+
max-width: 1600px;
|
| 196 |
+
margin: 0 auto;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.dashboard-grid {
|
| 200 |
+
display: flex;
|
| 201 |
+
flex-direction: column;
|
| 202 |
+
gap: 32px;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/* Base Panel Styles */
|
| 206 |
+
.panel {
|
| 207 |
+
background: var(--bg-surface);
|
| 208 |
+
border-radius: var(--radius-lg);
|
| 209 |
+
border: 1px solid var(--border-light);
|
| 210 |
+
box-shadow: var(--shadow-sm);
|
| 211 |
+
padding: 24px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.panel-header { display: flex; justify-content: space-between; align-items: center; }
|
| 215 |
+
.panel-title { font-size: 1rem; font-weight: 600; color: var(--text-primary); }
|
| 216 |
+
|
| 217 |
+
/* Metrics Row (Top) */
|
| 218 |
+
.metrics-row {
|
| 219 |
+
display: grid;
|
| 220 |
+
grid-template-columns: repeat(4, 1fr);
|
| 221 |
+
gap: 24px;
|
| 222 |
+
}
|
| 223 |
+
@media (max-width: 1200px) { .metrics-row { grid-template-columns: repeat(2, 1fr); } }
|
| 224 |
+
|
| 225 |
+
.stat-card {
|
| 226 |
+
background: var(--bg-surface);
|
| 227 |
+
border: 1px solid var(--border-light);
|
| 228 |
+
border-radius: var(--radius-md);
|
| 229 |
+
padding: 24px;
|
| 230 |
+
display: flex;
|
| 231 |
+
align-items: center;
|
| 232 |
+
gap: 16px;
|
| 233 |
+
transition: all 0.25s ease;
|
| 234 |
+
box-shadow: var(--shadow-sm);
|
| 235 |
+
}
|
| 236 |
+
.stat-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-medium); }
|
| 237 |
+
.stat-icon-wrapper { width: 48px; height: 48px; background: var(--bg-app); border-radius: 12px; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); }
|
| 238 |
+
.stat-content { display: flex; flex-direction: column; gap: 4px; flex: 1; }
|
| 239 |
+
.stat-label { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
|
| 240 |
+
.stat-value { font-size: 1.5rem; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
|
| 241 |
+
|
| 242 |
+
.stat-control { display: flex; align-items: center; gap: 12px; margin-top: 4px; }
|
| 243 |
+
.stat-control input[type="range"] { flex: 1; accent-color: var(--accent); }
|
| 244 |
+
.range-val { font-family: var(--font-mono); font-size: 0.9rem; font-weight: 600; }
|
| 245 |
+
|
| 246 |
+
/* Main Section: Upload + Log */
|
| 247 |
+
.main-interaction-row {
|
| 248 |
+
display: grid;
|
| 249 |
+
grid-template-columns: 380px 1fr;
|
| 250 |
+
gap: 32px;
|
| 251 |
+
}
|
| 252 |
+
@media (max-width: 1024px) { .main-interaction-row { grid-template-columns: 1fr; } }
|
| 253 |
+
|
| 254 |
+
/* Upload Component */
|
| 255 |
+
.upload-container { display: flex; flex-direction: column; gap: 24px; }
|
| 256 |
+
.dropzone {
|
| 257 |
+
flex: 1;
|
| 258 |
+
border: 2px dashed var(--border-medium);
|
| 259 |
+
border-radius: var(--radius-md);
|
| 260 |
+
background: var(--bg-app);
|
| 261 |
+
display: flex;
|
| 262 |
+
flex-direction: column;
|
| 263 |
+
align-items: center;
|
| 264 |
+
justify-content: center;
|
| 265 |
+
padding: 40px 24px;
|
| 266 |
+
text-align: center;
|
| 267 |
+
cursor: pointer;
|
| 268 |
+
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
| 269 |
+
position: relative;
|
| 270 |
+
}
|
| 271 |
+
.dropzone input[type="file"] { position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
| 272 |
+
.dropzone:hover, .dropzone.drag-active { border-color: var(--accent); background: var(--accent-light); }
|
| 273 |
+
.dropzone-icon { color: var(--text-tertiary); margin-bottom: 16px; transition: transform 0.2s; }
|
| 274 |
+
.dropzone:hover .dropzone-icon { transform: translateY(-4px); color: var(--accent); }
|
| 275 |
+
.dropzone-text { font-size: 1rem; color: var(--text-primary); margin-bottom: 8px; font-weight: 500; }
|
| 276 |
+
.dropzone-primary { color: var(--accent); font-weight: 600; }
|
| 277 |
+
.dropzone-secondary { font-size: 0.85rem; color: var(--text-secondary); }
|
| 278 |
+
|
| 279 |
+
.selected-file-chip { margin-top: 16px; font-family: var(--font-mono); font-size: 0.85rem; background: #FFF; padding: 6px 16px; border-radius: 20px; box-shadow: var(--shadow-sm); border: 1px solid var(--border-light); z-index: 10; font-weight: 500; }
|
| 280 |
+
|
| 281 |
+
.btn-action-lg {
|
| 282 |
+
width: 100%;
|
| 283 |
+
padding: 16px;
|
| 284 |
+
border-radius: var(--radius-md);
|
| 285 |
+
background: var(--text-primary);
|
| 286 |
+
color: #FFF;
|
| 287 |
+
font-weight: 600;
|
| 288 |
+
font-size: 1rem;
|
| 289 |
+
border: none;
|
| 290 |
+
cursor: pointer;
|
| 291 |
+
display: flex;
|
| 292 |
+
align-items: center;
|
| 293 |
+
justify-content: center;
|
| 294 |
+
transition: all 0.3s;
|
| 295 |
+
}
|
| 296 |
+
.btn-action-lg:not(:disabled):hover { background: var(--accent); box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
| 297 |
+
.btn-action-lg:disabled { opacity: 0.6; cursor: not-allowed; }
|
| 298 |
+
|
| 299 |
+
/* History Component */
|
| 300 |
+
.history-container { display: flex; flex-direction: column; gap: 16px; min-height: 400px; }
|
| 301 |
+
.record-count { background: var(--bg-app); padding: 4px 12px; border-radius: var(--radius-full); font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); }
|
| 302 |
+
.history-list { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; max-height: 340px; padding-right: 8px; }
|
| 303 |
+
.history-row { display: flex; align-items: center; gap: 16px; padding: 12px; border: 1px solid var(--border-light); border-radius: var(--radius-md); transition: all 0.2s; cursor: default; }
|
| 304 |
+
.history-row:hover { background: var(--bg-app); border-color: var(--border-medium); }
|
| 305 |
+
.history-thumb { width: 56px; height: 56px; border-radius: 8px; object-fit: cover; background: var(--border-light); }
|
| 306 |
+
.history-details { flex: 1; display: flex; flex-direction: column; gap: 6px; overflow: hidden; }
|
| 307 |
+
.history-filename { font-size: 0.95rem; font-weight: 600; font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-primary); }
|
| 308 |
+
.history-time { font-size: 0.8rem; color: var(--text-secondary); }
|
| 309 |
+
.history-status { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
| 310 |
+
.status-badge { padding: 4px 12px; border-radius: var(--radius-full); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
| 311 |
+
.status-badge.alert { background: var(--alert-light); color: var(--alert); }
|
| 312 |
+
.status-badge.clear { background: var(--bg-app); color: var(--text-secondary); }
|
| 313 |
+
.history-score { font-family: var(--font-mono); font-size: 1rem; font-weight: 700; color: var(--text-primary); }
|
| 314 |
+
.empty-state { padding: 48px; text-align: center; color: var(--text-tertiary); font-weight: 500; border: 2px dashed var(--border-light); border-radius: var(--radius-md); margin-top: 24px; }
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
/* Bottom Component: Visualizations */
|
| 318 |
+
.visualization-container { padding: 32px; }
|
| 319 |
+
.frames-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
|
| 320 |
+
@media (max-width: 900px) { .frames-grid { grid-template-columns: 1fr; } }
|
| 321 |
+
.frame-card { display: flex; flex-direction: column; gap: 12px; }
|
| 322 |
+
.frame-header { font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; text-align: center; }
|
| 323 |
+
.frame-content { aspect-ratio: 1; background: var(--bg-app); border-radius: var(--radius-md); border: 1px solid var(--border-light); box-shadow: inset 0 2px 4px rgba(0,0,0,0.02); position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
|
| 324 |
+
.frame-content.dark-mode { background: #0F172A; border-color: #1E293B; }
|
| 325 |
+
.frame-content img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
|
| 326 |
+
.invert-render { filter: invert(1); }
|
| 327 |
+
.feature-overlay { mix-blend-mode: multiply; opacity: 0.8; filter: sepia(1) hue-rotate(130deg) saturate(500%); }
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { UploadCloud, Activity, CheckCircle, Layers, BarChart2, ArrowRight, ShieldCheck, Zap, Globe, Cpu, Settings, User } from 'lucide-react';
|
| 3 |
+
import './App.css';
|
| 4 |
+
|
| 5 |
+
interface HistoryRecord {
|
| 6 |
+
id: number;
|
| 7 |
+
timestamp: string;
|
| 8 |
+
filename: string;
|
| 9 |
+
confidence: number;
|
| 10 |
+
thumbnail: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function LandingPage({ onEnter }: { onEnter: () => void }) {
|
| 14 |
+
return (
|
| 15 |
+
<div className="landing-container">
|
| 16 |
+
<nav className="nav-bar">
|
| 17 |
+
<div className="logo">
|
| 18 |
+
<div className="logo-icon"><Activity size={20} color="#fff" /></div>
|
| 19 |
+
DeepOceans
|
| 20 |
+
</div>
|
| 21 |
+
<div className="nav-actions">
|
| 22 |
+
<button className="btn-text">Documentation</button>
|
| 23 |
+
<button className="btn-text">API Features</button>
|
| 24 |
+
<button className="btn-primary" onClick={onEnter}>View Dashboard</button>
|
| 25 |
+
</div>
|
| 26 |
+
</nav>
|
| 27 |
+
|
| 28 |
+
<main>
|
| 29 |
+
<section className="hero-section">
|
| 30 |
+
<div className="hero-badge">DeepOceans v1.2 Engine Live</div>
|
| 31 |
+
<h1 className="hero-title">Protecting Marine Life with Precision AI.</h1>
|
| 32 |
+
<p className="hero-description">
|
| 33 |
+
Enterprise-grade anomaly detection for satellite telemetry. Monitor coastal regions and detect oceanic oil spills automatically using our state-of-the-art segmentation network.
|
| 34 |
+
</p>
|
| 35 |
+
<div className="hero-buttons">
|
| 36 |
+
<button className="btn-primary-lg" onClick={onEnter}>Start Monitoring <ArrowRight size={20} /></button>
|
| 37 |
+
<button className="btn-secondary-lg">Talk to Sales</button>
|
| 38 |
+
</div>
|
| 39 |
+
<div className="hero-background-glow"></div>
|
| 40 |
+
</section>
|
| 41 |
+
|
| 42 |
+
<section className="features-section">
|
| 43 |
+
<div className="feature-grid">
|
| 44 |
+
<div className="feature-card">
|
| 45 |
+
<div className="feature-icon-wrapper"><Zap size={24} className="feature-icon" /></div>
|
| 46 |
+
<h3>Real-Time Inference</h3>
|
| 47 |
+
<p>Achieve sub-200ms latency on 256x256 image segmentation masks utilizing optimized T4 compute instances.</p>
|
| 48 |
+
</div>
|
| 49 |
+
<div className="feature-card">
|
| 50 |
+
<div className="feature-icon-wrapper"><Globe size={24} className="feature-icon" /></div>
|
| 51 |
+
<h3>Satellite Agnostic</h3>
|
| 52 |
+
<p>Process telemetry from SAR, Sentinel-1, or multi-spectral sources effortlessly through our robust API.</p>
|
| 53 |
+
</div>
|
| 54 |
+
<div className="feature-card">
|
| 55 |
+
<div className="feature-icon-wrapper"><ShieldCheck size={24} className="feature-icon" /></div>
|
| 56 |
+
<h3>Validated Accuracy</h3>
|
| 57 |
+
<p>Averaging 0.84 IoU against validation datasets, ensuring you capture maximum spill events with minimal noise.</p>
|
| 58 |
+
</div>
|
| 59 |
+
<div className="feature-card">
|
| 60 |
+
<div className="feature-icon-wrapper"><Cpu size={24} className="feature-icon" /></div>
|
| 61 |
+
<h3>Seamless Integration</h3>
|
| 62 |
+
<p>Plug predictions directly into your existing command center via standard REST protocols.</p>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</section>
|
| 66 |
+
|
| 67 |
+
<section className="how-it-works-section">
|
| 68 |
+
<div className="section-header">
|
| 69 |
+
<h2>Streamlined Operations</h2>
|
| 70 |
+
<p>From raw signal to actionable intelligence in three steps.</p>
|
| 71 |
+
</div>
|
| 72 |
+
<div className="steps-container">
|
| 73 |
+
<div className="step-card">
|
| 74 |
+
<span className="step-numeral">01</span>
|
| 75 |
+
<h4>Data Ingestion</h4>
|
| 76 |
+
<p>Upload your optical or synthetic aperture radar imagery directly into our secure pipeline.</p>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="step-card">
|
| 79 |
+
<span className="step-numeral">02</span>
|
| 80 |
+
<h4>Neural Processing</h4>
|
| 81 |
+
<p>Our proprietary U-Net architecture isolates hydrocarbon signatures instantly.</p>
|
| 82 |
+
</div>
|
| 83 |
+
<div className="step-card">
|
| 84 |
+
<span className="step-numeral">03</span>
|
| 85 |
+
<h4>Spatial Analytics</h4>
|
| 86 |
+
<p>Generate highly accurate masks and probability scores to dispatch cleanup crews faster.</p>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</section>
|
| 90 |
+
|
| 91 |
+
<section className="preview-section">
|
| 92 |
+
<div className="preview-app-window">
|
| 93 |
+
<div className="app-window-header">
|
| 94 |
+
<div className="mac-dots"><div className="mac-dot red"></div><div className="mac-dot yellow"></div><div className="mac-dot green"></div></div>
|
| 95 |
+
<div className="app-title">app.deepoceans.ai/workspace</div>
|
| 96 |
+
</div>
|
| 97 |
+
<div className="app-window-body">
|
| 98 |
+
<div className="app-mockup-skeleton">
|
| 99 |
+
<div className="mock-nav"></div>
|
| 100 |
+
<div className="mock-layout">
|
| 101 |
+
<div className="mock-top">
|
| 102 |
+
<div className="mock-stat"></div>
|
| 103 |
+
<div className="mock-stat"></div>
|
| 104 |
+
<div className="mock-stat"></div>
|
| 105 |
+
<div className="mock-stat"></div>
|
| 106 |
+
</div>
|
| 107 |
+
<div className="mock-main">
|
| 108 |
+
<div className="mock-side"></div>
|
| 109 |
+
<div className="mock-center"></div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</section>
|
| 116 |
+
|
| 117 |
+
<section className="cta-section">
|
| 118 |
+
<h2>Ready to revolutionize your monitoring?</h2>
|
| 119 |
+
<p>Join the marine conservation operations running on DeepOceans.</p>
|
| 120 |
+
<button className="btn-primary-lg" onClick={onEnter}>Launch Product <ArrowRight size={20} /></button>
|
| 121 |
+
</section>
|
| 122 |
+
</main>
|
| 123 |
+
|
| 124 |
+
<footer className="landing-footer">
|
| 125 |
+
<div className="footer-content">
|
| 126 |
+
<div className="logo"><Activity size={20} color="var(--accent)" /> DeepOceans</div>
|
| 127 |
+
<p>© 2026 DeepOceans AI. Securing marine futures.</p>
|
| 128 |
+
</div>
|
| 129 |
+
</footer>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function Dashboard() {
|
| 135 |
+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
| 136 |
+
const [preview, setPreview] = useState<string | null>(null);
|
| 137 |
+
const [maskSrc, setMaskSrc] = useState<string | null>(null);
|
| 138 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 139 |
+
const [confidence, setConfidence] = useState<number | null>(null);
|
| 140 |
+
const [latency, setLatency] = useState<number | null>(null);
|
| 141 |
+
const [threshold, setThreshold] = useState<number>(0.5);
|
| 142 |
+
|
| 143 |
+
const [history, setHistory] = useState<HistoryRecord[]>([
|
| 144 |
+
{
|
| 145 |
+
id: 1,
|
| 146 |
+
timestamp: new Date(Date.now() - 14400000).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}),
|
| 147 |
+
filename: 'sentinel_scan_alpha.png',
|
| 148 |
+
confidence: 84.2,
|
| 149 |
+
thumbnail: 'https://images.unsplash.com/photo-1621213038477-9dfaf942dcde?auto=format&fit=crop&w=48&h=48'
|
| 150 |
+
}
|
| 151 |
+
]);
|
| 152 |
+
|
| 153 |
+
const [isDragActive, setIsDragActive] = useState(false);
|
| 154 |
+
|
| 155 |
+
const handleFile = (file: File) => {
|
| 156 |
+
setSelectedFile(file);
|
| 157 |
+
const url = URL.createObjectURL(file);
|
| 158 |
+
setPreview(url);
|
| 159 |
+
setMaskSrc(null);
|
| 160 |
+
setConfidence(null);
|
| 161 |
+
setLatency(null);
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 165 |
+
if (e.target.files && e.target.files.length > 0) handleFile(e.target.files[0]);
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const onDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragActive(true); };
|
| 169 |
+
const onDragLeave = () => { setIsDragActive(false); };
|
| 170 |
+
const onDrop = (e: React.DragEvent) => {
|
| 171 |
+
e.preventDefault();
|
| 172 |
+
setIsDragActive(false);
|
| 173 |
+
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
const handleUpload = async () => {
|
| 177 |
+
if (!selectedFile || !preview) return;
|
| 178 |
+
|
| 179 |
+
setIsLoading(true);
|
| 180 |
+
const formData = new FormData();
|
| 181 |
+
formData.append("file", selectedFile);
|
| 182 |
+
|
| 183 |
+
try {
|
| 184 |
+
const response = await fetch("http://localhost:8000/predict", {
|
| 185 |
+
method: "POST",
|
| 186 |
+
body: formData,
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
if (response.ok) {
|
| 190 |
+
const confHeader = response.headers.get("X-Confidence-Score");
|
| 191 |
+
const latHeader = response.headers.get("X-Inference-Latency-Ms");
|
| 192 |
+
|
| 193 |
+
let confValue = 0;
|
| 194 |
+
if (confHeader) {
|
| 195 |
+
confValue = parseFloat(confHeader);
|
| 196 |
+
setConfidence(confValue);
|
| 197 |
+
}
|
| 198 |
+
if (latHeader) setLatency(parseInt(latHeader));
|
| 199 |
+
|
| 200 |
+
const blob = await response.blob();
|
| 201 |
+
const maskUrl = URL.createObjectURL(blob);
|
| 202 |
+
setMaskSrc(maskUrl);
|
| 203 |
+
|
| 204 |
+
const newRecord: HistoryRecord = {
|
| 205 |
+
id: Date.now(),
|
| 206 |
+
timestamp: new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}),
|
| 207 |
+
filename: selectedFile.name,
|
| 208 |
+
confidence: confValue,
|
| 209 |
+
thumbnail: preview
|
| 210 |
+
};
|
| 211 |
+
setHistory(prev => [newRecord, ...prev].slice(0, 10));
|
| 212 |
+
} else {
|
| 213 |
+
alert("Inference server returned an error. Ensure backend is running.");
|
| 214 |
+
}
|
| 215 |
+
} catch (error) {
|
| 216 |
+
console.error(error);
|
| 217 |
+
alert("Connection failure to inference node.");
|
| 218 |
+
} finally {
|
| 219 |
+
setIsLoading(false);
|
| 220 |
+
}
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
return (
|
| 224 |
+
<div className="app-layout">
|
| 225 |
+
{/* SaaS App Header */}
|
| 226 |
+
<header className="app-header">
|
| 227 |
+
<div className="app-logo">
|
| 228 |
+
<div className="logo-icon"><Activity size={18} color="#fff" /></div>
|
| 229 |
+
DeepOceans <span className="app-badge">Workspace</span>
|
| 230 |
+
</div>
|
| 231 |
+
<div className="header-actions">
|
| 232 |
+
<div className="connection-status">
|
| 233 |
+
<div className="status-orb"></div> Node Operational
|
| 234 |
+
</div>
|
| 235 |
+
<button className="icon-btn"><Settings size={18} /></button>
|
| 236 |
+
<button className="profile-btn"><User size={18} /></button>
|
| 237 |
+
</div>
|
| 238 |
+
</header>
|
| 239 |
+
|
| 240 |
+
<main className="dashboard-content">
|
| 241 |
+
<div className="dashboard-grid">
|
| 242 |
+
|
| 243 |
+
{/* Top Row: Metrics Grid */}
|
| 244 |
+
<div className="metrics-row">
|
| 245 |
+
<div className="stat-card">
|
| 246 |
+
<div className="stat-icon-wrapper"><BarChart2 size={20} className="stat-icon" /></div>
|
| 247 |
+
<div className="stat-content">
|
| 248 |
+
<div className="stat-label">Spill Probability</div>
|
| 249 |
+
<div className="stat-value" style={{ color: confidence && confidence > 50 ? 'var(--alert)' : 'var(--text-primary)' }}>
|
| 250 |
+
{confidence !== null ? `${confidence.toFixed(1)}%` : '--'}
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
<div className="stat-card">
|
| 255 |
+
<div className="stat-icon-wrapper"><Activity size={20} className="stat-icon" /></div>
|
| 256 |
+
<div className="stat-content">
|
| 257 |
+
<div className="stat-label">Inference Latency</div>
|
| 258 |
+
<div className="stat-value">{latency !== null ? `${latency}ms` : '--'}</div>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
<div className="stat-card">
|
| 262 |
+
<div className="stat-icon-wrapper"><CheckCircle size={20} className="stat-icon" /></div>
|
| 263 |
+
<div className="stat-content">
|
| 264 |
+
<div className="stat-label">Model IoU (Validation)</div>
|
| 265 |
+
<div className="stat-value">0.842</div>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
<div className="stat-card">
|
| 269 |
+
<div className="stat-icon-wrapper"><Layers size={20} className="stat-icon" /></div>
|
| 270 |
+
<div className="stat-content">
|
| 271 |
+
<div className="stat-label">Detection Threshold</div>
|
| 272 |
+
<div className="stat-control">
|
| 273 |
+
<input type="range" min="0.1" max="0.9" step="0.1" value={threshold} onChange={(e) => setThreshold(parseFloat(e.target.value))} />
|
| 274 |
+
<span className="range-val">{threshold.toFixed(2)}</span>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
|
| 280 |
+
<div className="main-interaction-row">
|
| 281 |
+
{/* Left: Uploader */}
|
| 282 |
+
<div className="upload-container panel">
|
| 283 |
+
<h3 className="panel-title">Data Ingestion</h3>
|
| 284 |
+
|
| 285 |
+
<div
|
| 286 |
+
className={`dropzone ${isDragActive ? 'drag-active' : ''} ${selectedFile ? 'has-file' : ''}`}
|
| 287 |
+
onDragOver={onDragOver}
|
| 288 |
+
onDragLeave={onDragLeave}
|
| 289 |
+
onDrop={onDrop}
|
| 290 |
+
>
|
| 291 |
+
<input type="file" id="file-upload" accept="image/*" onChange={handleFileChange} />
|
| 292 |
+
<UploadCloud size={48} className="dropzone-icon" />
|
| 293 |
+
<div className="dropzone-text">
|
| 294 |
+
<span className="dropzone-primary">Click to upload</span> or drag and drop
|
| 295 |
+
</div>
|
| 296 |
+
<div className="dropzone-secondary">JPG or PNG (Optimal size: 256x256)</div>
|
| 297 |
+
{selectedFile && <div className="selected-file-chip">{selectedFile.name}</div>}
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<button
|
| 301 |
+
className="btn-action-lg"
|
| 302 |
+
onClick={handleUpload}
|
| 303 |
+
disabled={!selectedFile || isLoading}
|
| 304 |
+
>
|
| 305 |
+
{isLoading ? <span className="loader" style={{marginRight: '8px'}}></span> : null}
|
| 306 |
+
{isLoading ? "Running Prediction Pipeline..." : "Execute Detection"}
|
| 307 |
+
</button>
|
| 308 |
+
</div>
|
| 309 |
+
|
| 310 |
+
{/* Right: History Log */}
|
| 311 |
+
<div className="history-container panel">
|
| 312 |
+
<div className="panel-header">
|
| 313 |
+
<h3 className="panel-title">Analysis History</h3>
|
| 314 |
+
<span className="record-count">{history.length} Records</span>
|
| 315 |
+
</div>
|
| 316 |
+
<div className="history-list">
|
| 317 |
+
{history.length === 0 ? (
|
| 318 |
+
<div className="empty-state">No telemetry analyzed yet.</div>
|
| 319 |
+
) : (
|
| 320 |
+
history.map(record => (
|
| 321 |
+
<div key={record.id} className="history-row fade-in">
|
| 322 |
+
<img src={record.thumbnail} alt="snapshot" className="history-thumb" />
|
| 323 |
+
<div className="history-details">
|
| 324 |
+
<div className="history-filename" title={record.filename}>{record.filename}</div>
|
| 325 |
+
<div className="history-time">{record.timestamp}</div>
|
| 326 |
+
</div>
|
| 327 |
+
<div className="history-status">
|
| 328 |
+
<div className={`status-badge ${record.confidence > 50 ? 'alert' : 'clear'}`}>
|
| 329 |
+
{record.confidence > 50 ? 'Anomaly Detected' : 'All Clear'}
|
| 330 |
+
</div>
|
| 331 |
+
<div className="history-score">{record.confidence.toFixed(1)}%</div>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
))
|
| 335 |
+
)}
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
|
| 340 |
+
{/* Bottom Row: Visualizations */}
|
| 341 |
+
<div className="visualization-container panel">
|
| 342 |
+
<div className="panel-header" style={{ marginBottom: '24px' }}>
|
| 343 |
+
<h3 className="panel-title">Spatial Rendering Analysis</h3>
|
| 344 |
+
<span className="badge-pill">U-Net Feature Map</span>
|
| 345 |
+
</div>
|
| 346 |
+
|
| 347 |
+
<div className="frames-grid">
|
| 348 |
+
<div className="frame-card">
|
| 349 |
+
<div className="frame-header">Source Imagery</div>
|
| 350 |
+
<div className="frame-content">
|
| 351 |
+
{preview ? <img src={preview} alt="Raw" className="fade-in" /> : <div className="empty-state">Awaiting Feed</div>}
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<div className="frame-card">
|
| 356 |
+
<div className="frame-header">Segmentation Mask</div>
|
| 357 |
+
<div className="frame-content dark-mode">
|
| 358 |
+
{maskSrc ? <img src={maskSrc} alt="Mask" className="fade-in invert-render" /> : <div className="empty-state">No Output</div>}
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
|
| 362 |
+
<div className="frame-card">
|
| 363 |
+
<div className="frame-header">Anomaly Overlay</div>
|
| 364 |
+
<div className="frame-content">
|
| 365 |
+
{preview && maskSrc ? (
|
| 366 |
+
<>
|
| 367 |
+
<img src={preview} alt="Base" />
|
| 368 |
+
<img src={maskSrc} alt="Overlay" className="fade-in feature-overlay" />
|
| 369 |
+
</>
|
| 370 |
+
) : <div className="empty-state">No Output</div>}
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
</div>
|
| 377 |
+
</main>
|
| 378 |
+
</div>
|
| 379 |
+
);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
function App() {
|
| 383 |
+
const [view, setView] = useState<'landing' | 'dashboard'>('landing');
|
| 384 |
+
|
| 385 |
+
return (
|
| 386 |
+
<>
|
| 387 |
+
{view === 'landing' ? <LandingPage onEnter={() => setView('dashboard')} /> : <Dashboard />}
|
| 388 |
+
</>
|
| 389 |
+
);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
export default App;
|
frontend/src/assets/hero.png
ADDED
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/assets/vite.svg
ADDED
|
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--text: #6b6375;
|
| 3 |
+
--text-h: #08060d;
|
| 4 |
+
--bg: #fff;
|
| 5 |
+
--border: #e5e4e7;
|
| 6 |
+
--code-bg: #f4f3ec;
|
| 7 |
+
--accent: #aa3bff;
|
| 8 |
+
--accent-bg: rgba(170, 59, 255, 0.1);
|
| 9 |
+
--accent-border: rgba(170, 59, 255, 0.5);
|
| 10 |
+
--social-bg: rgba(244, 243, 236, 0.5);
|
| 11 |
+
--shadow:
|
| 12 |
+
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
| 13 |
+
|
| 14 |
+
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
| 15 |
+
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
| 16 |
+
--mono: ui-monospace, Consolas, monospace;
|
| 17 |
+
|
| 18 |
+
font: 18px/145% var(--sans);
|
| 19 |
+
letter-spacing: 0.18px;
|
| 20 |
+
color-scheme: light dark;
|
| 21 |
+
color: var(--text);
|
| 22 |
+
background: var(--bg);
|
| 23 |
+
font-synthesis: none;
|
| 24 |
+
text-rendering: optimizeLegibility;
|
| 25 |
+
-webkit-font-smoothing: antialiased;
|
| 26 |
+
-moz-osx-font-smoothing: grayscale;
|
| 27 |
+
|
| 28 |
+
@media (max-width: 1024px) {
|
| 29 |
+
font-size: 16px;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
@media (prefers-color-scheme: dark) {
|
| 34 |
+
:root {
|
| 35 |
+
--text: #9ca3af;
|
| 36 |
+
--text-h: #f3f4f6;
|
| 37 |
+
--bg: #16171d;
|
| 38 |
+
--border: #2e303a;
|
| 39 |
+
--code-bg: #1f2028;
|
| 40 |
+
--accent: #c084fc;
|
| 41 |
+
--accent-bg: rgba(192, 132, 252, 0.15);
|
| 42 |
+
--accent-border: rgba(192, 132, 252, 0.5);
|
| 43 |
+
--social-bg: rgba(47, 48, 58, 0.5);
|
| 44 |
+
--shadow:
|
| 45 |
+
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
#social .button-icon {
|
| 49 |
+
filter: invert(1) brightness(2);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
#root {
|
| 54 |
+
width: 1126px;
|
| 55 |
+
max-width: 100%;
|
| 56 |
+
margin: 0 auto;
|
| 57 |
+
text-align: center;
|
| 58 |
+
border-inline: 1px solid var(--border);
|
| 59 |
+
min-height: 100svh;
|
| 60 |
+
display: flex;
|
| 61 |
+
flex-direction: column;
|
| 62 |
+
box-sizing: border-box;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
body {
|
| 66 |
+
margin: 0;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
h1,
|
| 70 |
+
h2 {
|
| 71 |
+
font-family: var(--heading);
|
| 72 |
+
font-weight: 500;
|
| 73 |
+
color: var(--text-h);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
h1 {
|
| 77 |
+
font-size: 56px;
|
| 78 |
+
letter-spacing: -1.68px;
|
| 79 |
+
margin: 32px 0;
|
| 80 |
+
@media (max-width: 1024px) {
|
| 81 |
+
font-size: 36px;
|
| 82 |
+
margin: 20px 0;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
h2 {
|
| 86 |
+
font-size: 24px;
|
| 87 |
+
line-height: 118%;
|
| 88 |
+
letter-spacing: -0.24px;
|
| 89 |
+
margin: 0 0 8px;
|
| 90 |
+
@media (max-width: 1024px) {
|
| 91 |
+
font-size: 20px;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
p {
|
| 95 |
+
margin: 0;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
code,
|
| 99 |
+
.counter {
|
| 100 |
+
font-family: var(--mono);
|
| 101 |
+
display: inline-flex;
|
| 102 |
+
border-radius: 4px;
|
| 103 |
+
color: var(--text-h);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
code {
|
| 107 |
+
font-size: 15px;
|
| 108 |
+
line-height: 135%;
|
| 109 |
+
padding: 4px 8px;
|
| 110 |
+
background: var(--code-bg);
|
| 111 |
+
}
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.tsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/tsconfig.app.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
| 4 |
+
"target": "es2023",
|
| 5 |
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "esnext",
|
| 7 |
+
"types": ["vite/client"],
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
|
| 10 |
+
/* Bundler mode */
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"allowImportingTsExtensions": true,
|
| 13 |
+
"verbatimModuleSyntax": true,
|
| 14 |
+
"moduleDetection": "force",
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
"jsx": "react-jsx",
|
| 17 |
+
|
| 18 |
+
/* Linting */
|
| 19 |
+
"noUnusedLocals": true,
|
| 20 |
+
"noUnusedParameters": true,
|
| 21 |
+
"erasableSyntaxOnly": true,
|
| 22 |
+
"noFallthroughCasesInSwitch": true
|
| 23 |
+
},
|
| 24 |
+
"include": ["src"]
|
| 25 |
+
}
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
frontend/tsconfig.node.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
| 4 |
+
"target": "es2023",
|
| 5 |
+
"lib": ["ES2023"],
|
| 6 |
+
"module": "esnext",
|
| 7 |
+
"types": ["node"],
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
|
| 10 |
+
/* Bundler mode */
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"allowImportingTsExtensions": true,
|
| 13 |
+
"verbatimModuleSyntax": true,
|
| 14 |
+
"moduleDetection": "force",
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
|
| 17 |
+
/* Linting */
|
| 18 |
+
"noUnusedLocals": true,
|
| 19 |
+
"noUnusedParameters": true,
|
| 20 |
+
"erasableSyntaxOnly": true,
|
| 21 |
+
"noFallthroughCasesInSwitch": true
|
| 22 |
+
},
|
| 23 |
+
"include": ["vite.config.ts"]
|
| 24 |
+
}
|
frontend/vite.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
// https://vite.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
})
|
model/loss_metrics.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tensorflow as tf
|
| 2 |
+
from tensorflow.keras import backend as K
|
| 3 |
+
|
| 4 |
+
def dice_coef(y_true, y_pred, smooth=1e-6):
|
| 5 |
+
"""Computes the Dice Coefficient."""
|
| 6 |
+
y_true_f = K.flatten(y_true)
|
| 7 |
+
y_pred_f = K.flatten(y_pred)
|
| 8 |
+
intersection = K.sum(y_true_f * y_pred_f)
|
| 9 |
+
return (2. * intersection + smooth) / (K.sum(y_true_f) + K.sum(y_pred_f) + smooth)
|
| 10 |
+
|
| 11 |
+
def dice_loss(y_true, y_pred):
|
| 12 |
+
"""Computes the Dice Loss."""
|
| 13 |
+
return 1 - dice_coef(y_true, y_pred)
|
| 14 |
+
|
| 15 |
+
def bce_dice_loss(y_true, y_pred):
|
| 16 |
+
"""Computes the Combined BCE + Dice Loss."""
|
| 17 |
+
bce = tf.keras.losses.binary_crossentropy(y_true, y_pred)
|
| 18 |
+
# Ensure dimensions match before adding
|
| 19 |
+
bce = K.mean(bce)
|
| 20 |
+
return bce + dice_loss(y_true, y_pred)
|
| 21 |
+
|
| 22 |
+
def iou_metric(y_true, y_pred, smooth=1e-6):
|
| 23 |
+
"""Computes Intersection over Union (IoU)."""
|
| 24 |
+
y_true_f = K.flatten(y_true)
|
| 25 |
+
y_pred_f = K.flatten(y_pred)
|
| 26 |
+
intersection = K.sum(y_true_f * y_pred_f)
|
| 27 |
+
union = K.sum(y_true_f) + K.sum(y_pred_f) - intersection
|
| 28 |
+
return (intersection + smooth) / (union + smooth)
|
| 29 |
+
|
| 30 |
+
def get_metrics():
|
| 31 |
+
"""Returns a list of metrics for model compilation."""
|
| 32 |
+
return [
|
| 33 |
+
dice_coef,
|
| 34 |
+
iou_metric,
|
| 35 |
+
tf.keras.metrics.Precision(name='precision'),
|
| 36 |
+
tf.keras.metrics.Recall(name='recall')
|
| 37 |
+
]
|
model/train.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import argparse
|
| 3 |
+
import tensorflow as tf
|
| 4 |
+
from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping, ReduceLROnPlateau
|
| 5 |
+
import glob
|
| 6 |
+
from sklearn.model_selection import train_test_split
|
| 7 |
+
|
| 8 |
+
from unet import build_unet
|
| 9 |
+
from loss_metrics import bce_dice_loss, get_metrics
|
| 10 |
+
import sys
|
| 11 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 12 |
+
from utils.data_loader import get_dataset
|
| 13 |
+
|
| 14 |
+
def get_file_paths(data_dir):
|
| 15 |
+
"""Retrieves image and mask paths."""
|
| 16 |
+
image_dir = os.path.join(data_dir, 'images')
|
| 17 |
+
mask_dir = os.path.join(data_dir, 'masks')
|
| 18 |
+
|
| 19 |
+
# Supported formats
|
| 20 |
+
image_paths = glob.glob(os.path.join(image_dir, '*.*'))
|
| 21 |
+
# Sorting ensures that image and mask align if they have identical names
|
| 22 |
+
image_paths.sort()
|
| 23 |
+
|
| 24 |
+
# Map image paths to corresponding mask paths
|
| 25 |
+
# Assumes masks have the exact same base name
|
| 26 |
+
mask_paths = []
|
| 27 |
+
for img_path in image_paths:
|
| 28 |
+
base_name = os.path.basename(img_path)
|
| 29 |
+
name, _ = os.path.splitext(base_name)
|
| 30 |
+
# Search for corresponding mask (usually png)
|
| 31 |
+
mask_search = glob.glob(os.path.join(mask_dir, f"{name}.*"))
|
| 32 |
+
if mask_search:
|
| 33 |
+
mask_paths.append(mask_search[0])
|
| 34 |
+
else:
|
| 35 |
+
print(f"Warning: No mask found for {img_path}")
|
| 36 |
+
# If no mask, we should ideally remove the image too, but for now just skip adding
|
| 37 |
+
|
| 38 |
+
# Filter image paths to only those with masks
|
| 39 |
+
valid_image_paths = [p for p in image_paths if glob.glob(os.path.join(mask_dir, f"{os.path.splitext(os.path.basename(p))[0]}.*"))]
|
| 40 |
+
valid_mask_paths = valid_image_paths.copy() # Placeholder - actually they match 1:1 if sorted and filtered
|
| 41 |
+
|
| 42 |
+
# Let's do it safely
|
| 43 |
+
final_img_paths = []
|
| 44 |
+
final_mask_paths = []
|
| 45 |
+
for img in image_paths:
|
| 46 |
+
base = os.path.splitext(os.path.basename(img))[0]
|
| 47 |
+
masks = glob.glob(os.path.join(mask_dir, f"{base}.*"))
|
| 48 |
+
if len(masks) > 0:
|
| 49 |
+
final_img_paths.append(img)
|
| 50 |
+
final_mask_paths.append(masks[0])
|
| 51 |
+
|
| 52 |
+
return final_img_paths, final_mask_paths
|
| 53 |
+
|
| 54 |
+
def train(args):
|
| 55 |
+
print(f"Starting training process. Using data dir: {args.data_dir}")
|
| 56 |
+
|
| 57 |
+
img_paths, mask_paths = get_file_paths(args.data_dir)
|
| 58 |
+
print(f"Found {len(img_paths)} image/mask pairs.")
|
| 59 |
+
|
| 60 |
+
if len(img_paths) == 0:
|
| 61 |
+
print("Error: No data found. Please check data_dir structure.")
|
| 62 |
+
return
|
| 63 |
+
|
| 64 |
+
# Split: 80% Train, 20% Val
|
| 65 |
+
train_x, val_x, train_y, val_y = train_test_split(img_paths, mask_paths, test_size=0.2, random_state=42)
|
| 66 |
+
|
| 67 |
+
train_dataset = get_dataset(train_x, train_y, batch_size=args.batch_size, is_train=True)
|
| 68 |
+
val_dataset = get_dataset(val_x, val_y, batch_size=args.batch_size, is_train=False)
|
| 69 |
+
|
| 70 |
+
model = build_unet(input_shape=(256, 256, 3))
|
| 71 |
+
|
| 72 |
+
optimizer = tf.keras.optimizers.Adam(learning_rate=args.learning_rate)
|
| 73 |
+
|
| 74 |
+
model.compile(optimizer=optimizer, loss=bce_dice_loss, metrics=get_metrics())
|
| 75 |
+
|
| 76 |
+
os.makedirs(args.save_dir, exist_ok=True)
|
| 77 |
+
model_path = os.path.join(args.save_dir, "oil_spill_unet_best.keras")
|
| 78 |
+
|
| 79 |
+
callbacks = [
|
| 80 |
+
ModelCheckpoint(model_path, verbose=1, save_best_only=True, monitor='val_loss'),
|
| 81 |
+
EarlyStopping(monitor='val_loss', patience=15, restore_best_weights=True),
|
| 82 |
+
ReduceLROnPlateau(monitor='val_loss', factor=0.1, patience=5, verbose=1, min_lr=1e-6)
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
history = model.fit(
|
| 86 |
+
train_dataset,
|
| 87 |
+
validation_data=val_dataset,
|
| 88 |
+
epochs=args.epochs,
|
| 89 |
+
callbacks=callbacks
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
print("Training complete. Best model saved at:", model_path)
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
parser = argparse.ArgumentParser("Train Oil Spill U-Net")
|
| 96 |
+
parser.add_argument("--data_dir", type=str, default="../data", help="Directory containing images/ and masks/ folders.")
|
| 97 |
+
parser.add_argument("--save_dir", type=str, default="../model/saved_models", help="Directory to save the trained model.")
|
| 98 |
+
parser.add_argument("--epochs", type=int, default=50, help="Number of training epochs.")
|
| 99 |
+
parser.add_argument("--batch_size", type=int, default=16, help="Batch size.")
|
| 100 |
+
parser.add_argument("--learning_rate", type=float, default=1e-4, help="Learning rate.")
|
| 101 |
+
|
| 102 |
+
args = parser.parse_args()
|
| 103 |
+
train(args)
|
model/unet.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tensorflow as tf
|
| 2 |
+
from tensorflow.keras import layers, models
|
| 3 |
+
|
| 4 |
+
def conv_block(input_tensor, num_filters):
|
| 5 |
+
x = layers.Conv2D(num_filters, (3, 3), padding="same")(input_tensor)
|
| 6 |
+
x = layers.BatchNormalization()(x)
|
| 7 |
+
x = layers.Activation("relu")(x)
|
| 8 |
+
|
| 9 |
+
x = layers.Conv2D(num_filters, (3, 3), padding="same")(x)
|
| 10 |
+
x = layers.BatchNormalization()(x)
|
| 11 |
+
x = layers.Activation("relu")(x)
|
| 12 |
+
return x
|
| 13 |
+
|
| 14 |
+
def encoder_block(input_tensor, num_filters):
|
| 15 |
+
x = conv_block(input_tensor, num_filters)
|
| 16 |
+
p = layers.MaxPooling2D((2, 2))(x)
|
| 17 |
+
return x, p
|
| 18 |
+
|
| 19 |
+
def decoder_block(input_tensor, concat_tensor, num_filters):
|
| 20 |
+
x = layers.Conv2DTranspose(num_filters, (2, 2), strides=(2, 2), padding="same")(input_tensor)
|
| 21 |
+
x = layers.concatenate([x, concat_tensor])
|
| 22 |
+
x = conv_block(x, num_filters)
|
| 23 |
+
return x
|
| 24 |
+
|
| 25 |
+
def build_unet(input_shape=(256, 256, 3)):
|
| 26 |
+
"""Builds a U-Net architecture for image segmentation."""
|
| 27 |
+
inputs = layers.Input(shape=input_shape)
|
| 28 |
+
|
| 29 |
+
# Encoder
|
| 30 |
+
e1, p1 = encoder_block(inputs, 64)
|
| 31 |
+
e2, p2 = encoder_block(p1, 128)
|
| 32 |
+
e3, p3 = encoder_block(p2, 256)
|
| 33 |
+
e4, p4 = encoder_block(p3, 512)
|
| 34 |
+
|
| 35 |
+
# Bridge
|
| 36 |
+
b = conv_block(p4, 1024)
|
| 37 |
+
|
| 38 |
+
# Decoder
|
| 39 |
+
d1 = decoder_block(b, e4, 512)
|
| 40 |
+
d2 = decoder_block(d1, e3, 256)
|
| 41 |
+
d3 = decoder_block(d2, e2, 128)
|
| 42 |
+
d4 = decoder_block(d3, e1, 64)
|
| 43 |
+
|
| 44 |
+
# Output (1 class for Oil Spill vs Background)
|
| 45 |
+
outputs = layers.Conv2D(1, (1, 1), padding="same", activation="sigmoid")(d4)
|
| 46 |
+
|
| 47 |
+
model = models.Model(inputs, outputs, name="U-Net")
|
| 48 |
+
return model
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
model = build_unet()
|
| 52 |
+
model.summary()
|
utils/data_loader.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tensorflow as tf
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
IMG_SIZE = (256, 256)
|
| 5 |
+
|
| 6 |
+
def decode_image(image_file):
|
| 7 |
+
"""Reads and decode an image to float32 [0, 1]."""
|
| 8 |
+
image = tf.io.read_file(image_file)
|
| 9 |
+
# Most general images dataset will be JPEG, but you can change to png if needed.
|
| 10 |
+
image = tf.image.decode_image(image, channels=3, expand_animations=False)
|
| 11 |
+
image = tf.image.resize(image, IMG_SIZE)
|
| 12 |
+
# Normalize between 0 and 1
|
| 13 |
+
image = tf.cast(image, tf.float32) / 255.0
|
| 14 |
+
return image
|
| 15 |
+
|
| 16 |
+
def decode_mask(mask_file):
|
| 17 |
+
"""Reads and decode a segmentation mask to binary [0, 1]."""
|
| 18 |
+
mask = tf.io.read_file(mask_file)
|
| 19 |
+
# Masks are typically PNG format
|
| 20 |
+
mask = tf.image.decode_image(mask, channels=1, expand_animations=False)
|
| 21 |
+
mask = tf.image.resize(mask, IMG_SIZE)
|
| 22 |
+
|
| 23 |
+
# Normalize and convert mask to binary (0 and 1)
|
| 24 |
+
mask = tf.cast(mask, tf.float32) / 255.0
|
| 25 |
+
mask = tf.math.round(mask)
|
| 26 |
+
return mask
|
| 27 |
+
|
| 28 |
+
def process_path(image_path, mask_path):
|
| 29 |
+
"""Loads image and mask from paths."""
|
| 30 |
+
image = decode_image(image_path)
|
| 31 |
+
mask = decode_mask(mask_path)
|
| 32 |
+
return image, mask
|
| 33 |
+
|
| 34 |
+
def augment(image, mask):
|
| 35 |
+
"""Applies random transformations for data augmentation."""
|
| 36 |
+
# Random flip left-right
|
| 37 |
+
if tf.random.uniform(()) > 0.5:
|
| 38 |
+
image = tf.image.flip_left_right(image)
|
| 39 |
+
mask = tf.image.flip_left_right(mask)
|
| 40 |
+
|
| 41 |
+
# Random flip up-down
|
| 42 |
+
if tf.random.uniform(()) > 0.5:
|
| 43 |
+
image = tf.image.flip_up_down(image)
|
| 44 |
+
mask = tf.image.flip_up_down(mask)
|
| 45 |
+
|
| 46 |
+
# Random brightness (only to the image, not the mask)
|
| 47 |
+
image = tf.image.random_brightness(image, max_delta=0.2)
|
| 48 |
+
|
| 49 |
+
# Clip values to be in [0, 1] after brightness modifications
|
| 50 |
+
image = tf.clip_by_value(image, 0.0, 1.0)
|
| 51 |
+
|
| 52 |
+
return image, mask
|
| 53 |
+
|
| 54 |
+
def get_dataset(image_paths, mask_paths, batch_size=16, is_train=True):
|
| 55 |
+
"""
|
| 56 |
+
Creates a tf.data.Dataset pipeline.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
image_paths (list): List of file paths to images.
|
| 60 |
+
mask_paths (list): List of file paths to corresponding masks.
|
| 61 |
+
batch_size (int): Size of batches.
|
| 62 |
+
is_train (bool): If true, applies shuffling and augmentations.
|
| 63 |
+
"""
|
| 64 |
+
dataset = tf.data.Dataset.from_tensor_slices((image_paths, mask_paths))
|
| 65 |
+
|
| 66 |
+
if is_train:
|
| 67 |
+
# Shuffle heavily for training
|
| 68 |
+
dataset = dataset.shuffle(buffer_size=1000)
|
| 69 |
+
|
| 70 |
+
# Map the image reading function across the dataset
|
| 71 |
+
dataset = dataset.map(process_path, num_parallel_calls=tf.data.AUTOTUNE)
|
| 72 |
+
|
| 73 |
+
if is_train:
|
| 74 |
+
# Apply data augmentations
|
| 75 |
+
dataset = dataset.map(augment, num_parallel_calls=tf.data.AUTOTUNE)
|
| 76 |
+
|
| 77 |
+
# Batch and prefetch for performance
|
| 78 |
+
dataset = dataset.batch(batch_size)
|
| 79 |
+
dataset = dataset.prefetch(tf.data.AUTOTUNE)
|
| 80 |
+
return dataset
|