Spaces:
Sleeping
Sleeping
clean working UI
Browse files- .gitignore +2 -0
- Dockerfile +23 -0
- app/backend/hub.py +63 -0
- app/backend/main.py +854 -0
- app/backend/processing.py +35 -0
- app/backend/schemas.py +49 -0
- app/backend/storage.py +268 -0
- app/frontend/app.js +748 -0
- app/frontend/index.html +237 -0
- app/frontend/styles.css +820 -0
- requirements.txt +13 -0
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# local env
|
| 2 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set the working directory in the container
|
| 5 |
+
WORKDIR /code
|
| 6 |
+
|
| 7 |
+
# Copy the requirements file into the container at /code
|
| 8 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 9 |
+
|
| 10 |
+
# Install any needed packages specified in requirements.txt
|
| 11 |
+
# Use --no-cache-dir to reduce image size
|
| 12 |
+
# Install libgl1 for OpenCV headless dependencies
|
| 13 |
+
RUN apt-get update && apt-get install -y libgl1 \
|
| 14 |
+
&& pip install --no-cache-dir --upgrade pip \
|
| 15 |
+
&& pip install --no-cache-dir -r /code/requirements.txt
|
| 16 |
+
|
| 17 |
+
# Copy the rest of the application code into the container at /code
|
| 18 |
+
COPY ./app /code/app
|
| 19 |
+
|
| 20 |
+
# Command to run the application when the container launches
|
| 21 |
+
# Binds to 0.0.0.0 to be accessible from outside the container
|
| 22 |
+
# The port 7860 is the standard port for Hugging Face Spaces
|
| 23 |
+
CMD ["uvicorn", "app.backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/backend/hub.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# /app/backend/hub.py
|
| 2 |
+
import os
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Optional, Dict
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from huggingface_hub import HfApi, upload_folder, Repository, create_repo
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
logger.addHandler(logging.NullHandler())
|
| 11 |
+
|
| 12 |
+
def get_hf_api(token: Optional[str] = None) -> Optional[HfApi]:
|
| 13 |
+
"""Initializes HfApi using a provided token or the environment secret."""
|
| 14 |
+
token_to_use = token or os.getenv("HF_TOKEN")
|
| 15 |
+
if token_to_use:
|
| 16 |
+
return HfApi(token=token_to_use)
|
| 17 |
+
return None
|
| 18 |
+
|
| 19 |
+
def get_user_info(token: Optional[str] = None) -> Optional[Dict]:
|
| 20 |
+
"""Fetches user info from the Hub (returns None if unauthenticated)."""
|
| 21 |
+
api = get_hf_api(token)
|
| 22 |
+
if api:
|
| 23 |
+
try:
|
| 24 |
+
return api.whoami()
|
| 25 |
+
except Exception as e:
|
| 26 |
+
# print(f"Failed to authenticate with Hugging Face Hub: {e}")
|
| 27 |
+
logger.warning("Failed to authenticate with Hugging Face Hub: %s", e)
|
| 28 |
+
return None
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def push_dataset_to_hub(folder_path: str, repo_name: str, namespace: str, private: bool, commit_message: str, token: Optional[str] = None):
|
| 33 |
+
"""
|
| 34 |
+
Push the contents of folder_path to the Hugging Face datasets repo `namespace/repo_name`.
|
| 35 |
+
Uses token param if provided, otherwise falls back to HF_TOKEN from environment/.env.
|
| 36 |
+
"""
|
| 37 |
+
api = get_hf_api(token)
|
| 38 |
+
if not api:
|
| 39 |
+
raise ConnectionError("Hugging Face token not found. Provide token or set HF_TOKEN in environment or .env file.")
|
| 40 |
+
|
| 41 |
+
repo_id = f"{namespace}/{repo_name}"
|
| 42 |
+
|
| 43 |
+
# create repo if not exists
|
| 44 |
+
try:
|
| 45 |
+
api.create_repo(name=repo_name, token=token or os.getenv("HF_TOKEN"), repo_type="dataset", private=private, namespace=namespace)
|
| 46 |
+
logger.info(f"Created repo {repo_id} (or it already exists)")
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.info(f"Repo create warning (may already exist): {e}")
|
| 49 |
+
|
| 50 |
+
# upload entire folder (recursively) using upload_folder helper
|
| 51 |
+
try:
|
| 52 |
+
upload_folder(
|
| 53 |
+
folder_path=folder_path,
|
| 54 |
+
path_in_repo="",
|
| 55 |
+
repo_id=repo_id,
|
| 56 |
+
token=token or os.getenv("HF_TOKEN"),
|
| 57 |
+
repo_type="dataset",
|
| 58 |
+
commit_message=commit_message,
|
| 59 |
+
)
|
| 60 |
+
logger.info(f"Uploaded folder to {repo_id} from {folder_path}")
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.exception(f"upload_folder failed for {folder_path} -> {repo_id}: {e}")
|
| 63 |
+
raise
|
app/backend/main.py
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# /app/backend/main.py
|
| 2 |
+
import os
|
| 3 |
+
import cv2
|
| 4 |
+
import base64
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import aiofiles
|
| 7 |
+
import shutil
|
| 8 |
+
import logging
|
| 9 |
+
import tempfile
|
| 10 |
+
import json
|
| 11 |
+
import csv
|
| 12 |
+
from pydantic import BaseModel # especially for DeleteImageRequest
|
| 13 |
+
|
| 14 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException, Request
|
| 15 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 16 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
+
from starlette.staticfiles import StaticFiles
|
| 18 |
+
from fastapi.exceptions import RequestValidationError
|
| 19 |
+
|
| 20 |
+
from .schemas import *
|
| 21 |
+
from .storage import (
|
| 22 |
+
create_new_session, get_session_path, save_session_config,
|
| 23 |
+
load_session_config, extract_zip, move_labeled_file,
|
| 24 |
+
create_dataset_readme, create_dataset_zip
|
| 25 |
+
)
|
| 26 |
+
from .processing import crop_image #segment_with_grabcut
|
| 27 |
+
from .hub import get_user_info, push_dataset_to_hub
|
| 28 |
+
|
| 29 |
+
# Configure logging
|
| 30 |
+
logging.basicConfig(level=logging.INFO)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
app = FastAPI(
|
| 34 |
+
title="Tulasi Data Curator",
|
| 35 |
+
description="Interactive tool for curating and labeling Tulasi leaf disease datasets",
|
| 36 |
+
version="1.0.0"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# Add CORS middleware
|
| 40 |
+
app.add_middleware(
|
| 41 |
+
CORSMiddleware,
|
| 42 |
+
allow_origins=["*"],
|
| 43 |
+
allow_credentials=True,
|
| 44 |
+
allow_methods=["*"],
|
| 45 |
+
allow_headers=["*"],
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Default labeling schema
|
| 49 |
+
DEFAULT_SCHEMA = {
|
| 50 |
+
"Sri/Green Tulasi": [
|
| 51 |
+
"Healthy", "Leaf Spot", "Powdery Mildew", "Downy Mildew",
|
| 52 |
+
"Bacterial Blight", "Nutrient Deficiency", "Insect Damage",
|
| 53 |
+
"Drought/Scorch", "Mechanical Damage", "Unknown"
|
| 54 |
+
],
|
| 55 |
+
"Krishna/Black Tulasi": [
|
| 56 |
+
"Healthy", "Leaf Spot", "Powdery Mildew", "Downy Mildew",
|
| 57 |
+
"Bacterial Blight", "Nutrient Deficiency", "Insect Damage",
|
| 58 |
+
"Drought/Scorch", "Mechanical Damage", "Unknown"
|
| 59 |
+
],
|
| 60 |
+
"Unknown/Other": [
|
| 61 |
+
"Healthy", "Leaf Spot", "Powdery Mildew", "Downy Mildew",
|
| 62 |
+
"Bacterial Blight", "Nutrient Deficiency", "Insect Damage",
|
| 63 |
+
"Drought/Scorch", "Mechanical Damage", "Unknown"
|
| 64 |
+
],
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# Exception handlers
|
| 68 |
+
@app.exception_handler(RequestValidationError)
|
| 69 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 70 |
+
logger.error(f"Validation error: {exc}")
|
| 71 |
+
return JSONResponse(
|
| 72 |
+
status_code=422,
|
| 73 |
+
content={"detail": f"Validation error: {str(exc)}"}
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
@app.exception_handler(Exception)
|
| 77 |
+
async def general_exception_handler(request: Request, exc: Exception):
|
| 78 |
+
logger.error(f"Unexpected error: {exc}")
|
| 79 |
+
return JSONResponse(
|
| 80 |
+
status_code=500,
|
| 81 |
+
content={"detail": f"Internal server error: {str(exc)}"}
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
@app.post("/api/upload_zip")
|
| 85 |
+
async def upload_zip(file: UploadFile = File(...)):
|
| 86 |
+
"""Upload and extract a ZIP file containing images."""
|
| 87 |
+
logger.info(f"Uploading ZIP file: {file.filename}")
|
| 88 |
+
|
| 89 |
+
# Validate file
|
| 90 |
+
if not file.filename.lower().endswith('.zip'):
|
| 91 |
+
raise HTTPException(status_code=400, detail="File must be a ZIP archive")
|
| 92 |
+
|
| 93 |
+
# Check file size (10GB limit)
|
| 94 |
+
max_size = 10 * 1024 * 1024 * 1024 # 10GB
|
| 95 |
+
if hasattr(file, 'size') and file.size > max_size:
|
| 96 |
+
raise HTTPException(status_code=413, detail="File too large. Maximum size is 10GB.")
|
| 97 |
+
|
| 98 |
+
session_id, session_path = create_new_session()
|
| 99 |
+
zip_path = session_path / file.filename
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
# Save uploaded file
|
| 103 |
+
async with aiofiles.open(zip_path, 'wb') as out_file:
|
| 104 |
+
content = await file.read()
|
| 105 |
+
await out_file.write(content)
|
| 106 |
+
|
| 107 |
+
logger.info(f"Saved ZIP file to: {zip_path}")
|
| 108 |
+
|
| 109 |
+
# Extract images
|
| 110 |
+
originals_path = session_path / "originals"
|
| 111 |
+
image_ids = extract_zip(zip_path, originals_path)
|
| 112 |
+
|
| 113 |
+
if not image_ids:
|
| 114 |
+
raise HTTPException(status_code=400, detail="No valid images found in ZIP file.")
|
| 115 |
+
|
| 116 |
+
logger.info(f"Extracted {len(image_ids)} images")
|
| 117 |
+
|
| 118 |
+
# Create session configuration
|
| 119 |
+
config = SessionConfig(
|
| 120 |
+
varieties=DEFAULT_SCHEMA.copy(),
|
| 121 |
+
unlabeled_images=image_ids,
|
| 122 |
+
image_metadata={
|
| 123 |
+
img_id: ImageMetadata(
|
| 124 |
+
original_path=str(originals_path / img_id)
|
| 125 |
+
) for img_id in image_ids
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
save_session_config(session_id, config)
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
"session_id": session_id,
|
| 133 |
+
"image_count": len(image_ids),
|
| 134 |
+
"image_ids": image_ids,
|
| 135 |
+
"config": config.model_dump()
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(f"Error processing ZIP file: {e}")
|
| 140 |
+
# Cleanup on error
|
| 141 |
+
if session_path.exists():
|
| 142 |
+
shutil.rmtree(session_path, ignore_errors=True)
|
| 143 |
+
raise HTTPException(status_code=500, detail=f"Failed to process ZIP file: {str(e)}")
|
| 144 |
+
finally:
|
| 145 |
+
# Clean up uploaded ZIP file
|
| 146 |
+
if zip_path.exists():
|
| 147 |
+
os.remove(zip_path)
|
| 148 |
+
|
| 149 |
+
@app.get("/api/image/{session_id}/{image_id}")
|
| 150 |
+
async def get_image(session_id: str, image_id: str):
|
| 151 |
+
"""Serve an image file (resolved relative to session if necessary)."""
|
| 152 |
+
try:
|
| 153 |
+
config = load_session_config(session_id)
|
| 154 |
+
metadata = config.image_metadata.get(image_id)
|
| 155 |
+
|
| 156 |
+
if not metadata:
|
| 157 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 158 |
+
|
| 159 |
+
image_path = Path(metadata.processed_path or metadata.original_path)
|
| 160 |
+
|
| 161 |
+
# If path is not absolute, resolve it relative to session directory
|
| 162 |
+
if not image_path.is_absolute():
|
| 163 |
+
image_path = get_session_path(session_id) / image_path
|
| 164 |
+
|
| 165 |
+
if not image_path.exists():
|
| 166 |
+
raise HTTPException(status_code=404, detail="Image file not found on disk")
|
| 167 |
+
|
| 168 |
+
# set media type based on file extension
|
| 169 |
+
ext = image_path.suffix.lower()
|
| 170 |
+
if ext == ".png":
|
| 171 |
+
media_type = "image/png"
|
| 172 |
+
elif ext in (".jpg", ".jpeg"):
|
| 173 |
+
media_type = "image/jpeg"
|
| 174 |
+
else:
|
| 175 |
+
media_type = "application/octet-stream"
|
| 176 |
+
|
| 177 |
+
return FileResponse(
|
| 178 |
+
path=image_path,
|
| 179 |
+
media_type=media_type,
|
| 180 |
+
headers={"Cache-Control": "max-age=3600"}
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
except FileNotFoundError:
|
| 185 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 186 |
+
except HTTPException:
|
| 187 |
+
raise
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"Error serving image: {e}")
|
| 190 |
+
raise HTTPException(status_code=500, detail="Failed to serve image")
|
| 191 |
+
|
| 192 |
+
# from pydantic import BaseModel
|
| 193 |
+
|
| 194 |
+
class DeleteImageRequest(BaseModel):
|
| 195 |
+
session_id: str
|
| 196 |
+
image_id: str
|
| 197 |
+
|
| 198 |
+
@app.post("/api/delete_image")
|
| 199 |
+
async def delete_image(req: DeleteImageRequest):
|
| 200 |
+
"""Delete a single image from the session (original, processed, mask) and update session config."""
|
| 201 |
+
try:
|
| 202 |
+
config = load_session_config(req.session_id)
|
| 203 |
+
metadata = config.image_metadata.get(req.image_id)
|
| 204 |
+
if not metadata:
|
| 205 |
+
raise HTTPException(status_code=404, detail="Image not found in session")
|
| 206 |
+
|
| 207 |
+
session_path = get_session_path(req.session_id)
|
| 208 |
+
|
| 209 |
+
# remove original
|
| 210 |
+
try:
|
| 211 |
+
orig = Path(metadata.original_path)
|
| 212 |
+
if not orig.is_absolute():
|
| 213 |
+
orig = session_path / orig
|
| 214 |
+
if orig.exists():
|
| 215 |
+
orig.unlink()
|
| 216 |
+
except Exception:
|
| 217 |
+
pass
|
| 218 |
+
|
| 219 |
+
# remove processed
|
| 220 |
+
try:
|
| 221 |
+
proc = Path(metadata.processed_path) if metadata.processed_path else None
|
| 222 |
+
if proc:
|
| 223 |
+
if not proc.is_absolute():
|
| 224 |
+
proc = session_path / proc
|
| 225 |
+
if proc.exists():
|
| 226 |
+
proc.unlink()
|
| 227 |
+
except Exception:
|
| 228 |
+
pass
|
| 229 |
+
|
| 230 |
+
# remove mask if any
|
| 231 |
+
try:
|
| 232 |
+
mask = Path(metadata.mask_path) if metadata.mask_path else None
|
| 233 |
+
if mask:
|
| 234 |
+
if not mask.is_absolute():
|
| 235 |
+
mask = session_path / mask
|
| 236 |
+
if mask.exists():
|
| 237 |
+
mask.unlink()
|
| 238 |
+
except Exception:
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
+
# remove from lists
|
| 242 |
+
if req.image_id in config.unlabeled_images:
|
| 243 |
+
config.unlabeled_images.remove(req.image_id)
|
| 244 |
+
if req.image_id in config.image_metadata:
|
| 245 |
+
del config.image_metadata[req.image_id]
|
| 246 |
+
|
| 247 |
+
save_session_config(req.session_id, config)
|
| 248 |
+
|
| 249 |
+
return {"status": "success", "session_id": req.session_id, "removed": req.image_id}
|
| 250 |
+
except FileNotFoundError:
|
| 251 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 252 |
+
except Exception as e:
|
| 253 |
+
logger.error(f"Error deleting image: {e}")
|
| 254 |
+
raise HTTPException(status_code=500, detail=f"Failed to delete image: {str(e)}")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@app.post("/api/apply_changes")
|
| 258 |
+
async def apply_changes(request: ApplyChangesRequest):
|
| 259 |
+
"""Apply cropping and/or mask changes to an image, Supports optionally creating a new sample from a crop."""
|
| 260 |
+
try:
|
| 261 |
+
config = load_session_config(request.session_id)
|
| 262 |
+
session_path = get_session_path(request.session_id) # <--- ensure we have session path early
|
| 263 |
+
metadata = config.image_metadata.get(request.image_id)
|
| 264 |
+
|
| 265 |
+
if not metadata:
|
| 266 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 267 |
+
|
| 268 |
+
returned = {"status": "success"}
|
| 269 |
+
|
| 270 |
+
# Apply cropping if specified
|
| 271 |
+
if request.crop_details:
|
| 272 |
+
# Resolve original path (it may be absolute or relative)
|
| 273 |
+
original_path = Path(metadata.original_path)
|
| 274 |
+
if not original_path.is_absolute():
|
| 275 |
+
original_path = session_path / original_path
|
| 276 |
+
|
| 277 |
+
img = cv2.imread(str(original_path))
|
| 278 |
+
if img is None:
|
| 279 |
+
raise HTTPException(status_code=500, detail="Could not read original image")
|
| 280 |
+
|
| 281 |
+
# Validate crop bounds
|
| 282 |
+
h, w = img.shape[:2]
|
| 283 |
+
crop = request.crop_details
|
| 284 |
+
if (crop.x < 0 or crop.y < 0 or
|
| 285 |
+
crop.x + crop.width > w or crop.y + crop.height > h or
|
| 286 |
+
crop.width <= 0 or crop.height <= 0):
|
| 287 |
+
raise HTTPException(status_code=400, detail="Invalid crop bounds")
|
| 288 |
+
|
| 289 |
+
# Crop image
|
| 290 |
+
# cropped_img = crop_image(img, **crop.model_dump())
|
| 291 |
+
cropped_img = crop_image(img, int(crop.x), int(crop.y), int(crop.width), int(crop.height))
|
| 292 |
+
|
| 293 |
+
# Save processed image(s)
|
| 294 |
+
processed_dir = session_path / "processed"
|
| 295 |
+
processed_dir.mkdir(exist_ok=True, parents=True)
|
| 296 |
+
|
| 297 |
+
# If caller requested a new sample from this crop, generate a unique filename and metadata
|
| 298 |
+
if getattr(request, "create_new_sample", False):
|
| 299 |
+
base_stem = Path(request.image_id).stem
|
| 300 |
+
suffix = Path(request.image_id).suffix or ".jpg"
|
| 301 |
+
# find unique name
|
| 302 |
+
idx = 1
|
| 303 |
+
while True:
|
| 304 |
+
new_name = f"{base_stem}__crop{idx}{suffix}"
|
| 305 |
+
new_path = processed_dir / new_name
|
| 306 |
+
if not new_path.exists():
|
| 307 |
+
break
|
| 308 |
+
idx += 1
|
| 309 |
+
|
| 310 |
+
success = cv2.imwrite(str(new_path), cropped_img)
|
| 311 |
+
if not success:
|
| 312 |
+
raise HTTPException(status_code=500, detail="Failed to save cropped image (new sample)")
|
| 313 |
+
|
| 314 |
+
# Prepare new metadata object (use same schema as ImageMetadata)
|
| 315 |
+
new_meta = ImageMetadata(
|
| 316 |
+
original_path=str(original_path),
|
| 317 |
+
processed_path=str(new_path),
|
| 318 |
+
crop_details=request.crop_details,
|
| 319 |
+
status="unlabeled",
|
| 320 |
+
mask_path=None,
|
| 321 |
+
variety=None,
|
| 322 |
+
disease=None
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
# Add new image id into config
|
| 326 |
+
config.image_metadata[new_name] = new_meta
|
| 327 |
+
config.unlabeled_images.append(new_name)
|
| 328 |
+
|
| 329 |
+
# Persist
|
| 330 |
+
save_session_config(request.session_id, config)
|
| 331 |
+
|
| 332 |
+
returned["new_image_id"] = new_name
|
| 333 |
+
returned["metadata"] = new_meta.model_dump()
|
| 334 |
+
return returned
|
| 335 |
+
|
| 336 |
+
else:
|
| 337 |
+
# Overwrite (or create) processed image for this image id
|
| 338 |
+
processed_path = processed_dir / request.image_id
|
| 339 |
+
success = cv2.imwrite(str(processed_path), cropped_img)
|
| 340 |
+
if not success:
|
| 341 |
+
raise HTTPException(status_code=500, detail="Failed to save cropped image")
|
| 342 |
+
|
| 343 |
+
# Update metadata for same image id
|
| 344 |
+
metadata.processed_path = str(processed_path)
|
| 345 |
+
metadata.crop_details = request.crop_details
|
| 346 |
+
# leave status unchanged here (label step will set status to 'labeled')
|
| 347 |
+
save_session_config(request.session_id, config)
|
| 348 |
+
returned["metadata"] = metadata.model_dump()
|
| 349 |
+
return returned
|
| 350 |
+
|
| 351 |
+
return returned
|
| 352 |
+
|
| 353 |
+
except FileNotFoundError:
|
| 354 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 355 |
+
except HTTPException:
|
| 356 |
+
raise
|
| 357 |
+
except Exception as e:
|
| 358 |
+
logger.error(f"Error applying changes: {e}")
|
| 359 |
+
raise HTTPException(status_code=500, detail=f"Failed to apply changes: {str(e)}")
|
| 360 |
+
|
| 361 |
+
@app.post("/api/label")
|
| 362 |
+
async def label_image(request: LabelRequest):
|
| 363 |
+
"""Label an image with variety and disease information."""
|
| 364 |
+
try:
|
| 365 |
+
config = load_session_config(request.session_id)
|
| 366 |
+
|
| 367 |
+
if request.image_id not in config.image_metadata:
|
| 368 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 369 |
+
|
| 370 |
+
# Validate variety and disease
|
| 371 |
+
if request.variety not in config.varieties:
|
| 372 |
+
raise HTTPException(status_code=400, detail="Invalid variety")
|
| 373 |
+
|
| 374 |
+
if request.disease not in config.varieties[request.variety]:
|
| 375 |
+
raise HTTPException(status_code=400, detail="Invalid disease for selected variety")
|
| 376 |
+
|
| 377 |
+
# Update metadata
|
| 378 |
+
metadata = config.image_metadata[request.image_id]
|
| 379 |
+
metadata.variety = request.variety
|
| 380 |
+
metadata.disease = request.disease
|
| 381 |
+
metadata.status = "labeled"
|
| 382 |
+
|
| 383 |
+
# Move to final dataset structure
|
| 384 |
+
try:
|
| 385 |
+
final_base_path, _ = move_labeled_file(
|
| 386 |
+
request.session_id, request.image_id,
|
| 387 |
+
request.variety, request.disease
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
# Generate/update README
|
| 391 |
+
create_dataset_readme(final_base_path, config)
|
| 392 |
+
|
| 393 |
+
except Exception as e:
|
| 394 |
+
logger.error(f"Error moving labeled file: {e}")
|
| 395 |
+
# Continue even if file move fails, just log the error
|
| 396 |
+
|
| 397 |
+
# Remove from unlabeled list
|
| 398 |
+
if request.image_id in config.unlabeled_images:
|
| 399 |
+
config.unlabeled_images.remove(request.image_id)
|
| 400 |
+
|
| 401 |
+
# Save configuration
|
| 402 |
+
save_session_config(request.session_id, config)
|
| 403 |
+
|
| 404 |
+
return {
|
| 405 |
+
"status": "success",
|
| 406 |
+
"config": config.model_dump()
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
except FileNotFoundError:
|
| 410 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 411 |
+
except Exception as e:
|
| 412 |
+
logger.error(f"Error labeling image: {e}")
|
| 413 |
+
raise HTTPException(status_code=500, detail=f"Failed to label image: {str(e)}")
|
| 414 |
+
|
| 415 |
+
@app.get("/api/summary/{session_id}")
|
| 416 |
+
async def get_summary(session_id: str):
|
| 417 |
+
"""Get a summary of the current labeling progress."""
|
| 418 |
+
try:
|
| 419 |
+
config = load_session_config(session_id)
|
| 420 |
+
|
| 421 |
+
counts = {}
|
| 422 |
+
total_labeled = 0
|
| 423 |
+
|
| 424 |
+
for metadata in config.image_metadata.values():
|
| 425 |
+
if metadata.status == "labeled":
|
| 426 |
+
total_labeled += 1
|
| 427 |
+
key = f"{metadata.variety} -> {metadata.disease}"
|
| 428 |
+
counts[key] = counts.get(key, 0) + 1
|
| 429 |
+
|
| 430 |
+
return {
|
| 431 |
+
"unlabeled_count": len(config.unlabeled_images),
|
| 432 |
+
"labeled_count": total_labeled,
|
| 433 |
+
"class_counts": counts
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
except FileNotFoundError:
|
| 437 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 438 |
+
except Exception as e:
|
| 439 |
+
logger.error(f"Error getting summary: {e}")
|
| 440 |
+
raise HTTPException(status_code=500, detail="Failed to get summary")
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
@app.get("/api/export_zip/{session_id}")
|
| 444 |
+
async def export_dataset_as_zip(session_id: str):
|
| 445 |
+
"""Create and download a ZIP of the curated dataset (labeled images only)."""
|
| 446 |
+
try:
|
| 447 |
+
config = load_session_config(session_id)
|
| 448 |
+
|
| 449 |
+
# Build a temporary dataset folder that contains only labeled items
|
| 450 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 451 |
+
tmp_base = Path(tmpdir)
|
| 452 |
+
final_images_dir = tmp_base / "images"
|
| 453 |
+
final_annotations_dir = tmp_base / "annotations"
|
| 454 |
+
final_images_dir.mkdir(parents=True, exist_ok=True)
|
| 455 |
+
final_annotations_dir.mkdir(parents=True, exist_ok=True)
|
| 456 |
+
|
| 457 |
+
labeled_any = False
|
| 458 |
+
|
| 459 |
+
def _meta_to_dict(meta):
|
| 460 |
+
if hasattr(meta, "model_dump"):
|
| 461 |
+
d = meta.model_dump()
|
| 462 |
+
elif hasattr(meta, "dict"):
|
| 463 |
+
d = meta.dict()
|
| 464 |
+
else:
|
| 465 |
+
d = dict(meta)
|
| 466 |
+
# take processed_path (or fallback to original), rename to "image"
|
| 467 |
+
proc = d.pop("processed_path", None) or d.pop("original_path", None)
|
| 468 |
+
d["image"] = str(Path(proc)) if proc else ""
|
| 469 |
+
# drop unwanted keys
|
| 470 |
+
d.pop("original_path", None)
|
| 471 |
+
d.pop("mask_path", None)
|
| 472 |
+
d.pop("status", None)
|
| 473 |
+
return d
|
| 474 |
+
|
| 475 |
+
for image_id, metadata in config.image_metadata.items():
|
| 476 |
+
if getattr(metadata, "status", None) != "labeled":
|
| 477 |
+
continue
|
| 478 |
+
|
| 479 |
+
source_path_str = metadata.processed_path or metadata.original_path
|
| 480 |
+
source_path = Path(source_path_str)
|
| 481 |
+
if not source_path.is_absolute():
|
| 482 |
+
source_path = get_session_path(session_id) / source_path
|
| 483 |
+
|
| 484 |
+
dest_var = metadata.variety or "Unknown"
|
| 485 |
+
dest_dis = metadata.disease or "Unknown"
|
| 486 |
+
dest_dir = final_images_dir / dest_var / dest_dis
|
| 487 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 488 |
+
dest_img_path = dest_dir / image_id
|
| 489 |
+
|
| 490 |
+
if source_path.exists():
|
| 491 |
+
try:
|
| 492 |
+
shutil.copy2(source_path, dest_img_path)
|
| 493 |
+
metadata.processed_path = str(Path("images") / dest_var / dest_dis / image_id)
|
| 494 |
+
except Exception as e:
|
| 495 |
+
logger.warning(f"Failed to copy {source_path} -> {dest_img_path}: {e}")
|
| 496 |
+
continue
|
| 497 |
+
else:
|
| 498 |
+
logger.warning(f"Source image missing for export: {image_id} -> {source_path}")
|
| 499 |
+
continue
|
| 500 |
+
|
| 501 |
+
# write cleaned annotation JSON (image key + others)
|
| 502 |
+
try:
|
| 503 |
+
ann_path = final_annotations_dir / f"{Path(image_id).stem}.json"
|
| 504 |
+
with open(ann_path, "w", encoding="utf-8") as f:
|
| 505 |
+
json.dump(_meta_to_dict(metadata), f, indent=2, ensure_ascii=False)
|
| 506 |
+
except Exception as e:
|
| 507 |
+
logger.warning(f"Failed to write annotation for {image_id}: {e}")
|
| 508 |
+
|
| 509 |
+
labeled_any = True
|
| 510 |
+
|
| 511 |
+
if not labeled_any:
|
| 512 |
+
raise HTTPException(status_code=400, detail="No images have been labeled yet. Please label at least one image before exporting.")
|
| 513 |
+
|
| 514 |
+
# generate README at dataset root
|
| 515 |
+
try:
|
| 516 |
+
create_dataset_readme(tmp_base, config)
|
| 517 |
+
except Exception as e:
|
| 518 |
+
logger.warning(f"Failed to create README in export staging: {e}")
|
| 519 |
+
|
| 520 |
+
# Create a small CSV manifest for easier viewing; first column named "image"
|
| 521 |
+
manifest_path = tmp_base / "manifest.csv"
|
| 522 |
+
with open(manifest_path, "w", newline="", encoding="utf-8") as mf:
|
| 523 |
+
writer = csv.writer(mf)
|
| 524 |
+
writer.writerow(["image", "variety", "disease", "annotation_path"])
|
| 525 |
+
for image_id, metadata in config.image_metadata.items():
|
| 526 |
+
if getattr(metadata, "status", None) != "labeled":
|
| 527 |
+
continue
|
| 528 |
+
img_rel = metadata.processed_path or str(Path("images") / (metadata.variety or "Unknown") / (metadata.disease or "Unknown") / image_id)
|
| 529 |
+
ann_rel = str(Path("annotations") / f"{Path(image_id).stem}.json")
|
| 530 |
+
writer.writerow([img_rel, metadata.variety, metadata.disease, ann_rel])
|
| 531 |
+
|
| 532 |
+
# create zip from tmp_base
|
| 533 |
+
zip_name = f"tulasi_curated_{session_id}"
|
| 534 |
+
zip_path = Path("/tmp") / f"{zip_name}.zip"
|
| 535 |
+
if zip_path.exists():
|
| 536 |
+
zip_path.unlink()
|
| 537 |
+
shutil.make_archive(str(zip_path.with_suffix('')), 'zip', str(tmp_base))
|
| 538 |
+
|
| 539 |
+
return FileResponse(
|
| 540 |
+
path=zip_path,
|
| 541 |
+
media_type='application/zip',
|
| 542 |
+
filename=f"{zip_name}.zip",
|
| 543 |
+
headers={"Content-Disposition": f"attachment; filename={zip_name}.zip"}
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
except FileNotFoundError:
|
| 547 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 548 |
+
except HTTPException:
|
| 549 |
+
raise
|
| 550 |
+
except Exception as e:
|
| 551 |
+
logger.error(f"Error creating ZIP: {e}")
|
| 552 |
+
raise HTTPException(status_code=500, detail=f"Failed to create ZIP file: {str(e)}")
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
@app.post("/api/push_to_hub")
|
| 558 |
+
async def push_to_hub_endpoint(request: HubPushRequest):
|
| 559 |
+
"""
|
| 560 |
+
Push the curated dataset to Hugging Face Hub (only labeled images).
|
| 561 |
+
This implementation:
|
| 562 |
+
- copies only labeled images into a staging folder images/<variety>/<disease>/
|
| 563 |
+
- writes cleaned annotations under annotations/ where each JSON contains:
|
| 564 |
+
{ "image": "images/variety/disease/imagename.jpg", "variety": "...", "disease": "...", "crop_details": "..." }
|
| 565 |
+
(no original_path, mask_path, status)
|
| 566 |
+
- creates manifest.csv with first column 'image'
|
| 567 |
+
- creates a README.md at dataset root (create_dataset_readme)
|
| 568 |
+
- builds a datasets.Dataset with features:
|
| 569 |
+
image (Image), variety (string), disease (string), crop_details (string)
|
| 570 |
+
and pushes it via Dataset.push_to_hub so HF shows thumbnails and preserves column typing & order.
|
| 571 |
+
"""
|
| 572 |
+
try:
|
| 573 |
+
# validate HF auth / inputs
|
| 574 |
+
user_info = get_user_info(request.token)
|
| 575 |
+
if not user_info:
|
| 576 |
+
raise HTTPException(status_code=401, detail="Hugging Face authentication required. Please set HF_TOKEN in Space settings.")
|
| 577 |
+
if not request.repo_name or not request.repo_name.strip():
|
| 578 |
+
raise HTTPException(status_code=400, detail="Repository name is required")
|
| 579 |
+
|
| 580 |
+
# staging directories
|
| 581 |
+
tmpdir = tempfile.mkdtemp(prefix="tulasi_push_")
|
| 582 |
+
tmp_base = Path(tmpdir)
|
| 583 |
+
logger.info(f"Created staging folder: {tmp_base}")
|
| 584 |
+
archive_base = None
|
| 585 |
+
archive_path = None
|
| 586 |
+
|
| 587 |
+
try:
|
| 588 |
+
final_images_dir = tmp_base / "images"
|
| 589 |
+
final_annotations_dir = tmp_base / "annotations"
|
| 590 |
+
final_images_dir.mkdir(parents=True, exist_ok=True)
|
| 591 |
+
final_annotations_dir.mkdir(parents=True, exist_ok=True)
|
| 592 |
+
|
| 593 |
+
config = load_session_config(request.session_id)
|
| 594 |
+
logger.info(
|
| 595 |
+
f"Preparing staging for push: session={request.session_id} metadata_entries={len(config.image_metadata)} "
|
| 596 |
+
f"labeled_count={sum(1 for m in config.image_metadata.values() if getattr(m, 'status', None)=='labeled')}"
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
# collect dataset records (image path, variety, disease, crop_details)
|
| 600 |
+
records = []
|
| 601 |
+
labeled_any = False
|
| 602 |
+
|
| 603 |
+
for image_id, metadata in config.image_metadata.items():
|
| 604 |
+
# include only labeled images
|
| 605 |
+
if getattr(metadata, "status", None) != "labeled":
|
| 606 |
+
continue
|
| 607 |
+
|
| 608 |
+
# resolve source (processed preferred)
|
| 609 |
+
source_path = Path(metadata.processed_path or metadata.original_path)
|
| 610 |
+
if not source_path.is_absolute():
|
| 611 |
+
source_path = get_session_path(request.session_id) / source_path
|
| 612 |
+
|
| 613 |
+
if not source_path.exists():
|
| 614 |
+
logger.warning(f"Source image missing, skipping: {source_path}")
|
| 615 |
+
continue
|
| 616 |
+
|
| 617 |
+
dest_var = metadata.variety or "Unknown"
|
| 618 |
+
dest_dis = metadata.disease or "Unknown"
|
| 619 |
+
dest_dir = final_images_dir / dest_var / dest_dis
|
| 620 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 621 |
+
dest_img_path = dest_dir / image_id
|
| 622 |
+
|
| 623 |
+
try:
|
| 624 |
+
shutil.copy2(source_path, dest_img_path)
|
| 625 |
+
except Exception as e:
|
| 626 |
+
logger.warning(f"Failed to copy {source_path} -> {dest_img_path}: {e}")
|
| 627 |
+
continue
|
| 628 |
+
|
| 629 |
+
# relative path in dataset for annotation + manifest
|
| 630 |
+
rel_img_path = str(Path("images") / dest_var / dest_dis / image_id)
|
| 631 |
+
metadata.processed_path = rel_img_path # update config snapshot for manifest/annotations
|
| 632 |
+
|
| 633 |
+
# normalize crop_details to something JSON-serializable
|
| 634 |
+
cd_raw = getattr(metadata, "crop_details", None)
|
| 635 |
+
|
| 636 |
+
if cd_raw is None:
|
| 637 |
+
cd_serialized = ""
|
| 638 |
+
else:
|
| 639 |
+
# if it's a Pydantic model (v2 -> model_dump), convert to dict
|
| 640 |
+
if hasattr(cd_raw, "model_dump"):
|
| 641 |
+
try:
|
| 642 |
+
cd_obj = cd_raw.model_dump()
|
| 643 |
+
except Exception:
|
| 644 |
+
# fallback to dict()
|
| 645 |
+
try:
|
| 646 |
+
cd_obj = dict(cd_raw)
|
| 647 |
+
except Exception:
|
| 648 |
+
cd_obj = str(cd_raw)
|
| 649 |
+
elif isinstance(cd_raw, dict):
|
| 650 |
+
cd_obj = cd_raw
|
| 651 |
+
else:
|
| 652 |
+
# try __dict__ or str fallback
|
| 653 |
+
cd_obj = getattr(cd_raw, "__dict__", str(cd_raw))
|
| 654 |
+
|
| 655 |
+
try:
|
| 656 |
+
cd_serialized = json.dumps(cd_obj, ensure_ascii=False)
|
| 657 |
+
except Exception:
|
| 658 |
+
# final fallback to string
|
| 659 |
+
cd_serialized = str(cd_obj)
|
| 660 |
+
|
| 661 |
+
# build cleaned annotation dict and write it
|
| 662 |
+
ann = {
|
| 663 |
+
"image": rel_img_path,
|
| 664 |
+
"variety": metadata.variety,
|
| 665 |
+
"disease": metadata.disease,
|
| 666 |
+
# keep crop_details as JSON string (so viewer stores it as a string column)
|
| 667 |
+
# "crop_details": json.dumps(metadata.crop_details) if getattr(metadata, "crop_details", None) is not None else ""
|
| 668 |
+
"crop_details": cd_serialized
|
| 669 |
+
}
|
| 670 |
+
try:
|
| 671 |
+
ann_path = final_annotations_dir / f"{Path(image_id).stem}.json"
|
| 672 |
+
with open(ann_path, "w", encoding="utf-8") as f:
|
| 673 |
+
json.dump(ann, f, indent=2, ensure_ascii=False)
|
| 674 |
+
except Exception as e:
|
| 675 |
+
logger.warning(f"Failed to write annotation for {image_id} at {final_annotations_dir}: {e}")
|
| 676 |
+
|
| 677 |
+
# add record for the Dataset
|
| 678 |
+
# NOTE: use the actual file path on disk so Dataset/Image can load it before push_to_hub
|
| 679 |
+
records.append({
|
| 680 |
+
"image": str(dest_img_path),
|
| 681 |
+
"variety": metadata.variety,
|
| 682 |
+
"disease": metadata.disease,
|
| 683 |
+
# "crop_details": ann["crop_details"]
|
| 684 |
+
"crop_details": cd_serialized
|
| 685 |
+
})
|
| 686 |
+
|
| 687 |
+
labeled_any = True
|
| 688 |
+
|
| 689 |
+
if not labeled_any:
|
| 690 |
+
raise HTTPException(status_code=400, detail="No labeled images to push. Label at least one image before pushing.")
|
| 691 |
+
|
| 692 |
+
# create manifest.csv with first column named "image"
|
| 693 |
+
manifest_path = tmp_base / "manifest.csv"
|
| 694 |
+
with open(manifest_path, "w", newline="", encoding="utf-8") as mf:
|
| 695 |
+
writer = csv.writer(mf)
|
| 696 |
+
writer.writerow(["image", "variety", "disease", "annotation_path"])
|
| 697 |
+
for image_id, metadata in config.image_metadata.items():
|
| 698 |
+
if getattr(metadata, "status", None) != "labeled":
|
| 699 |
+
continue
|
| 700 |
+
img_rel = metadata.processed_path or str(Path("images") / (metadata.variety or "Unknown") / (metadata.disease or "Unknown") / image_id)
|
| 701 |
+
ann_rel = str(Path("annotations") / f"{Path(image_id).stem}.json")
|
| 702 |
+
writer.writerow([img_rel, metadata.variety, metadata.disease, ann_rel])
|
| 703 |
+
|
| 704 |
+
# generate README in staging (ensure it exists at tmp_base/README.md)
|
| 705 |
+
readme_path = tmp_base / "README.md"
|
| 706 |
+
try:
|
| 707 |
+
create_dataset_readme(tmp_base, config)
|
| 708 |
+
# double-check and log existence
|
| 709 |
+
if not readme_path.exists():
|
| 710 |
+
logger.warning(f"create_dataset_readme ran but README missing at {readme_path}; creating fallback README.")
|
| 711 |
+
raise RuntimeError("README missing after create_dataset_readme")
|
| 712 |
+
logger.info(f"README created at staging: {readme_path}")
|
| 713 |
+
except Exception as e:
|
| 714 |
+
# fallback: write a minimal README so Hub has something to display
|
| 715 |
+
logger.warning(f"Failed to create README in push staging with helper: {e}. Writing fallback README.md.")
|
| 716 |
+
try:
|
| 717 |
+
fallback = f"# Tulasi Curated Dataset\n\nThis dataset was created by Tulasi Data Curator.\n\nGenerated files: images/, annotations/, manifest.csv\n\nVarieties: {', '.join(config.varieties.keys())}\n"
|
| 718 |
+
readme_path.write_text(fallback, encoding="utf-8")
|
| 719 |
+
logger.info(f"Wrote fallback README.md at {readme_path}")
|
| 720 |
+
except Exception as e2:
|
| 721 |
+
logger.warning(f"Failed to write fallback README.md at {readme_path}: {e2}")
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
# Build datasets.Dataset with typed Image column and push to hub
|
| 725 |
+
try:
|
| 726 |
+
from datasets import Dataset, Features, Image, Value
|
| 727 |
+
except Exception:
|
| 728 |
+
logger.exception("The 'datasets' library is required for pushing with typed 'image' column.")
|
| 729 |
+
raise HTTPException(status_code=500, detail="Server missing 'datasets' dependency. Install 'datasets' and 'pyarrow'.")
|
| 730 |
+
|
| 731 |
+
# transpose records into columns in developer-defined order: image, variety, disease, crop_details
|
| 732 |
+
col_image = [r["image"] for r in records]
|
| 733 |
+
col_variety = [r["variety"] for r in records]
|
| 734 |
+
col_disease = [r["disease"] for r in records]
|
| 735 |
+
col_crop = [r["crop_details"] for r in records]
|
| 736 |
+
|
| 737 |
+
data_dict = {
|
| 738 |
+
"image": col_image,
|
| 739 |
+
"variety": col_variety,
|
| 740 |
+
"disease": col_disease,
|
| 741 |
+
"crop_details": col_crop
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
features = Features({
|
| 745 |
+
"image": Image(),
|
| 746 |
+
"variety": Value("string"),
|
| 747 |
+
"disease": Value("string"),
|
| 748 |
+
"crop_details": Value("string")
|
| 749 |
+
})
|
| 750 |
+
|
| 751 |
+
ds = Dataset.from_dict(data_dict)
|
| 752 |
+
ds = ds.cast(features)
|
| 753 |
+
|
| 754 |
+
repo_id = f"{request.namespace}/{request.repo_name}"
|
| 755 |
+
try:
|
| 756 |
+
ds.push_to_hub(repo_id, private=request.private, token=request.token)
|
| 757 |
+
# --- ensure READMe + annotations + manifest are uploaded so Hub displays them ---
|
| 758 |
+
try:
|
| 759 |
+
# make a small upload folder that contains only README, manifest.csv and annotations/
|
| 760 |
+
upload_extra = tmp_base / "hub_upload_extra"
|
| 761 |
+
if upload_extra.exists():
|
| 762 |
+
shutil.rmtree(upload_extra)
|
| 763 |
+
upload_extra.mkdir(parents=True, exist_ok=True)
|
| 764 |
+
|
| 765 |
+
# copy README (already ensured earlier)
|
| 766 |
+
if readme_path.exists():
|
| 767 |
+
shutil.copy2(readme_path, upload_extra / "README.md")
|
| 768 |
+
# copy manifest.csv
|
| 769 |
+
if manifest_path.exists():
|
| 770 |
+
shutil.copy2(manifest_path, upload_extra / "manifest.csv")
|
| 771 |
+
|
| 772 |
+
# copy annotations/ directory (if present) preserving filenames
|
| 773 |
+
if final_annotations_dir.exists():
|
| 774 |
+
(upload_extra / "annotations").mkdir(parents=True, exist_ok=True)
|
| 775 |
+
for ann_file in final_annotations_dir.iterdir():
|
| 776 |
+
if ann_file.is_file():
|
| 777 |
+
shutil.copy2(ann_file, upload_extra / "annotations" / ann_file.name)
|
| 778 |
+
|
| 779 |
+
# call helper to upload just these files (this will add README & annotation files into the dataset repo)
|
| 780 |
+
try:
|
| 781 |
+
push_dataset_to_hub(
|
| 782 |
+
folder_path=str(upload_extra),
|
| 783 |
+
repo_name=request.repo_name,
|
| 784 |
+
namespace=request.namespace,
|
| 785 |
+
private=request.private,
|
| 786 |
+
commit_message=(request.commit_message or "Add README + annotations"),
|
| 787 |
+
token=request.token
|
| 788 |
+
)
|
| 789 |
+
logger.info("Uploaded README/annotations to Hub via push_dataset_to_hub")
|
| 790 |
+
except Exception as e:
|
| 791 |
+
logger.warning("Failed to upload README/annotations via push_dataset_to_hub: %s", e)
|
| 792 |
+
|
| 793 |
+
except Exception as e:
|
| 794 |
+
logger.warning("Error while preparing/uploading extra hub files (README/annotations): %s", e)
|
| 795 |
+
|
| 796 |
+
except Exception as e:
|
| 797 |
+
logger.exception(f"Failed to push dataset via datasets.push_to_hub: {e}")
|
| 798 |
+
raise HTTPException(status_code=500, detail=f"Failed to push to Hub: {str(e)}")
|
| 799 |
+
|
| 800 |
+
repo_url = f"https://huggingface.co/datasets/{request.namespace}/{request.repo_name}"
|
| 801 |
+
return {"status": "success", "repo_url": repo_url}
|
| 802 |
+
|
| 803 |
+
finally:
|
| 804 |
+
# cleanup staging + archive
|
| 805 |
+
try:
|
| 806 |
+
if tmp_base.exists():
|
| 807 |
+
shutil.rmtree(tmp_base)
|
| 808 |
+
logger.info(f"Removed staging folder: {tmp_base}")
|
| 809 |
+
except Exception as e:
|
| 810 |
+
logger.warning(f"Failed to remove staging folder {tmp_base}: {e}")
|
| 811 |
+
try:
|
| 812 |
+
if archive_path is not None and archive_path.exists():
|
| 813 |
+
archive_path.unlink()
|
| 814 |
+
if archive_base and archive_base.exists():
|
| 815 |
+
shutil.rmtree(archive_base, ignore_errors=True)
|
| 816 |
+
except Exception:
|
| 817 |
+
pass
|
| 818 |
+
|
| 819 |
+
except FileNotFoundError:
|
| 820 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 821 |
+
except HTTPException:
|
| 822 |
+
raise
|
| 823 |
+
except Exception as e:
|
| 824 |
+
logger.exception(f"Error pushing to hub: {e}")
|
| 825 |
+
raise HTTPException(status_code=500, detail=f"Failed to push to Hub: {str(e)}")
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
@app.get("/api/whoami")
|
| 829 |
+
async def whoami():
|
| 830 |
+
"""Get current user information from Hugging Face."""
|
| 831 |
+
try:
|
| 832 |
+
user_info = get_user_info()
|
| 833 |
+
return {
|
| 834 |
+
"namespace": user_info.get("name") if user_info else None,
|
| 835 |
+
"authenticated": user_info is not None
|
| 836 |
+
}
|
| 837 |
+
except Exception as e:
|
| 838 |
+
logger.error(f"Error getting user info: {e}")
|
| 839 |
+
return {
|
| 840 |
+
"namespace": None,
|
| 841 |
+
"authenticated": False
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
@app.get("/api/health")
|
| 845 |
+
async def health_check():
|
| 846 |
+
"""Health check endpoint."""
|
| 847 |
+
return {"status": "healthy", "service": "Tulasi Data Curator"}
|
| 848 |
+
|
| 849 |
+
# Serve static files (frontend)
|
| 850 |
+
app.mount("/", StaticFiles(directory="app/frontend", html=True), name="static")
|
| 851 |
+
|
| 852 |
+
if __name__ == "__main__":
|
| 853 |
+
import uvicorn
|
| 854 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
app/backend/processing.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# /app/backend/processing.py
|
| 2 |
+
# Simplified and fast image processing logic.
|
| 3 |
+
|
| 4 |
+
import cv2
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
def crop_image(img: np.ndarray, x: int, y: int, w: int, h: int) -> np.ndarray:
|
| 8 |
+
"""Crops an image using the given coordinates (safe)."""
|
| 9 |
+
# return img[y:y+h, x:x+w]
|
| 10 |
+
h_img, w_img = img.shape[:2]
|
| 11 |
+
# clamp bounds (defensive)
|
| 12 |
+
x0 = max(0, int(x))
|
| 13 |
+
y0 = max(0, int(y))
|
| 14 |
+
x1 = min(w_img, x0 + max(0, int(w)))
|
| 15 |
+
y1 = min(h_img, y0 + max(0, int(h)))
|
| 16 |
+
if x1 <= x0 or y1 <= y0:
|
| 17 |
+
raise ValueError("Invalid crop area")
|
| 18 |
+
return img[y0:y1, x0:x1].copy()
|
| 19 |
+
|
| 20 |
+
def segment_with_grabcut(img: np.ndarray, rect: tuple) -> np.ndarray:
|
| 21 |
+
"""
|
| 22 |
+
Performs GrabCut segmentation using a user-provided rectangle.
|
| 23 |
+
Returns a binary mask.
|
| 24 |
+
"""
|
| 25 |
+
mask = np.zeros(img.shape[:2], np.uint8)
|
| 26 |
+
bgdModel = np.zeros((1, 65), np.float64)
|
| 27 |
+
fgdModel = np.zeros((1, 65), np.float64)
|
| 28 |
+
|
| 29 |
+
# The user's rectangle is a strong hint for the foreground
|
| 30 |
+
cv2.grabCut(img, mask, rect, bgdModel, fgdModel, 5, cv2.GC_INIT_WITH_RECT)
|
| 31 |
+
|
| 32 |
+
# The mask will have values 0,1,2,3. We want definite and probable foreground.
|
| 33 |
+
final_mask = np.where((mask == 2) | (mask == 0), 0, 1).astype('uint8')
|
| 34 |
+
|
| 35 |
+
return final_mask * 255 # Return as a black and white image
|
app/backend/schemas.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# /app/backend/schemas.py
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
from typing import List, Dict, Any, Optional
|
| 4 |
+
|
| 5 |
+
class CropDetails(BaseModel):
|
| 6 |
+
x: int
|
| 7 |
+
y: int
|
| 8 |
+
width: int
|
| 9 |
+
height: int
|
| 10 |
+
|
| 11 |
+
class SegmentationRequest(BaseModel):
|
| 12 |
+
session_id: str
|
| 13 |
+
image_id: str
|
| 14 |
+
rect: CropDetails
|
| 15 |
+
|
| 16 |
+
class ApplyChangesRequest(BaseModel):
|
| 17 |
+
session_id: str
|
| 18 |
+
image_id: str
|
| 19 |
+
crop_details: Optional[CropDetails] = None
|
| 20 |
+
mask_path: Optional[str] = None # Path to the generated mask
|
| 21 |
+
create_new_sample: Optional[bool] = False # <-- add this
|
| 22 |
+
|
| 23 |
+
class LabelRequest(BaseModel):
|
| 24 |
+
session_id: str
|
| 25 |
+
image_id: str
|
| 26 |
+
variety: str
|
| 27 |
+
disease: str
|
| 28 |
+
|
| 29 |
+
class HubPushRequest(BaseModel):
|
| 30 |
+
session_id: str
|
| 31 |
+
repo_name: str
|
| 32 |
+
namespace: str
|
| 33 |
+
private: bool = True
|
| 34 |
+
commit_message: str = "Publish curated dataset"
|
| 35 |
+
token: Optional[str] = None # For fallback if secret is not set
|
| 36 |
+
|
| 37 |
+
class ImageMetadata(BaseModel):
|
| 38 |
+
original_path: str
|
| 39 |
+
processed_path: Optional[str] = None
|
| 40 |
+
mask_path: Optional[str] = None
|
| 41 |
+
variety: Optional[str] = None
|
| 42 |
+
disease: Optional[str] = None
|
| 43 |
+
crop_details: Optional[CropDetails] = None
|
| 44 |
+
status: str = "unlabeled"
|
| 45 |
+
|
| 46 |
+
class SessionConfig(BaseModel):
|
| 47 |
+
varieties: Dict[str, List[str]]
|
| 48 |
+
unlabeled_images: List[str]
|
| 49 |
+
image_metadata: Dict[str, ImageMetadata] = Field(default_factory=dict)
|
app/backend/storage.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# /app/backend/storage.py
|
| 2 |
+
# Handles file system operations, ZIP extraction, and session management.
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
import zipfile
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import shutil
|
| 10 |
+
from typing import Dict, List, Tuple
|
| 11 |
+
|
| 12 |
+
from .schemas import SessionConfig, ImageMetadata
|
| 13 |
+
|
| 14 |
+
TEMP_DIR = Path("/tmp/dataset_session")
|
| 15 |
+
OUTPUT_DIR = Path("/tmp/clean_dataset")
|
| 16 |
+
|
| 17 |
+
# ensure these base dirs exist
|
| 18 |
+
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
| 21 |
+
|
| 22 |
+
def create_new_session() -> Tuple[str, Path]:
|
| 23 |
+
"""Creates a new session directory and returns its ID and path."""
|
| 24 |
+
session_id = str(uuid.uuid4())
|
| 25 |
+
session_path = TEMP_DIR / session_id
|
| 26 |
+
session_path.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
(session_path / "originals").mkdir(parents=True, exist_ok=True)
|
| 28 |
+
# initialize an empty session_config.json (optional)
|
| 29 |
+
# This helps other code that expects a config file to exist after extraction/save
|
| 30 |
+
# but main.py will save a config after extraction so this is optional.
|
| 31 |
+
return session_id, session_path
|
| 32 |
+
|
| 33 |
+
def get_session_path(session_id: str) -> Path:
|
| 34 |
+
"""Gets the path for a given session ID, ensuring it exists."""
|
| 35 |
+
path = TEMP_DIR / session_id
|
| 36 |
+
if not path.is_dir():
|
| 37 |
+
# use FileNotFoundError so callers that catch FileNotFoundError behave correctly
|
| 38 |
+
raise FileNotFoundError("Session not found")
|
| 39 |
+
return path
|
| 40 |
+
|
| 41 |
+
def save_session_config(session_id: str, config: SessionConfig):
|
| 42 |
+
"""Saves the session's configuration to a JSON file."""
|
| 43 |
+
session_path = get_session_path(session_id)
|
| 44 |
+
with open(session_path / "session_config.json", "w") as f:
|
| 45 |
+
f.write(config.model_dump_json(indent=2))
|
| 46 |
+
|
| 47 |
+
def load_session_config(session_id: str) -> SessionConfig:
|
| 48 |
+
"""Loads the session's configuration from a JSON file."""
|
| 49 |
+
session_path = get_session_path(session_id)
|
| 50 |
+
config_path = session_path / "session_config.json"
|
| 51 |
+
if not config_path.exists():
|
| 52 |
+
raise FileNotFoundError("Session config not found")
|
| 53 |
+
with open(config_path, "r") as f:
|
| 54 |
+
data = json.load(f)
|
| 55 |
+
return SessionConfig(**data)
|
| 56 |
+
|
| 57 |
+
def extract_zip(zip_file_path: Path, extract_to: Path) -> List[str]:
|
| 58 |
+
"""Extracts images from a ZIP file, sanitizing filenames."""
|
| 59 |
+
image_filenames = []
|
| 60 |
+
# make sure extract_to exists
|
| 61 |
+
extract_to.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:
|
| 64 |
+
for member in zip_ref.infolist():
|
| 65 |
+
# skip directories and non-image files
|
| 66 |
+
if member.is_dir() or not any(member.filename.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS):
|
| 67 |
+
continue
|
| 68 |
+
|
| 69 |
+
# sanitize the name (strip directories)
|
| 70 |
+
original_filename = Path(member.filename).name
|
| 71 |
+
sanitized_id = f"{uuid.uuid4().hex[:8]}_{original_filename}"
|
| 72 |
+
|
| 73 |
+
target_path = extract_to / sanitized_id
|
| 74 |
+
# ensure parent dir exists (should already)
|
| 75 |
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
|
| 77 |
+
with zip_ref.open(member) as source, open(target_path, "wb") as target:
|
| 78 |
+
shutil.copyfileobj(source, target)
|
| 79 |
+
image_filenames.append(sanitized_id)
|
| 80 |
+
return image_filenames
|
| 81 |
+
|
| 82 |
+
# def move_labeled_file(session_id: str, image_id: str, variety: str, disease: str) -> Tuple[Path, Path]:
|
| 83 |
+
# """Moves a labeled image to its final structured directory and returns the new paths.
|
| 84 |
+
#
|
| 85 |
+
# NOTE: this function updates metadata.processed_path to a RELATIVE path inside the final dataset
|
| 86 |
+
# (images/<variety>/<disease>/<image_id>) and writes a per-image annotation JSON that excludes
|
| 87 |
+
# original_path, mask_path and status (as requested).
|
| 88 |
+
# """
|
| 89 |
+
# config = load_session_config(session_id)
|
| 90 |
+
# if image_id not in config.image_metadata:
|
| 91 |
+
# raise FileNotFoundError(f"Image id {image_id} not found in session config")
|
| 92 |
+
# metadata = config.image_metadata[image_id]
|
| 93 |
+
#
|
| 94 |
+
# final_dataset_slug = "tulasi-curated-dataset"
|
| 95 |
+
# final_base_path = OUTPUT_DIR / final_dataset_slug
|
| 96 |
+
# final_image_dir = final_base_path / "images" / variety / disease
|
| 97 |
+
# final_mask_dir = final_base_path / "masks"
|
| 98 |
+
# final_annotations_dir = final_base_path / "annotations"
|
| 99 |
+
#
|
| 100 |
+
# final_image_dir.mkdir(parents=True, exist_ok=True)
|
| 101 |
+
# final_annotations_dir.mkdir(parents=True, exist_ok=True)
|
| 102 |
+
#
|
| 103 |
+
# Prefer processed image if present, otherwise fall back to original
|
| 104 |
+
# source_path_str = metadata.processed_path or metadata.original_path
|
| 105 |
+
# source_path = Path(source_path_str)
|
| 106 |
+
#
|
| 107 |
+
# Resolve relative paths (relative to session)
|
| 108 |
+
# if not source_path.is_absolute():
|
| 109 |
+
# source_path = get_session_path(session_id) / source_path
|
| 110 |
+
#
|
| 111 |
+
# if not source_path.exists():
|
| 112 |
+
# raise FileNotFoundError(f"Source image file not found: {source_path}")
|
| 113 |
+
#
|
| 114 |
+
# final_image_path = final_image_dir / image_id
|
| 115 |
+
# use copy2 to preserve timestamps/metadata
|
| 116 |
+
# shutil.copy2(source_path, final_image_path)
|
| 117 |
+
#
|
| 118 |
+
# Update metadata to point to relative paths inside final dataset
|
| 119 |
+
# metadata.processed_path = str(final_image_path)
|
| 120 |
+
# Update metadata to point to relative path inside final dataset (so HF can resolve it)
|
| 121 |
+
# metadata.processed_path = str(Path("images") / variety / disease / image_id)
|
| 122 |
+
#
|
| 123 |
+
# save_session_config(session_id, config)
|
| 124 |
+
#
|
| 125 |
+
# Write a per-image annotation sidecar in final dataset WITHOUT original_path/mask_path/status
|
| 126 |
+
# try:
|
| 127 |
+
# produce a plain dict (compat for pydantic v2 / v1)
|
| 128 |
+
# if hasattr(metadata, "model_dump"):
|
| 129 |
+
# meta_dict = metadata.model_dump()
|
| 130 |
+
# elif hasattr(metadata, "dict"):
|
| 131 |
+
# meta_dict = metadata.dict()
|
| 132 |
+
# else:
|
| 133 |
+
# meta_dict = dict(metadata)
|
| 134 |
+
#
|
| 135 |
+
# remove unwanted fields
|
| 136 |
+
# meta_dict.pop("original_path", None)
|
| 137 |
+
# meta_dict.pop("mask_path", None)
|
| 138 |
+
# meta_dict.pop("status", None)
|
| 139 |
+
#
|
| 140 |
+
# write a per-image annotation sidecar in final dataset
|
| 141 |
+
# with open(final_annotations_dir / f"{Path(image_id).stem}.json", "w", encoding="utf-8") as f:
|
| 142 |
+
# f.write(metadata.model_dump_json(indent=2))
|
| 143 |
+
# json.dump(meta_dict, f, indent=2, ensure_ascii=False)
|
| 144 |
+
# except Exception:
|
| 145 |
+
# best-effort writing annotation — don't fail the labeling step if this fails
|
| 146 |
+
# pass
|
| 147 |
+
#
|
| 148 |
+
# return final_base_path, final_image_path
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def move_labeled_file(session_id: str, image_id: str, variety: str, disease: str) -> Tuple[Path, Path]:
|
| 152 |
+
"""Moves a labeled image to its final structured directory and returns the new paths.
|
| 153 |
+
|
| 154 |
+
The annotation sidecar will include:
|
| 155 |
+
- image: relative path inside dataset (images/<variety>/<disease>/<image_id>)
|
| 156 |
+
- variety, disease, crop_details (if present)
|
| 157 |
+
and will NOT include original_path, mask_path or status.
|
| 158 |
+
"""
|
| 159 |
+
config = load_session_config(session_id)
|
| 160 |
+
if image_id not in config.image_metadata:
|
| 161 |
+
raise FileNotFoundError(f"Image id {image_id} not found in session config")
|
| 162 |
+
metadata = config.image_metadata[image_id]
|
| 163 |
+
|
| 164 |
+
final_dataset_slug = "tulasi-curated-dataset"
|
| 165 |
+
final_base_path = OUTPUT_DIR / final_dataset_slug
|
| 166 |
+
final_image_dir = final_base_path / "images" / variety / disease
|
| 167 |
+
final_annotations_dir = final_base_path / "annotations"
|
| 168 |
+
|
| 169 |
+
final_image_dir.mkdir(parents=True, exist_ok=True)
|
| 170 |
+
final_annotations_dir.mkdir(parents=True, exist_ok=True)
|
| 171 |
+
|
| 172 |
+
# Prefer processed image if present, otherwise fall back to original
|
| 173 |
+
source_path_str = metadata.processed_path or metadata.original_path
|
| 174 |
+
source_path = Path(source_path_str)
|
| 175 |
+
|
| 176 |
+
# Resolve relative paths (relative to session)
|
| 177 |
+
if not source_path.is_absolute():
|
| 178 |
+
source_path = get_session_path(session_id) / source_path
|
| 179 |
+
|
| 180 |
+
if not source_path.exists():
|
| 181 |
+
raise FileNotFoundError(f"Source image file not found: {source_path}")
|
| 182 |
+
|
| 183 |
+
final_image_path = final_image_dir / image_id
|
| 184 |
+
shutil.copy2(source_path, final_image_path)
|
| 185 |
+
|
| 186 |
+
# Set processed_path in session config to RELATIVE dataset path so UI sees it
|
| 187 |
+
metadata.processed_path = str(Path("images") / variety / disease / image_id)
|
| 188 |
+
save_session_config(session_id, config)
|
| 189 |
+
|
| 190 |
+
# Build cleaned annotation dict (rename processed_path -> image, drop unwanted fields)
|
| 191 |
+
try:
|
| 192 |
+
if hasattr(metadata, "model_dump"):
|
| 193 |
+
meta_dict = metadata.model_dump()
|
| 194 |
+
elif hasattr(metadata, "dict"):
|
| 195 |
+
meta_dict = metadata.dict()
|
| 196 |
+
else:
|
| 197 |
+
meta_dict = dict(metadata)
|
| 198 |
+
|
| 199 |
+
# rename processed_path -> image
|
| 200 |
+
proc = meta_dict.pop("processed_path", None)
|
| 201 |
+
if proc is None:
|
| 202 |
+
proc = meta_dict.pop("original_path", None)
|
| 203 |
+
meta_dict["image"] = str(Path(proc) if proc is not None else Path(""))
|
| 204 |
+
|
| 205 |
+
# drop fields we don't want
|
| 206 |
+
meta_dict.pop("original_path", None)
|
| 207 |
+
meta_dict.pop("mask_path", None)
|
| 208 |
+
meta_dict.pop("status", None)
|
| 209 |
+
|
| 210 |
+
# write annotation JSON
|
| 211 |
+
ann_path = final_annotations_dir / f"{Path(image_id).stem}.json"
|
| 212 |
+
with open(ann_path, "w", encoding="utf-8") as f:
|
| 213 |
+
json.dump(meta_dict, f, indent=2, ensure_ascii=False)
|
| 214 |
+
except Exception:
|
| 215 |
+
# annotation writing is best-effort — don't break labeling on failure
|
| 216 |
+
pass
|
| 217 |
+
|
| 218 |
+
return final_base_path, final_image_path
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def create_dataset_readme(output_path: Path, config: SessionConfig):
|
| 222 |
+
"""Generates a README.md for the dataset card in output_path (writes README.md)."""
|
| 223 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 224 |
+
readme_content = f"""
|
| 225 |
+
---
|
| 226 |
+
license: apache-2.0
|
| 227 |
+
tags:
|
| 228 |
+
- image-classification
|
| 229 |
+
- image-segmentation
|
| 230 |
+
- agriculture
|
| 231 |
+
- tulasi
|
| 232 |
+
task_categories:
|
| 233 |
+
- image-classification
|
| 234 |
+
language:
|
| 235 |
+
- en
|
| 236 |
+
---
|
| 237 |
+
# Tulasi Leaf Health Dataset 🌿
|
| 238 |
+
This dataset was curated using the Interactive **Tulasi Data Curator** application.
|
| 239 |
+
|
| 240 |
+
## Label Schema
|
| 241 |
+
|
| 242 |
+
### Varieties
|
| 243 |
+
- {', '.join(config.varieties.keys())}
|
| 244 |
+
|
| 245 |
+
### Diseases
|
| 246 |
+
"""
|
| 247 |
+
for variety, diseases in config.varieties.items():
|
| 248 |
+
readme_content += f"\n#### {variety}\n- {', '.join(diseases)}\n"
|
| 249 |
+
|
| 250 |
+
# write README.md at root of output_path
|
| 251 |
+
readme_file = output_path / "README.md"
|
| 252 |
+
with open(readme_file, "w", encoding="utf-8") as f:
|
| 253 |
+
f.write(readme_content)
|
| 254 |
+
|
| 255 |
+
# --- THIS IS THE MISSING FUNCTION ---
|
| 256 |
+
def create_dataset_zip(session_id: str, dataset_slug: str) -> Path:
|
| 257 |
+
"""Creates a ZIP archive of the final curated dataset."""
|
| 258 |
+
final_base_path = OUTPUT_DIR / dataset_slug
|
| 259 |
+
if not final_base_path.exists() or not final_base_path.is_dir():
|
| 260 |
+
raise FileNotFoundError(f"Dataset folder does not exist: {final_base_path}")
|
| 261 |
+
|
| 262 |
+
zip_output_path = OUTPUT_DIR / f"{dataset_slug}" # Path without extension for make_archive
|
| 263 |
+
|
| 264 |
+
# shutil.make_archive returns the full path to the created archive
|
| 265 |
+
archive_path_str = shutil.make_archive(str(zip_output_path), 'zip', str(final_base_path))
|
| 266 |
+
|
| 267 |
+
return Path(archive_path_str)
|
| 268 |
+
|
app/frontend/app.js
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// /app/frontend/app.js
|
| 2 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 3 |
+
// --- State Management ---
|
| 4 |
+
const state = {
|
| 5 |
+
sessionId: null,
|
| 6 |
+
imageIds: [],
|
| 7 |
+
currentIndex: -1,
|
| 8 |
+
config: null,
|
| 9 |
+
cropper: null,
|
| 10 |
+
// currentMask: { path: null, data: null },
|
| 11 |
+
applyCropToAll: false,
|
| 12 |
+
lastCropData: null,
|
| 13 |
+
activeTab: 'tools',
|
| 14 |
+
|
| 15 |
+
// --- NEW: persist last used labels across images ---
|
| 16 |
+
lastSelectedVariety: null,
|
| 17 |
+
lastSelectedDisease: null,
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
// --- DOM Element Cache ---
|
| 21 |
+
const dom = {
|
| 22 |
+
uploadScreen: document.getElementById('upload-screen'),
|
| 23 |
+
workspaceScreen: document.getElementById('workspace-screen'),
|
| 24 |
+
uploadBtn: document.getElementById('upload-btn'),
|
| 25 |
+
zipUploadInput: document.getElementById('zip-upload-input'),
|
| 26 |
+
uploadProgress: document.getElementById('upload-progress'),
|
| 27 |
+
thumbnailGrid: document.getElementById('thumbnail-grid'),
|
| 28 |
+
imageViewer: document.getElementById('image-viewer'),
|
| 29 |
+
currentImageName: document.getElementById('current-image-name'),
|
| 30 |
+
prevBtn: document.getElementById('prev-btn'),
|
| 31 |
+
nextBtn: document.getElementById('next-btn'),
|
| 32 |
+
progressText: document.getElementById('progress-text'),
|
| 33 |
+
varietySelect: document.getElementById('variety-select'),
|
| 34 |
+
diseaseSelect: document.getElementById('disease-select'),
|
| 35 |
+
saveLabelBtn: document.getElementById('save-label-btn'),
|
| 36 |
+
aspectRatioLock: document.getElementById('aspect-ratio-lock'),
|
| 37 |
+
applyCropAllToggle: document.getElementById('apply-crop-all-toggle'),
|
| 38 |
+
applyChangesBtn: document.getElementById('apply-changes-btn'),
|
| 39 |
+
resetImageBtn: document.getElementById('reset-image-btn'),
|
| 40 |
+
saveCropAsSampleBtn: document.getElementById('save-crop-as-sample-btn'),
|
| 41 |
+
deleteImageBtn: document.getElementById('delete-image-btn'),
|
| 42 |
+
exportBtn: document.getElementById('export-btn'),
|
| 43 |
+
exportModal: document.getElementById('export-modal'),
|
| 44 |
+
exportSummary: document.getElementById('export-summary'),
|
| 45 |
+
downloadZipBtn: document.getElementById('download-zip-btn'),
|
| 46 |
+
hubRepoName: document.getElementById('hub-repo-name'),
|
| 47 |
+
hubPrivateRepo: document.getElementById('hub-private-repo'),
|
| 48 |
+
pushToHubBtn: document.getElementById('push-to-hub-btn'),
|
| 49 |
+
pushStatus: document.getElementById('push-status'),
|
| 50 |
+
toast: document.getElementById('toast'),
|
| 51 |
+
helpBtn: document.getElementById('help-btn'),
|
| 52 |
+
helpModal: document.getElementById('help-modal'),
|
| 53 |
+
toolsTab: document.getElementById('tools-tab'),
|
| 54 |
+
labelTab: document.getElementById('label-tab'),
|
| 55 |
+
toolsContent: document.getElementById('tools-content'),
|
| 56 |
+
labelContent: document.getElementById('label-content'),
|
| 57 |
+
maskPreviewContainer: document.getElementById('mask-preview-container')
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
// --- API Helper (improved) ---
|
| 61 |
+
const api = {
|
| 62 |
+
async _readResponse(response) {
|
| 63 |
+
// debug helpful info
|
| 64 |
+
console.debug(`[api] ${response.url} -> status=${response.status}`, {
|
| 65 |
+
headers: Object.fromEntries(response.headers.entries())
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
const ct = (response.headers.get('content-type') || '').toLowerCase();
|
| 69 |
+
try {
|
| 70 |
+
if (ct.includes('application/json')) {
|
| 71 |
+
return await response.json();
|
| 72 |
+
} else {
|
| 73 |
+
// fallback: return text for non-json content (e.g. file downloads, empty bodies)
|
| 74 |
+
const txt = await response.text();
|
| 75 |
+
return txt;
|
| 76 |
+
}
|
| 77 |
+
} catch (err) {
|
| 78 |
+
// parsing error
|
| 79 |
+
console.warn('[api] failed to parse response body as JSON/text', err);
|
| 80 |
+
// attempt text fallback
|
| 81 |
+
try {
|
| 82 |
+
return await response.text();
|
| 83 |
+
} catch (_) {
|
| 84 |
+
return null;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
async post(endpoint, body) {
|
| 90 |
+
const url = `/api${endpoint}`;
|
| 91 |
+
const response = await fetch(url, {
|
| 92 |
+
method: 'POST',
|
| 93 |
+
headers: { 'Content-Type': 'application/json' },
|
| 94 |
+
body: JSON.stringify(body),
|
| 95 |
+
});
|
| 96 |
+
const payload = await this._readResponse(response);
|
| 97 |
+
|
| 98 |
+
if (!response.ok) {
|
| 99 |
+
// If backend returned JSON error with { detail }, propagate meaningful message
|
| 100 |
+
const msg = (payload && payload.detail) ? payload.detail : `HTTP ${response.status}`;
|
| 101 |
+
console.error(`[api.post] ${url} failed:`, msg);
|
| 102 |
+
throw new Error(msg);
|
| 103 |
+
}
|
| 104 |
+
return payload;
|
| 105 |
+
},
|
| 106 |
+
|
| 107 |
+
async get(endpoint) {
|
| 108 |
+
const url = `/api${endpoint}`;
|
| 109 |
+
const response = await fetch(url);
|
| 110 |
+
const payload = await this._readResponse(response);
|
| 111 |
+
|
| 112 |
+
if (!response.ok) {
|
| 113 |
+
const msg = (payload && payload.detail) ? payload.detail : `HTTP ${response.status}`;
|
| 114 |
+
console.error(`[api.get] ${url} failed:`, msg);
|
| 115 |
+
throw new Error(msg);
|
| 116 |
+
}
|
| 117 |
+
return payload;
|
| 118 |
+
},
|
| 119 |
+
|
| 120 |
+
async postForm(endpoint, formData) {
|
| 121 |
+
const url = `/api${endpoint}`;
|
| 122 |
+
const response = await fetch(url, {
|
| 123 |
+
method: 'POST',
|
| 124 |
+
body: formData,
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
const payload = await this._readResponse(response);
|
| 128 |
+
|
| 129 |
+
if (!response.ok) {
|
| 130 |
+
const msg = (payload && payload.detail) ? payload.detail : `HTTP ${response.status}`;
|
| 131 |
+
console.error(`[api.postForm] ${url} failed:`, msg);
|
| 132 |
+
throw new Error(msg);
|
| 133 |
+
}
|
| 134 |
+
return payload;
|
| 135 |
+
}
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
// --- UI Helper Functions ---
|
| 139 |
+
function showToast(message, type = 'info') {
|
| 140 |
+
dom.toast.textContent = message;
|
| 141 |
+
dom.toast.className = `show ${type}`;
|
| 142 |
+
setTimeout(() => {
|
| 143 |
+
dom.toast.className = 'hidden';
|
| 144 |
+
}, 3000);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
function updateProgress() {
|
| 148 |
+
if (!state.config) return;
|
| 149 |
+
const total = state.imageIds.length;
|
| 150 |
+
const labeled = total - state.config.unlabeled_images.length;
|
| 151 |
+
dom.progressText.textContent = `${labeled}/${total} images labeled`;
|
| 152 |
+
|
| 153 |
+
// Enable export button if at least one image is labeled
|
| 154 |
+
dom.exportBtn.disabled = labeled === 0;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function populateThumbnails() {
|
| 158 |
+
if (!state.config) return;
|
| 159 |
+
|
| 160 |
+
dom.thumbnailGrid.innerHTML = '';
|
| 161 |
+
state.imageIds.forEach((imageId, index) => {
|
| 162 |
+
const img = document.createElement('img');
|
| 163 |
+
img.src = `/api/image/${state.sessionId}/${imageId}`;
|
| 164 |
+
img.className = 'thumbnail';
|
| 165 |
+
img.alt = imageId;
|
| 166 |
+
|
| 167 |
+
const metadata = state.config.image_metadata[imageId];
|
| 168 |
+
if (metadata && metadata.status === 'labeled') {
|
| 169 |
+
img.classList.add('labeled');
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if (index === state.currentIndex) {
|
| 173 |
+
img.classList.add('active');
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
img.addEventListener('click', () => loadImage(index));
|
| 177 |
+
dom.thumbnailGrid.appendChild(img);
|
| 178 |
+
});
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
function updateLabelingUI() {
|
| 182 |
+
if (!state.config || state.currentIndex < 0) return;
|
| 183 |
+
|
| 184 |
+
const imageId = state.imageIds[state.currentIndex];
|
| 185 |
+
const metadata = state.config.image_metadata[imageId] || {};
|
| 186 |
+
|
| 187 |
+
// Populate variety options
|
| 188 |
+
dom.varietySelect.innerHTML = '<option value="">Select Variety</option>';
|
| 189 |
+
Object.keys(state.config.varieties).forEach(variety => {
|
| 190 |
+
const option = document.createElement('option');
|
| 191 |
+
option.value = variety;
|
| 192 |
+
option.textContent = variety;
|
| 193 |
+
dom.varietySelect.appendChild(option);
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
// Decide which variety to select:
|
| 197 |
+
// 1. image metadata (already labeled)
|
| 198 |
+
// 2. last selected variety (from previous image)
|
| 199 |
+
// 3. none
|
| 200 |
+
let toSelectVariety = metadata.variety || state.lastSelectedVariety || '';
|
| 201 |
+
if (toSelectVariety && !state.config.varieties.hasOwnProperty(toSelectVariety)) {
|
| 202 |
+
// invalid (e.g. schema changed) -> clear it
|
| 203 |
+
toSelectVariety = '';
|
| 204 |
+
}
|
| 205 |
+
dom.varietySelect.value = toSelectVariety;
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
// Update disease options based on selected variety
|
| 209 |
+
updateDiseaseOptions();
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
// Decide which disease to select:
|
| 213 |
+
// 1. image metadata
|
| 214 |
+
// 2. last selected disease (only if it exists in the current variety)
|
| 215 |
+
// 3. none
|
| 216 |
+
let toSelectDisease = metadata.disease || state.lastSelectedDisease || '';
|
| 217 |
+
// ensure the disease exists in the currently-selected variety list
|
| 218 |
+
const currentVar = dom.varietySelect.value;
|
| 219 |
+
if (currentVar && state.config.varieties[currentVar]) {
|
| 220 |
+
const diseases = state.config.varieties[currentVar];
|
| 221 |
+
if (!diseases.includes(toSelectDisease)) {
|
| 222 |
+
// If metadata.disease is valid use it, otherwise clear
|
| 223 |
+
toSelectDisease = metadata.disease && diseases.includes(metadata.disease) ? metadata.disease : '';
|
| 224 |
+
// if (toSelectDisease && !diseases.includes(toSelectDisease)) toSelectDisease = '';
|
| 225 |
+
}
|
| 226 |
+
} else {
|
| 227 |
+
// no variety selected -> clear disease
|
| 228 |
+
toSelectDisease = '';
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
dom.diseaseSelect.value = toSelectDisease;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
function updateDiseaseOptions() {
|
| 235 |
+
const selectedVariety = dom.varietySelect.value;
|
| 236 |
+
dom.diseaseSelect.innerHTML = '<option value="">Select Disease</option>';
|
| 237 |
+
|
| 238 |
+
if (selectedVariety && state.config.varieties[selectedVariety]) {
|
| 239 |
+
state.config.varieties[selectedVariety].forEach(disease => {
|
| 240 |
+
const option = document.createElement('option');
|
| 241 |
+
option.value = disease;
|
| 242 |
+
option.textContent = disease;
|
| 243 |
+
dom.diseaseSelect.appendChild(option);
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
// If lastSelectedDisease exists and is valid for this variety, select it
|
| 247 |
+
if (state.lastSelectedDisease && state.config.varieties[selectedVariety].includes(state.lastSelectedDisease)) {
|
| 248 |
+
dom.diseaseSelect.value = state.lastSelectedDisease;
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
function switchTab(tabName) {
|
| 254 |
+
state.activeTab = tabName;
|
| 255 |
+
|
| 256 |
+
// Update tab buttons
|
| 257 |
+
dom.toolsTab.classList.toggle('active', tabName === 'tools');
|
| 258 |
+
dom.labelTab.classList.toggle('active', tabName === 'label');
|
| 259 |
+
|
| 260 |
+
// Update tab content
|
| 261 |
+
dom.toolsContent.classList.toggle('hidden', tabName !== 'tools');
|
| 262 |
+
dom.labelContent.classList.toggle('hidden', tabName !== 'label');
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function initCropper(imageUrl) {
|
| 266 |
+
const imageElement = document.createElement('img');
|
| 267 |
+
imageElement.src = imageUrl;
|
| 268 |
+
imageElement.style.maxWidth = '100%';
|
| 269 |
+
imageElement.style.height = 'auto';
|
| 270 |
+
|
| 271 |
+
dom.imageViewer.innerHTML = '';
|
| 272 |
+
dom.imageViewer.appendChild(imageElement);
|
| 273 |
+
|
| 274 |
+
// Initialize Cropper.js
|
| 275 |
+
if (state.cropper) {
|
| 276 |
+
state.cropper.destroy();
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
state.cropper = new Cropper(imageElement, {
|
| 280 |
+
viewMode: 1,
|
| 281 |
+
dragMode: 'move',
|
| 282 |
+
aspectRatio: NaN,
|
| 283 |
+
autoCropArea: 0.8,
|
| 284 |
+
restore: false,
|
| 285 |
+
guides: false,
|
| 286 |
+
center: false,
|
| 287 |
+
highlight: false,
|
| 288 |
+
cropBoxMovable: true,
|
| 289 |
+
cropBoxResizable: true,
|
| 290 |
+
toggleDragModeOnDblclick: false,
|
| 291 |
+
ready: function () {
|
| 292 |
+
// Apply last crop data if available and toggle is on
|
| 293 |
+
if (state.applyCropToAll && state.lastCropData) {
|
| 294 |
+
this.cropper.setData(state.lastCropData);
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
});
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
async function loadImage(index) {
|
| 301 |
+
if (index < 0 || index >= state.imageIds.length) return;
|
| 302 |
+
|
| 303 |
+
state.currentIndex = index;
|
| 304 |
+
const imageId = state.imageIds[index];
|
| 305 |
+
|
| 306 |
+
// Update current image name
|
| 307 |
+
dom.currentImageName.textContent = imageId;
|
| 308 |
+
|
| 309 |
+
// Update navigation buttons
|
| 310 |
+
dom.prevBtn.disabled = index === 0;
|
| 311 |
+
dom.nextBtn.disabled = index === state.imageIds.length - 1;
|
| 312 |
+
|
| 313 |
+
// Load image in cropper
|
| 314 |
+
const imageUrl = `/api/image/${state.sessionId}/${imageId}`;
|
| 315 |
+
initCropper(imageUrl);
|
| 316 |
+
|
| 317 |
+
// Update thumbnails
|
| 318 |
+
populateThumbnails();
|
| 319 |
+
|
| 320 |
+
// Update labeling UI
|
| 321 |
+
updateLabelingUI();
|
| 322 |
+
|
| 323 |
+
// Clear mask preview
|
| 324 |
+
// dom.maskPreviewContainer.innerHTML = '';
|
| 325 |
+
if (dom.maskPreviewContainer) dom.maskPreviewContainer.innerHTML = '';
|
| 326 |
+
state.currentMask = { path: null, data: null };
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
// --- Event Handlers ---
|
| 330 |
+
async function handleZipUpload(file) {
|
| 331 |
+
const maxSize = 10 * 1024 * 1024 * 1024; // 10GB
|
| 332 |
+
if (file.size > maxSize) {
|
| 333 |
+
showToast('File too large. Maximum size is 10GB.', 'error');
|
| 334 |
+
return;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// Show upload progress
|
| 338 |
+
dom.uploadProgress.innerHTML = '<div class="spinner"></div><p>Uploading and extracting...</p>';
|
| 339 |
+
dom.uploadProgress.classList.remove('hidden');
|
| 340 |
+
|
| 341 |
+
try {
|
| 342 |
+
const formData = new FormData();
|
| 343 |
+
formData.append('file', file);
|
| 344 |
+
|
| 345 |
+
const result = await api.postForm('/upload_zip', formData);
|
| 346 |
+
|
| 347 |
+
// Some backends might return a plain string on success (rare), so guard:
|
| 348 |
+
if (!result || !result.session_id) {
|
| 349 |
+
console.warn('upload_zip result unexpected:', result);
|
| 350 |
+
showToast('Upload succeeded but server returned unexpected response', 'warning');
|
| 351 |
+
// You can still attempt to parse what you got. If it's text maybe the server printed logs.
|
| 352 |
+
return;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
state.sessionId = result.session_id;
|
| 356 |
+
state.imageIds = result.image_ids || [];
|
| 357 |
+
state.config = result.config || {};
|
| 358 |
+
|
| 359 |
+
// Switch to workspace
|
| 360 |
+
dom.uploadScreen.classList.add('hidden');
|
| 361 |
+
dom.workspaceScreen.classList.remove('hidden');
|
| 362 |
+
|
| 363 |
+
// Load first image
|
| 364 |
+
if (state.imageIds.length > 0) {
|
| 365 |
+
await loadImage(0);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
updateProgress();
|
| 369 |
+
showToast(`Successfully loaded ${result.image_count} images!`, 'success');
|
| 370 |
+
} catch (error) {
|
| 371 |
+
showToast('Failed to upload ZIP file', 'error');
|
| 372 |
+
} finally {
|
| 373 |
+
dom.uploadProgress.classList.add('hidden');
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
async function handleSaveLabel() {
|
| 378 |
+
if (!state.sessionId || state.currentIndex < 0) return;
|
| 379 |
+
|
| 380 |
+
const variety = dom.varietySelect.value;
|
| 381 |
+
const disease = dom.diseaseSelect.value;
|
| 382 |
+
|
| 383 |
+
console.log('Saving label', { session_id: state.sessionId, image_index: state.currentIndex, variety, disease });
|
| 384 |
+
|
| 385 |
+
if (!variety || !disease) {
|
| 386 |
+
showToast('Please select both variety and disease', 'error');
|
| 387 |
+
return;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
try {
|
| 391 |
+
const imageId = state.imageIds[state.currentIndex];
|
| 392 |
+
|
| 393 |
+
// If no processed image exists for this image and we have a crop, apply crop first
|
| 394 |
+
const metadata = (state.config.image_metadata && state.config.image_metadata[imageId]) || {};
|
| 395 |
+
const hasProcessedPath = metadata.processed_path && metadata.processed_path.length > 0;
|
| 396 |
+
|
| 397 |
+
// If no processed image but user drew a crop, auto apply changes
|
| 398 |
+
if (!hasProcessedPath && state.cropper) {
|
| 399 |
+
const cropData = state.cropper.getData();
|
| 400 |
+
if (cropData && cropData.width > 0 && cropData.height > 0) {
|
| 401 |
+
// apply changes first and update local metadata
|
| 402 |
+
await handleApplyChanges();
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
// now call label
|
| 407 |
+
const result = await api.post('/label', {
|
| 408 |
+
session_id: state.sessionId,
|
| 409 |
+
image_id: imageId,
|
| 410 |
+
variety: variety,
|
| 411 |
+
disease: disease
|
| 412 |
+
});
|
| 413 |
+
|
| 414 |
+
// persist last-used labels for next image prefills
|
| 415 |
+
state.lastSelectedVariety = variety;
|
| 416 |
+
state.lastSelectedDisease = disease;
|
| 417 |
+
|
| 418 |
+
// update config from server response
|
| 419 |
+
if (result && result.config) {
|
| 420 |
+
state.config = result.config;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
updateProgress();
|
| 424 |
+
populateThumbnails();
|
| 425 |
+
|
| 426 |
+
showToast('Label saved successfully!', 'success');
|
| 427 |
+
|
| 428 |
+
// Move to next unlabeled image
|
| 429 |
+
const nextUnlabeledIndex = state.imageIds.findIndex((id, idx) =>
|
| 430 |
+
idx > state.currentIndex && state.config.unlabeled_images.includes(id)
|
| 431 |
+
);
|
| 432 |
+
|
| 433 |
+
if (nextUnlabeledIndex !== -1) {
|
| 434 |
+
await loadImage(nextUnlabeledIndex);
|
| 435 |
+
} else {
|
| 436 |
+
// If no more unlabeled images after current, find first unlabeled
|
| 437 |
+
const firstUnlabeledIndex = state.imageIds.findIndex(id =>
|
| 438 |
+
state.config.unlabeled_images.includes(id)
|
| 439 |
+
);
|
| 440 |
+
if (firstUnlabeledIndex !== -1) {
|
| 441 |
+
await loadImage(firstUnlabeledIndex);
|
| 442 |
+
}
|
| 443 |
+
}
|
| 444 |
+
} catch (error) {
|
| 445 |
+
showToast('Failed to save label', 'error');
|
| 446 |
+
console.error('SaveLabel error:', error);
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
async function handleApplyChanges() {
|
| 451 |
+
if (!state.cropper || !state.sessionId || state.currentIndex < 0) return;
|
| 452 |
+
|
| 453 |
+
const cropData = state.cropper.getData();
|
| 454 |
+
const cropDetails = {
|
| 455 |
+
x: Math.round(cropData.x),
|
| 456 |
+
y: Math.round(cropData.y),
|
| 457 |
+
width: Math.round(cropData.width),
|
| 458 |
+
height: Math.round(cropData.height)
|
| 459 |
+
};
|
| 460 |
+
|
| 461 |
+
try {
|
| 462 |
+
const imageId = state.imageIds[state.currentIndex];
|
| 463 |
+
const result = await api.post('/apply_changes', {
|
| 464 |
+
session_id: state.sessionId,
|
| 465 |
+
image_id: imageId,
|
| 466 |
+
crop_details: cropDetails,
|
| 467 |
+
// mask_path: state.currentMask.path
|
| 468 |
+
});
|
| 469 |
+
|
| 470 |
+
// result.metadata contains updated ImageMetadata from server
|
| 471 |
+
// Update state.config with new metadata for this image
|
| 472 |
+
if (result && result.metadata && state.config && state.config.image_metadata) {
|
| 473 |
+
state.config.image_metadata[imageId] = result.metadata;
|
| 474 |
+
// If it was previously unlabeled, keep it as unlabeled until label step
|
| 475 |
+
// Persist last crop so we can reapply for "apply to all" if needed
|
| 476 |
+
state.lastCropData = cropData;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
showToast('Changes applied successfully!', 'success');
|
| 480 |
+
} catch (error) {
|
| 481 |
+
showToast('Failed to apply changes', 'error');
|
| 482 |
+
}
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
async function handleResetImage() {
|
| 486 |
+
if (!state.sessionId || state.currentIndex < 0) return;
|
| 487 |
+
|
| 488 |
+
// Reset cropper to full image
|
| 489 |
+
if (state.cropper) {
|
| 490 |
+
state.cropper.reset();
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
// Clear mask
|
| 494 |
+
// dom.maskPreviewContainer.innerHTML = '';
|
| 495 |
+
if (dom.maskPreviewContainer) dom.maskPreviewContainer.innerHTML = '';
|
| 496 |
+
state.currentMask = { path: null, data: null };
|
| 497 |
+
|
| 498 |
+
showToast('Image reset to original', 'success');
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
async function handleDownloadZip() {
|
| 502 |
+
if (!state.sessionId) return;
|
| 503 |
+
|
| 504 |
+
try {
|
| 505 |
+
showToast('Preparing dataset ZIP...', 'info');
|
| 506 |
+
|
| 507 |
+
// Create a download link
|
| 508 |
+
const link = document.createElement('a');
|
| 509 |
+
link.href = `/api/export_zip/${state.sessionId}`;
|
| 510 |
+
link.download = 'tulasi-curated-dataset.zip';
|
| 511 |
+
document.body.appendChild(link);
|
| 512 |
+
link.click();
|
| 513 |
+
document.body.removeChild(link);
|
| 514 |
+
|
| 515 |
+
showToast('Download started!', 'success');
|
| 516 |
+
} catch (error) {
|
| 517 |
+
showToast('Failed to download dataset', 'error');
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
async function handlePushToHub() {
|
| 522 |
+
const repoName = dom.hubRepoName.value.trim();
|
| 523 |
+
if (!repoName) {
|
| 524 |
+
showToast('Repository Name is required.', 'error');
|
| 525 |
+
return;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
dom.pushStatus.classList.remove('hidden');
|
| 529 |
+
dom.pushStatus.textContent = 'Connecting to Hugging Face...';
|
| 530 |
+
|
| 531 |
+
try {
|
| 532 |
+
// Get current user namespace
|
| 533 |
+
const userInfo = await api.get('/whoami');
|
| 534 |
+
if (!userInfo.namespace) {
|
| 535 |
+
throw new Error("Hugging Face authentication not available. Please check HF_TOKEN.");
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
const namespace = userInfo.namespace;
|
| 539 |
+
dom.pushStatus.textContent = `Pushing dataset to ${namespace}/${repoName}...`;
|
| 540 |
+
|
| 541 |
+
// Push to hub
|
| 542 |
+
const response = await api.post('/push_to_hub', {
|
| 543 |
+
session_id: state.sessionId,
|
| 544 |
+
repo_name: repoName,
|
| 545 |
+
namespace: namespace,
|
| 546 |
+
private: dom.hubPrivateRepo.checked,
|
| 547 |
+
commit_message: "Curated Tulasi leaf dataset"
|
| 548 |
+
});
|
| 549 |
+
|
| 550 |
+
dom.pushStatus.innerHTML = `
|
| 551 |
+
<p style="color: var(--primary-color);">✅ Dataset published successfully!</p>
|
| 552 |
+
<p><a href="${response.repo_url}" target="_blank" rel="noopener noreferrer">${response.repo_url}</a></p>
|
| 553 |
+
`;
|
| 554 |
+
|
| 555 |
+
} catch (error) {
|
| 556 |
+
dom.pushStatus.innerHTML = `<p style="color: #ff6b6b;">❌ Error: ${error.message}</p>`;
|
| 557 |
+
showToast(error.message, 'error');
|
| 558 |
+
}
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
// --- Initialize Application ---
|
| 562 |
+
function init() {
|
| 563 |
+
// Upload handlers
|
| 564 |
+
dom.uploadBtn.addEventListener('click', () => dom.zipUploadInput.click());
|
| 565 |
+
dom.zipUploadInput.addEventListener('change', (e) => {
|
| 566 |
+
if (e.target.files.length) {
|
| 567 |
+
handleZipUpload(e.target.files[0]);
|
| 568 |
+
}
|
| 569 |
+
});
|
| 570 |
+
|
| 571 |
+
// Navigation handlers
|
| 572 |
+
dom.prevBtn.addEventListener('click', () => {
|
| 573 |
+
if (state.currentIndex > 0) {
|
| 574 |
+
loadImage(state.currentIndex - 1);
|
| 575 |
+
}
|
| 576 |
+
});
|
| 577 |
+
|
| 578 |
+
dom.nextBtn.addEventListener('click', () => {
|
| 579 |
+
if (state.currentIndex < state.imageIds.length - 1) {
|
| 580 |
+
loadImage(state.currentIndex + 1);
|
| 581 |
+
}
|
| 582 |
+
});
|
| 583 |
+
|
| 584 |
+
// Tab switching
|
| 585 |
+
dom.toolsTab.addEventListener('click', () => switchTab('tools'));
|
| 586 |
+
dom.labelTab.addEventListener('click', () => switchTab('label'));
|
| 587 |
+
|
| 588 |
+
// Tool handlers
|
| 589 |
+
// dom.segmentBtn.addEventListener('click', handleGenerateMask);
|
| 590 |
+
dom.applyChangesBtn.addEventListener('click', handleApplyChanges);
|
| 591 |
+
dom.resetImageBtn.addEventListener('click', handleResetImage);
|
| 592 |
+
|
| 593 |
+
// Delete image handler
|
| 594 |
+
if (dom.deleteImageBtn) {
|
| 595 |
+
dom.deleteImageBtn.addEventListener('click', async () => {
|
| 596 |
+
if (!state.sessionId || state.currentIndex < 0) return;
|
| 597 |
+
const imageId = state.imageIds[state.currentIndex];
|
| 598 |
+
if (!confirm('Delete this image from session? This cannot be undone.')) return;
|
| 599 |
+
|
| 600 |
+
try {
|
| 601 |
+
const res = await api.post('/delete_image', {
|
| 602 |
+
session_id: state.sessionId,
|
| 603 |
+
image_id: imageId
|
| 604 |
+
});
|
| 605 |
+
// remove from local state
|
| 606 |
+
state.imageIds.splice(state.currentIndex, 1);
|
| 607 |
+
if (state.config && state.config.image_metadata) {
|
| 608 |
+
delete state.config.image_metadata[imageId];
|
| 609 |
+
const uiIndex = state.config.unlabeled_images.indexOf(imageId);
|
| 610 |
+
if (uiIndex !== -1) state.config.unlabeled_images.splice(uiIndex, 1);
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
// load next or previous
|
| 614 |
+
const nextIndex = Math.min(state.currentIndex, state.imageIds.length - 1);
|
| 615 |
+
if (state.imageIds.length > 0 && nextIndex >= 0) {
|
| 616 |
+
await loadImage(nextIndex);
|
| 617 |
+
} else {
|
| 618 |
+
// no images left -> show upload screen
|
| 619 |
+
dom.workspaceScreen.classList.add('hidden');
|
| 620 |
+
dom.uploadScreen.classList.remove('hidden');
|
| 621 |
+
}
|
| 622 |
+
populateThumbnails();
|
| 623 |
+
updateProgress();
|
| 624 |
+
showToast('Image deleted from session', 'success');
|
| 625 |
+
} catch (error) {
|
| 626 |
+
console.error('Delete image error', error);
|
| 627 |
+
showToast('Failed to delete image', 'error');
|
| 628 |
+
}
|
| 629 |
+
});
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
dom.saveCropAsSampleBtn.addEventListener('click', async () => {
|
| 633 |
+
if (!state.cropper || !state.sessionId || state.currentIndex < 0) return;
|
| 634 |
+
const cropData = state.cropper.getData(true);
|
| 635 |
+
try {
|
| 636 |
+
const imageId = state.imageIds[state.currentIndex];
|
| 637 |
+
const result = await api.post('/apply_changes', {
|
| 638 |
+
session_id: state.sessionId,
|
| 639 |
+
image_id: imageId,
|
| 640 |
+
crop_details: cropData,
|
| 641 |
+
create_new_sample: true
|
| 642 |
+
});
|
| 643 |
+
if (result && result.new_image_id) {
|
| 644 |
+
// Add new sample to local state + UI
|
| 645 |
+
state.imageIds.push(result.new_image_id);
|
| 646 |
+
state.config.image_metadata[result.new_image_id] = result.metadata;
|
| 647 |
+
state.config.unlabeled_images = state.config.unlabeled_images || [];
|
| 648 |
+
state.config.unlabeled_images.push(result.new_image_id);
|
| 649 |
+
populateThumbnails();
|
| 650 |
+
updateProgress();
|
| 651 |
+
showToast('Saved cropped sample', 'success');
|
| 652 |
+
}
|
| 653 |
+
} catch (err) {
|
| 654 |
+
showToast('Failed to save crop as new sample', 'error');
|
| 655 |
+
}
|
| 656 |
+
});
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
// Aspect ratio lock toggle
|
| 662 |
+
dom.aspectRatioLock.addEventListener('change', (e) => {
|
| 663 |
+
if (state.cropper) {
|
| 664 |
+
state.cropper.setAspectRatio(e.target.checked ? 1 : NaN);
|
| 665 |
+
}
|
| 666 |
+
});
|
| 667 |
+
|
| 668 |
+
// Apply crop to all toggle
|
| 669 |
+
dom.applyCropAllToggle.addEventListener('change', (e) => {
|
| 670 |
+
state.applyCropToAll = e.target.checked;
|
| 671 |
+
});
|
| 672 |
+
|
| 673 |
+
// Labeling handlers
|
| 674 |
+
dom.varietySelect.addEventListener('change', updateDiseaseOptions);
|
| 675 |
+
dom.saveLabelBtn.addEventListener('click', handleSaveLabel);
|
| 676 |
+
|
| 677 |
+
// Export handlers
|
| 678 |
+
dom.exportBtn.addEventListener('click', async () => {
|
| 679 |
+
try {
|
| 680 |
+
const summary = await api.get(`/summary/${state.sessionId}`);
|
| 681 |
+
dom.exportSummary.innerHTML = `
|
| 682 |
+
<h4>Dataset Summary</h4>
|
| 683 |
+
<p><strong>Total Images:</strong> ${summary.labeled_count + summary.unlabeled_count}</p>
|
| 684 |
+
<p><strong>Labeled:</strong> ${summary.labeled_count}</p>
|
| 685 |
+
<p><strong>Unlabeled:</strong> ${summary.unlabeled_count}</p>
|
| 686 |
+
${Object.keys(summary.class_counts).length > 0 ?
|
| 687 |
+
'<h5>Class Distribution:</h5><ul>' +
|
| 688 |
+
Object.entries(summary.class_counts).map(([key, count]) =>
|
| 689 |
+
`<li>${key}: ${count}</li>`).join('') +
|
| 690 |
+
'</ul>' : ''}
|
| 691 |
+
`;
|
| 692 |
+
dom.exportModal.classList.remove('hidden');
|
| 693 |
+
} catch (error) {
|
| 694 |
+
showToast('Failed to load summary', 'error');
|
| 695 |
+
}
|
| 696 |
+
});
|
| 697 |
+
|
| 698 |
+
dom.downloadZipBtn.addEventListener('click', handleDownloadZip);
|
| 699 |
+
dom.pushToHubBtn.addEventListener('click', handlePushToHub);
|
| 700 |
+
|
| 701 |
+
// Modal handlers
|
| 702 |
+
dom.exportModal.querySelector('.close-btn').addEventListener('click', () => {
|
| 703 |
+
dom.exportModal.classList.add('hidden');
|
| 704 |
+
});
|
| 705 |
+
|
| 706 |
+
// Help modal (if exists)
|
| 707 |
+
if (dom.helpBtn && dom.helpModal) {
|
| 708 |
+
dom.helpBtn.addEventListener('click', () => dom.helpModal.classList.remove('hidden'));
|
| 709 |
+
dom.helpModal.querySelector('.close-btn')?.addEventListener('click', () => {
|
| 710 |
+
dom.helpModal.classList.add('hidden');
|
| 711 |
+
});
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
// Keyboard shortcuts
|
| 715 |
+
document.addEventListener('keydown', (e) => {
|
| 716 |
+
if (dom.workspaceScreen && !dom.workspaceScreen.classList.contains('hidden')) {
|
| 717 |
+
switch(e.key) {
|
| 718 |
+
case 'ArrowLeft':
|
| 719 |
+
if (state.currentIndex > 0) {
|
| 720 |
+
e.preventDefault();
|
| 721 |
+
loadImage(state.currentIndex - 1);
|
| 722 |
+
}
|
| 723 |
+
break;
|
| 724 |
+
case 'ArrowRight':
|
| 725 |
+
if (state.currentIndex < state.imageIds.length - 1) {
|
| 726 |
+
e.preventDefault();
|
| 727 |
+
loadImage(state.currentIndex + 1);
|
| 728 |
+
}
|
| 729 |
+
break;
|
| 730 |
+
case 'Enter':
|
| 731 |
+
if (state.activeTab === 'label') {
|
| 732 |
+
e.preventDefault();
|
| 733 |
+
handleSaveLabel();
|
| 734 |
+
}
|
| 735 |
+
break;
|
| 736 |
+
}
|
| 737 |
+
}
|
| 738 |
+
});
|
| 739 |
+
|
| 740 |
+
// Initialize with tools tab
|
| 741 |
+
switchTab('tools');
|
| 742 |
+
|
| 743 |
+
console.log('Tulasi Data Curator initialized successfully');
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
// Start the application
|
| 747 |
+
init();
|
| 748 |
+
});
|
app/frontend/index.html
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Tulasi Data Curator</title>
|
| 7 |
+
<link rel="stylesheet" href="styles.css">
|
| 8 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css" rel="stylesheet">
|
| 9 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js"></script>
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div id="app-container">
|
| 13 |
+
<!-- Header -->
|
| 14 |
+
<header id="app-header">
|
| 15 |
+
<h1>🌿 Tulasi Data Curator</h1>
|
| 16 |
+
<div id="progress-indicator">
|
| 17 |
+
<span id="progress-text">Upload a ZIP to begin</span>
|
| 18 |
+
</div>
|
| 19 |
+
<div class="header-actions">
|
| 20 |
+
<button id="help-btn">Disease Guide</button>
|
| 21 |
+
<button id="export-btn" class="primary-btn" disabled>Export & Push</button>
|
| 22 |
+
</div>
|
| 23 |
+
</header>
|
| 24 |
+
|
| 25 |
+
<!-- Main Content -->
|
| 26 |
+
<main id="main-content">
|
| 27 |
+
<!-- Upload Screen -->
|
| 28 |
+
<div id="upload-screen" class="screen">
|
| 29 |
+
<div class="upload-box">
|
| 30 |
+
<h2>Upload Your Image Dataset</h2>
|
| 31 |
+
<p>Select a ZIP file containing your Tulasi leaf images (up to 10GB)</p>
|
| 32 |
+
<button id="upload-btn" class="primary-btn">Choose ZIP File</button>
|
| 33 |
+
<input type="file" id="zip-upload-input" accept=".zip" hidden>
|
| 34 |
+
<div id="upload-progress" class="hidden">
|
| 35 |
+
<div class="spinner"></div>
|
| 36 |
+
<p>Processing your images...</p>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<!-- Workspace Screen -->
|
| 42 |
+
<div id="workspace-screen" class="screen hidden">
|
| 43 |
+
<!-- Left Panel - Thumbnails -->
|
| 44 |
+
<div id="left-panel" class="panel">
|
| 45 |
+
<h3>Images</h3>
|
| 46 |
+
<div id="thumbnail-grid"></div>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<!-- Center Panel - Image Viewer -->
|
| 50 |
+
<div id="center-panel" class="panel">
|
| 51 |
+
<div id="image-viewer"></div>
|
| 52 |
+
<div id="image-toolbar">
|
| 53 |
+
<div id="current-image-name">No image selected</div>
|
| 54 |
+
<div class="toolbar-buttons">
|
| 55 |
+
<button id="prev-btn" disabled>◀ Previous</button>
|
| 56 |
+
<button id="next-btn" disabled>Next ▶</button>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- Right Panel - Tools & Labels -->
|
| 62 |
+
<div id="right-panel" class="panel">
|
| 63 |
+
<div id="tools-label-tabs">
|
| 64 |
+
<button id="tools-tab" class="tab-btn active">Tools</button>
|
| 65 |
+
<button id="label-tab" class="tab-btn">Label</button>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<!-- Tools Tab Content -->
|
| 69 |
+
<div id="tools-content" class="tab-content">
|
| 70 |
+
<div class="tool-group">
|
| 71 |
+
<h3>Cropping & resizing</h3>
|
| 72 |
+
<p>Draw a bounding box around the leaf area to make the image more clear.</p>
|
| 73 |
+
|
| 74 |
+
<div class="checkbox-group">
|
| 75 |
+
<input type="checkbox" id="aspect-ratio-lock">
|
| 76 |
+
<label for="aspect-ratio-lock">Lock aspect ratio (1:1)</label>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div class="checkbox-group">
|
| 80 |
+
<input type="checkbox" id="apply-crop-all-toggle">
|
| 81 |
+
<label for="apply-crop-all-toggle">Apply crop to all images</label>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div class="button-group">
|
| 85 |
+
<!-- <button id="segment-btn" class="primary-btn">Generate Mask</button> -->
|
| 86 |
+
<button id="apply-changes-btn">Apply Changes</button>
|
| 87 |
+
<button id="reset-image-btn">Reset Image</button>
|
| 88 |
+
<button id="save-crop-as-sample-btn" class="primary-btn">Save Crop as New Sample</button>
|
| 89 |
+
<button id="delete-image-btn" class="secondary-btn" style="background: #a94442; border-color:#a94442;">Delete Image</button>
|
| 90 |
+
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div id="mask-preview-container"></div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<!-- Label Tab Content -->
|
| 98 |
+
<div id="label-content" class="tab-content hidden">
|
| 99 |
+
<h3>Label Current Image</h3>
|
| 100 |
+
|
| 101 |
+
<div class="form-group">
|
| 102 |
+
<label for="variety-select">Variety:</label>
|
| 103 |
+
<select id="variety-select">
|
| 104 |
+
<option value="">Select Variety</option>
|
| 105 |
+
</select>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<div class="form-group">
|
| 109 |
+
<label for="disease-select">Disease/Condition:</label>
|
| 110 |
+
<select id="disease-select">
|
| 111 |
+
<option value="">Select Disease</option>
|
| 112 |
+
</select>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<button id="save-label-btn" class="primary-btn">Save Label</button>
|
| 116 |
+
|
| 117 |
+
<div class="label-info">
|
| 118 |
+
<h4>Quick Reference</h4>
|
| 119 |
+
<p><strong>Healthy:</strong> Green, vibrant leaves</p>
|
| 120 |
+
<p><strong>Leaf Spot:</strong> Dark spots or patches</p>
|
| 121 |
+
<p><strong>Powdery Mildew:</strong> White powdery coating</p>
|
| 122 |
+
<p><strong>Bacterial Blight:</strong> Water-soaked lesions</p>
|
| 123 |
+
<p><strong>Nutrient Deficiency:</strong> Yellowing or discoloration</p>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</main>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<!-- Export & Push Modal -->
|
| 132 |
+
<div id="export-modal" class="modal hidden">
|
| 133 |
+
<div class="modal-content">
|
| 134 |
+
<span class="close-btn">×</span>
|
| 135 |
+
<h2>Export & Push to Hub</h2>
|
| 136 |
+
<div id="export-summary"></div>
|
| 137 |
+
|
| 138 |
+
<hr>
|
| 139 |
+
|
| 140 |
+
<div class="export-section">
|
| 141 |
+
<h3>1. Download Your Dataset</h3>
|
| 142 |
+
<p>Download the prepared dataset as a ZIP file to your local machine.</p>
|
| 143 |
+
<button id="download-zip-btn" class="secondary-btn">Download Dataset ZIP</button>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<hr>
|
| 147 |
+
|
| 148 |
+
<div class="export-section">
|
| 149 |
+
<h3>2. Publish to Hugging Face Hub</h3>
|
| 150 |
+
<div class="warning-banner">
|
| 151 |
+
<strong>Note:</strong> This requires HF_TOKEN to be set in the Space settings.
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<div class="form-group">
|
| 155 |
+
<label for="hub-repo-name">Repository Name</label>
|
| 156 |
+
<input type="text" id="hub-repo-name" placeholder="e.g., tulasi-leaf-dataset">
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<div class="checkbox-group">
|
| 160 |
+
<input type="checkbox" id="hub-private-repo" checked>
|
| 161 |
+
<label for="hub-private-repo">Make repository private</label>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<button id="push-to-hub-btn" class="primary-btn">Push to Hub</button>
|
| 165 |
+
<div id="push-status" class="hidden"></div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
<!-- Help Modal -->
|
| 171 |
+
<div id="help-modal" class="modal hidden">
|
| 172 |
+
<div class="modal-content">
|
| 173 |
+
<span class="close-btn">×</span>
|
| 174 |
+
<h2>Disease Guide</h2>
|
| 175 |
+
|
| 176 |
+
<div class="disease-guide">
|
| 177 |
+
<h3>Common Tulasi Leaf Diseases</h3>
|
| 178 |
+
|
| 179 |
+
<div class="disease-item">
|
| 180 |
+
<h4>Healthy</h4>
|
| 181 |
+
<p>Vibrant green leaves with no visible damage or discoloration.</p>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
<div class="disease-item">
|
| 185 |
+
<h4>Leaf Spot</h4>
|
| 186 |
+
<p>Dark brown or black circular spots on leaves, often with yellow halos.</p>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div class="disease-item">
|
| 190 |
+
<h4>Powdery Mildew</h4>
|
| 191 |
+
<p>White powdery coating on leaf surfaces, usually on upper side.</p>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<div class="disease-item">
|
| 195 |
+
<h4>Downy Mildew</h4>
|
| 196 |
+
<p>Yellow patches on upper leaf surface with fuzzy growth underneath.</p>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
<div class="disease-item">
|
| 200 |
+
<h4>Bacterial Blight</h4>
|
| 201 |
+
<p>Water-soaked lesions that turn brown/black, often with yellow margins.</p>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div class="disease-item">
|
| 205 |
+
<h4>Nutrient Deficiency</h4>
|
| 206 |
+
<p>Yellowing leaves, stunted growth, or unusual discoloration patterns.</p>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<div class="disease-item">
|
| 210 |
+
<h4>Insect Damage</h4>
|
| 211 |
+
<p>Holes, chewed edges, or visible insect feeding marks on leaves.</p>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<div class="disease-item">
|
| 215 |
+
<h4>Drought/Scorch</h4>
|
| 216 |
+
<p>Brown, crispy edges or wilted appearance due to water stress.</p>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<div class="disease-item">
|
| 220 |
+
<h4>Mechanical Damage</h4>
|
| 221 |
+
<p>Physical tears, cuts, or bruising from handling or environmental factors.</p>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<div class="disease-item">
|
| 225 |
+
<h4>Unknown</h4>
|
| 226 |
+
<p>Symptoms that don't clearly match other categories or unclear conditions.</p>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<!-- Toast Notification -->
|
| 233 |
+
<div id="toast" class="hidden"></div>
|
| 234 |
+
|
| 235 |
+
<script src="app.js"></script>
|
| 236 |
+
</body>
|
| 237 |
+
</html>
|
app/frontend/styles.css
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* /app/frontend/styles.css - Dark Mode Theme */
|
| 2 |
+
:root {
|
| 3 |
+
--primary-color: #4CAF50;
|
| 4 |
+
--primary-hover: #66bb6a;
|
| 5 |
+
--secondary-color: #2196F3;
|
| 6 |
+
--secondary-hover: #42A5F5;
|
| 7 |
+
--bg-color: #121212;
|
| 8 |
+
--surface-color: #1e1e1e;
|
| 9 |
+
--panel-color: #2a2a2a;
|
| 10 |
+
--border-color: #444;
|
| 11 |
+
--text-color: #e0e0e0;
|
| 12 |
+
--text-muted: #888;
|
| 13 |
+
--success-color: #4CAF50;
|
| 14 |
+
--warning-color: #FF9800;
|
| 15 |
+
--error-color: #F44336;
|
| 16 |
+
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
* {
|
| 20 |
+
box-sizing: border-box;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* Utility: hide/show toggled by JS */
|
| 24 |
+
.hidden {
|
| 25 |
+
display: none !important;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
img {
|
| 30 |
+
max-width: 100%;
|
| 31 |
+
height: auto;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
canvas, .cropper-container {
|
| 35 |
+
max-width: 100%;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
body {
|
| 39 |
+
font-family: var(--font-family);
|
| 40 |
+
margin: 0;
|
| 41 |
+
background-color: var(--bg-color);
|
| 42 |
+
color: var(--text-color);
|
| 43 |
+
display: flex;
|
| 44 |
+
justify-content: center;
|
| 45 |
+
align-items: center;
|
| 46 |
+
min-height: 100vh;
|
| 47 |
+
font-size: 14px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
#app-container {
|
| 51 |
+
width: 100%;
|
| 52 |
+
max-width: 1600px;
|
| 53 |
+
height: 95vh;
|
| 54 |
+
background: var(--surface-color);
|
| 55 |
+
border-radius: 8px;
|
| 56 |
+
border: 1px solid var(--border-color);
|
| 57 |
+
display: flex;
|
| 58 |
+
flex-direction: column;
|
| 59 |
+
overflow: hidden;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* Header */
|
| 63 |
+
#app-header {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: space-between;
|
| 67 |
+
padding: 10px 20px;
|
| 68 |
+
border-bottom: 1px solid var(--border-color);
|
| 69 |
+
background: var(--surface-color);
|
| 70 |
+
min-height: 60px;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
#app-header h1 {
|
| 74 |
+
font-size: 1.4em;
|
| 75 |
+
margin: 0;
|
| 76 |
+
color: var(--text-color);
|
| 77 |
+
font-weight: 600;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
#progress-indicator {
|
| 81 |
+
font-weight: 500;
|
| 82 |
+
background: var(--panel-color);
|
| 83 |
+
padding: 8px 20px;
|
| 84 |
+
border-radius: 20px;
|
| 85 |
+
color: var(--text-muted);
|
| 86 |
+
border: 1px solid var(--border-color);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.header-actions {
|
| 90 |
+
display: flex;
|
| 91 |
+
gap: 10px;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* Main Content & Panels */
|
| 95 |
+
#main-content {
|
| 96 |
+
flex-grow: 1;
|
| 97 |
+
display: flex;
|
| 98 |
+
overflow: hidden;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.screen {
|
| 102 |
+
width: 100%;
|
| 103 |
+
height: 100%;
|
| 104 |
+
display: flex;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.screen.hidden {
|
| 108 |
+
display: none;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
#workspace-screen {
|
| 112 |
+
padding: 10px;
|
| 113 |
+
gap: 10px;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.panel {
|
| 117 |
+
background: var(--surface-color);
|
| 118 |
+
border: 1px solid var(--border-color);
|
| 119 |
+
border-radius: 6px;
|
| 120 |
+
display: flex;
|
| 121 |
+
flex-direction: column;
|
| 122 |
+
overflow: hidden;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
#left-panel {
|
| 126 |
+
flex: 0 0 280px;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
#center-panel {
|
| 130 |
+
flex: 1 1 auto;
|
| 131 |
+
background: var(--bg-color);
|
| 132 |
+
min-width: 400px;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
#right-panel {
|
| 136 |
+
flex: 0 0 380px;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/* Upload Screen */
|
| 140 |
+
#upload-screen {
|
| 141 |
+
flex-direction: column;
|
| 142 |
+
justify-content: center;
|
| 143 |
+
align-items: center;
|
| 144 |
+
background: var(--bg-color);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.upload-box {
|
| 148 |
+
text-align: center;
|
| 149 |
+
border: 2px dashed var(--border-color);
|
| 150 |
+
padding: 60px 40px;
|
| 151 |
+
border-radius: 12px;
|
| 152 |
+
background: var(--surface-color);
|
| 153 |
+
max-width: 600px;
|
| 154 |
+
transition: border-color 0.3s ease;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.upload-box:hover {
|
| 158 |
+
border-color: var(--primary-color);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.upload-box h2 {
|
| 162 |
+
margin: 0 0 15px 0;
|
| 163 |
+
color: var(--text-color);
|
| 164 |
+
font-size: 1.8em;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.upload-box p {
|
| 168 |
+
margin: 0 0 25px 0;
|
| 169 |
+
color: var(--text-muted);
|
| 170 |
+
font-size: 1.1em;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/* Left Panel - Thumbnails */
|
| 174 |
+
#left-panel h3 {
|
| 175 |
+
margin: 0;
|
| 176 |
+
padding: 15px 20px;
|
| 177 |
+
font-size: 1.1em;
|
| 178 |
+
border-bottom: 1px solid var(--border-color);
|
| 179 |
+
background: var(--panel-color);
|
| 180 |
+
font-weight: 600;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
#thumbnail-grid {
|
| 184 |
+
padding: 15px;
|
| 185 |
+
display: grid;
|
| 186 |
+
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
| 187 |
+
gap: 12px;
|
| 188 |
+
overflow-y: auto;
|
| 189 |
+
flex-grow: 1;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.thumbnail {
|
| 193 |
+
width: 100%;
|
| 194 |
+
aspect-ratio: 1 / 1;
|
| 195 |
+
object-fit: cover;
|
| 196 |
+
border-radius: 6px;
|
| 197 |
+
cursor: pointer;
|
| 198 |
+
border: 2px solid transparent;
|
| 199 |
+
transition: all 0.2s ease;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.thumbnail:hover {
|
| 203 |
+
border-color: var(--text-muted);
|
| 204 |
+
transform: scale(1.02);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.thumbnail.active {
|
| 208 |
+
border-color: var(--primary-color);
|
| 209 |
+
transform: scale(1.05);
|
| 210 |
+
box-shadow: 0 0 15px rgba(76, 175, 80, 0.3);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.thumbnail.labeled {
|
| 214 |
+
opacity: 0.5;
|
| 215 |
+
position: relative;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.thumbnail.labeled::after {
|
| 219 |
+
content: "✓";
|
| 220 |
+
position: absolute;
|
| 221 |
+
top: 5px;
|
| 222 |
+
right: 5px;
|
| 223 |
+
background: var(--success-color);
|
| 224 |
+
color: white;
|
| 225 |
+
border-radius: 50%;
|
| 226 |
+
width: 20px;
|
| 227 |
+
height: 20px;
|
| 228 |
+
display: flex;
|
| 229 |
+
align-items: center;
|
| 230 |
+
justify-content: center;
|
| 231 |
+
font-size: 12px;
|
| 232 |
+
font-weight: bold;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/* Center Panel - Image Viewer */
|
| 236 |
+
#image-viewer {
|
| 237 |
+
flex-grow: 1;
|
| 238 |
+
display: flex;
|
| 239 |
+
justify-content: center;
|
| 240 |
+
align-items: center;
|
| 241 |
+
overflow: hidden;
|
| 242 |
+
padding: 10px;
|
| 243 |
+
background: var(--bg-color);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
#image-viewer img {
|
| 247 |
+
display: block;
|
| 248 |
+
max-width: 100%;
|
| 249 |
+
max-height: 100%;
|
| 250 |
+
border-radius: 4px;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
#image-toolbar {
|
| 254 |
+
display: flex;
|
| 255 |
+
justify-content: space-between;
|
| 256 |
+
align-items: center;
|
| 257 |
+
padding: 12px 15px;
|
| 258 |
+
border-top: 1px solid var(--border-color);
|
| 259 |
+
background: var(--panel-color);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
#current-image-name {
|
| 263 |
+
font-size: 0.95em;
|
| 264 |
+
color: var(--text-color);
|
| 265 |
+
font-weight: 500;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.toolbar-buttons {
|
| 269 |
+
display: flex;
|
| 270 |
+
gap: 10px;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
/* Right Panel - Tabs */
|
| 274 |
+
#tools-label-tabs {
|
| 275 |
+
display: flex;
|
| 276 |
+
border-bottom: 1px solid var(--border-color);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.tab-btn {
|
| 280 |
+
flex: 1;
|
| 281 |
+
padding: 15px 12px;
|
| 282 |
+
background: var(--surface-color);
|
| 283 |
+
border: none;
|
| 284 |
+
cursor: pointer;
|
| 285 |
+
font-size: 1em;
|
| 286 |
+
color: var(--text-muted);
|
| 287 |
+
border-bottom: 3px solid transparent;
|
| 288 |
+
transition: all 0.2s ease;
|
| 289 |
+
font-weight: 500;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.tab-btn:hover {
|
| 293 |
+
background: var(--panel-color);
|
| 294 |
+
color: var(--text-color);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.tab-btn.active {
|
| 298 |
+
color: var(--primary-color);
|
| 299 |
+
font-weight: 600;
|
| 300 |
+
border-bottom-color: var(--primary-color);
|
| 301 |
+
background: var(--panel-color);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.tab-content {
|
| 305 |
+
padding: 20px;
|
| 306 |
+
overflow-y: auto;
|
| 307 |
+
flex-grow: 1;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.tab-content.hidden {
|
| 311 |
+
display: none;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.tab-content h3 {
|
| 315 |
+
margin-top: 0;
|
| 316 |
+
font-size: 1.2em;
|
| 317 |
+
color: var(--text-color);
|
| 318 |
+
margin-bottom: 20px;
|
| 319 |
+
font-weight: 600;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* Form Elements */
|
| 323 |
+
.form-group {
|
| 324 |
+
margin-bottom: 20px;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.form-group label {
|
| 328 |
+
display: block;
|
| 329 |
+
margin-bottom: 8px;
|
| 330 |
+
font-weight: 600;
|
| 331 |
+
color: var(--text-color);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
select, input[type="text"] {
|
| 335 |
+
width: 100%;
|
| 336 |
+
padding: 12px;
|
| 337 |
+
border: 1px solid var(--border-color);
|
| 338 |
+
border-radius: 6px;
|
| 339 |
+
background: var(--panel-color);
|
| 340 |
+
color: var(--text-color);
|
| 341 |
+
font-size: 14px;
|
| 342 |
+
transition: border-color 0.2s ease;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
select:focus, input[type="text"]:focus {
|
| 346 |
+
outline: none;
|
| 347 |
+
border-color: var(--primary-color);
|
| 348 |
+
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.checkbox-group {
|
| 352 |
+
display: flex;
|
| 353 |
+
align-items: center;
|
| 354 |
+
margin: 15px 0;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.checkbox-group input[type="checkbox"] {
|
| 358 |
+
width: auto;
|
| 359 |
+
margin-right: 10px;
|
| 360 |
+
accent-color: var(--primary-color);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.checkbox-group label {
|
| 364 |
+
margin-bottom: 0;
|
| 365 |
+
font-weight: normal;
|
| 366 |
+
cursor: pointer;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
/* Tool Groups */
|
| 370 |
+
.tool-group {
|
| 371 |
+
margin-bottom: 25px;
|
| 372 |
+
border: 1px solid var(--border-color);
|
| 373 |
+
border-radius: 8px;
|
| 374 |
+
padding: 20px;
|
| 375 |
+
background: var(--bg-color);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.tool-group h3 {
|
| 379 |
+
margin-top: 0;
|
| 380 |
+
margin-bottom: 15px;
|
| 381 |
+
color: var(--primary-color);
|
| 382 |
+
font-size: 1.1em;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.tool-group p {
|
| 386 |
+
color: var(--text-muted);
|
| 387 |
+
margin-bottom: 20px;
|
| 388 |
+
line-height: 1.5;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.button-group {
|
| 392 |
+
display: flex;
|
| 393 |
+
flex-direction: column;
|
| 394 |
+
gap: 10px;
|
| 395 |
+
margin-top: 15px;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
#mask-preview-container {
|
| 399 |
+
margin-top: 20px;
|
| 400 |
+
padding: 15px;
|
| 401 |
+
background: var(--surface-color);
|
| 402 |
+
border-radius: 6px;
|
| 403 |
+
border: 1px solid var(--border-color);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
#mask-preview-container h4 {
|
| 407 |
+
margin: 0 0 10px 0;
|
| 408 |
+
color: var(--text-color);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
#mask-preview-container img {
|
| 412 |
+
max-width: 100%;
|
| 413 |
+
border-radius: 4px;
|
| 414 |
+
border: 1px solid var(--border-color);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
/* Label Info */
|
| 418 |
+
.label-info {
|
| 419 |
+
background: var(--bg-color);
|
| 420 |
+
padding: 20px;
|
| 421 |
+
border-radius: 8px;
|
| 422 |
+
border: 1px solid var(--border-color);
|
| 423 |
+
margin-top: 25px;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.label-info h4 {
|
| 427 |
+
margin: 0 0 15px 0;
|
| 428 |
+
color: var(--primary-color);
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.label-info p {
|
| 432 |
+
margin: 8px 0;
|
| 433 |
+
font-size: 13px;
|
| 434 |
+
line-height: 1.4;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.label-info strong {
|
| 438 |
+
color: var(--text-color);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/* Buttons */
|
| 442 |
+
button {
|
| 443 |
+
padding: 12px 18px;
|
| 444 |
+
border: 1px solid var(--border-color);
|
| 445 |
+
border-radius: 6px;
|
| 446 |
+
background: var(--panel-color);
|
| 447 |
+
cursor: pointer;
|
| 448 |
+
font-size: 14px;
|
| 449 |
+
color: var(--text-color);
|
| 450 |
+
transition: all 0.2s ease;
|
| 451 |
+
font-weight: 500;
|
| 452 |
+
font-family: inherit;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
button:hover {
|
| 456 |
+
background-color: #3a3a3a;
|
| 457 |
+
border-color: var(--text-muted);
|
| 458 |
+
transform: translateY(-1px);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
button:active {
|
| 462 |
+
transform: translateY(0);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
button:disabled {
|
| 466 |
+
cursor: not-allowed;
|
| 467 |
+
opacity: 0.5;
|
| 468 |
+
transform: none;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.primary-btn {
|
| 472 |
+
background: var(--primary-color);
|
| 473 |
+
border-color: var(--primary-color);
|
| 474 |
+
color: white;
|
| 475 |
+
font-weight: 600;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.primary-btn:hover:not(:disabled) {
|
| 479 |
+
background: var(--primary-hover);
|
| 480 |
+
border-color: var(--primary-hover);
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.secondary-btn {
|
| 484 |
+
background: var(--secondary-color);
|
| 485 |
+
border-color: var(--secondary-color);
|
| 486 |
+
color: white;
|
| 487 |
+
font-weight: 600;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
.secondary-btn:hover:not(:disabled) {
|
| 491 |
+
background: var(--secondary-hover);
|
| 492 |
+
border-color: var(--secondary-hover);
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
/* Modals */
|
| 496 |
+
.modal {
|
| 497 |
+
position: fixed;
|
| 498 |
+
z-index: 1000;
|
| 499 |
+
left: 0;
|
| 500 |
+
top: 0;
|
| 501 |
+
width: 100%;
|
| 502 |
+
height: 100%;
|
| 503 |
+
background-color: rgba(0,0,0,0.8);
|
| 504 |
+
display: flex;
|
| 505 |
+
justify-content: center;
|
| 506 |
+
align-items: center;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.modal.hidden {
|
| 510 |
+
display: none;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.modal-content {
|
| 514 |
+
background-color: var(--surface-color);
|
| 515 |
+
padding: 30px;
|
| 516 |
+
border-radius: 12px;
|
| 517 |
+
width: 90%;
|
| 518 |
+
max-width: 600px;
|
| 519 |
+
max-height: 80vh;
|
| 520 |
+
overflow-y: auto;
|
| 521 |
+
border: 1px solid var(--border-color);
|
| 522 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.close-btn {
|
| 526 |
+
color: var(--text-muted);
|
| 527 |
+
float: right;
|
| 528 |
+
font-size: 28px;
|
| 529 |
+
font-weight: bold;
|
| 530 |
+
cursor: pointer;
|
| 531 |
+
line-height: 1;
|
| 532 |
+
margin-top: -10px;
|
| 533 |
+
margin-right: -10px;
|
| 534 |
+
transition: color 0.2s ease;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.close-btn:hover {
|
| 538 |
+
color: var(--text-color);
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.modal h2 {
|
| 542 |
+
margin: 0 0 25px 0;
|
| 543 |
+
color: var(--text-color);
|
| 544 |
+
font-size: 1.5em;
|
| 545 |
+
font-weight: 600;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
.modal hr {
|
| 549 |
+
border: none;
|
| 550 |
+
border-top: 1px solid var(--border-color);
|
| 551 |
+
margin: 25px 0;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
/* Export Modal */
|
| 555 |
+
.export-section {
|
| 556 |
+
margin: 20px 0;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
.export-section h3 {
|
| 560 |
+
color: var(--primary-color);
|
| 561 |
+
margin-bottom: 10px;
|
| 562 |
+
font-size: 1.2em;
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
.export-section p {
|
| 566 |
+
color: var(--text-muted);
|
| 567 |
+
margin-bottom: 15px;
|
| 568 |
+
line-height: 1.5;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.warning-banner {
|
| 572 |
+
background-color: rgba(255, 152, 0, 0.1);
|
| 573 |
+
border: 1px solid var(--warning-color);
|
| 574 |
+
padding: 15px;
|
| 575 |
+
border-radius: 6px;
|
| 576 |
+
margin-bottom: 20px;
|
| 577 |
+
font-size: 14px;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
.warning-banner strong {
|
| 581 |
+
color: var(--warning-color);
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
#export-summary {
|
| 585 |
+
background: var(--bg-color);
|
| 586 |
+
padding: 20px;
|
| 587 |
+
border-radius: 8px;
|
| 588 |
+
border: 1px solid var(--border-color);
|
| 589 |
+
margin-bottom: 20px;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
#export-summary h4 {
|
| 593 |
+
margin: 0 0 15px 0;
|
| 594 |
+
color: var(--primary-color);
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
#export-summary p {
|
| 598 |
+
margin: 8px 0;
|
| 599 |
+
color: var(--text-color);
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
#export-summary h5 {
|
| 603 |
+
margin: 15px 0 10px 0;
|
| 604 |
+
color: var(--text-color);
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
#export-summary ul {
|
| 608 |
+
margin: 0;
|
| 609 |
+
padding-left: 20px;
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
#export-summary li {
|
| 613 |
+
color: var(--text-muted);
|
| 614 |
+
margin: 5px 0;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
#push-status {
|
| 618 |
+
margin-top: 15px;
|
| 619 |
+
padding: 15px;
|
| 620 |
+
background: var(--bg-color);
|
| 621 |
+
border-radius: 6px;
|
| 622 |
+
border: 1px solid var(--border-color);
|
| 623 |
+
font-size: 14px;
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
#push-status.hidden {
|
| 627 |
+
display: none;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
#push-status a {
|
| 631 |
+
color: var(--primary-color);
|
| 632 |
+
text-decoration: none;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
#push-status a:hover {
|
| 636 |
+
text-decoration: underline;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
/* Disease Guide */
|
| 640 |
+
.disease-guide {
|
| 641 |
+
max-height: 400px;
|
| 642 |
+
overflow-y: auto;
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
.disease-item {
|
| 646 |
+
margin-bottom: 20px;
|
| 647 |
+
padding: 15px;
|
| 648 |
+
background: var(--bg-color);
|
| 649 |
+
border-radius: 6px;
|
| 650 |
+
border: 1px solid var(--border-color);
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.disease-item h4 {
|
| 654 |
+
margin: 0 0 8px 0;
|
| 655 |
+
color: var(--primary-color);
|
| 656 |
+
font-size: 1.1em;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.disease-item p {
|
| 660 |
+
margin: 0;
|
| 661 |
+
color: var(--text-muted);
|
| 662 |
+
line-height: 1.5;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
/* Toast Notification */
|
| 666 |
+
#toast {
|
| 667 |
+
position: fixed;
|
| 668 |
+
bottom: 20px;
|
| 669 |
+
left: 50%;
|
| 670 |
+
transform: translateX(-50%);
|
| 671 |
+
padding: 15px 25px;
|
| 672 |
+
border-radius: 25px;
|
| 673 |
+
background-color: var(--panel-color);
|
| 674 |
+
color: var(--text-color);
|
| 675 |
+
z-index: 2000;
|
| 676 |
+
opacity: 0;
|
| 677 |
+
transition: all 0.3s ease;
|
| 678 |
+
border: 1px solid var(--border-color);
|
| 679 |
+
font-weight: 500;
|
| 680 |
+
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
#toast.show {
|
| 684 |
+
opacity: 1;
|
| 685 |
+
bottom: 30px;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
#toast.success {
|
| 689 |
+
background-color: var(--success-color);
|
| 690 |
+
color: white;
|
| 691 |
+
border-color: var(--success-color);
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
#toast.error {
|
| 695 |
+
background-color: var(--error-color);
|
| 696 |
+
color: white;
|
| 697 |
+
border-color: var(--error-color);
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
/* Spinner */
|
| 701 |
+
.spinner {
|
| 702 |
+
border: 4px solid var(--border-color);
|
| 703 |
+
border-top: 4px solid var(--primary-color);
|
| 704 |
+
border-radius: 50%;
|
| 705 |
+
width: 40px;
|
| 706 |
+
height: 40px;
|
| 707 |
+
animation: spin 1s linear infinite;
|
| 708 |
+
margin: 0 auto 15px;
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
@keyframes spin {
|
| 712 |
+
0% { transform: rotate(0deg); }
|
| 713 |
+
100% { transform: rotate(360deg); }
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
/* Cropper.js Customizations */
|
| 717 |
+
.cropper-view-box {
|
| 718 |
+
outline: 2px solid var(--primary-color) !important;
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
.cropper-face {
|
| 722 |
+
background-color: rgba(76, 175, 80, 0.1) !important;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
.cropper-line,
|
| 726 |
+
.cropper-point {
|
| 727 |
+
background-color: var(--primary-color) !important;
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
.cropper-point.point-se {
|
| 731 |
+
width: 8px !important;
|
| 732 |
+
height: 8px !important;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
/* Responsive Design */
|
| 736 |
+
@media (max-width: 1200px) {
|
| 737 |
+
#left-panel {
|
| 738 |
+
flex: 0 0 220px;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
#right-panel {
|
| 742 |
+
flex: 0 0 320px;
|
| 743 |
+
}
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
@media (max-width: 900px) {
|
| 747 |
+
#workspace-screen {
|
| 748 |
+
flex-direction: column;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
#left-panel,
|
| 752 |
+
#right-panel {
|
| 753 |
+
flex: 0 0 200px;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
#center-panel {
|
| 757 |
+
flex: 1 1 300px;
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
#thumbnail-grid {
|
| 761 |
+
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
| 762 |
+
gap: 8px;
|
| 763 |
+
padding: 10px;
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
@media (max-width: 600px) {
|
| 768 |
+
#app-container {
|
| 769 |
+
height: 100vh;
|
| 770 |
+
border-radius: 0;
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
#app-header {
|
| 774 |
+
flex-direction: column;
|
| 775 |
+
gap: 10px;
|
| 776 |
+
padding: 15px;
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
#app-header h1 {
|
| 780 |
+
font-size: 1.2em;
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
.upload-box {
|
| 784 |
+
padding: 40px 20px;
|
| 785 |
+
margin: 20px;
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
.modal-content {
|
| 789 |
+
padding: 20px;
|
| 790 |
+
margin: 20px;
|
| 791 |
+
width: calc(100% - 40px);
|
| 792 |
+
}
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
/* Scrollbar Styling */
|
| 796 |
+
::-webkit-scrollbar {
|
| 797 |
+
width: 8px;
|
| 798 |
+
height: 8px;
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
::-webkit-scrollbar-track {
|
| 802 |
+
background: var(--bg-color);
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
::-webkit-scrollbar-thumb {
|
| 806 |
+
background: var(--border-color);
|
| 807 |
+
border-radius: 4px;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
::-webkit-scrollbar-thumb:hover {
|
| 811 |
+
background: var(--text-muted);
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
/* Focus States */
|
| 815 |
+
button:focus,
|
| 816 |
+
select:focus,
|
| 817 |
+
input:focus {
|
| 818 |
+
outline: 2px solid var(--primary-color);
|
| 819 |
+
outline-offset: 2px;
|
| 820 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn[standard]==0.24.0
|
| 3 |
+
python-multipart==0.0.6
|
| 4 |
+
aiofiles==23.2.1
|
| 5 |
+
opencv-python-headless==4.8.1.78
|
| 6 |
+
numpy==1.24.3
|
| 7 |
+
Pillow==10.1.0
|
| 8 |
+
pydantic==2.5.0
|
| 9 |
+
huggingface-hub==0.19.4
|
| 10 |
+
pathlib==1.0.1
|
| 11 |
+
datasets
|
| 12 |
+
pyarrow
|
| 13 |
+
python-dotenv
|