Welcome to SAT Annotator
-Upload a satellite image to start annotating
-✨ Polygon editing now enabled! ✨
-diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ea75cc..eeceffa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - if [ -f app/requirements_simplified.txt ]; then pip install -r app/requirements_simplified.txt; fi + # Install PyTorch CPU version first with the correct index + pip install torch==2.5.1+cpu torchvision==0.20.1+cpu --index-url https://download.pytorch.org/whl/cpu + # Install CI-specific requirements (CPU versions) + if [ -f app/requirements-ci.txt ]; then pip install -r app/requirements-ci.txt; fi + # Install test requirements if [ -f app/tests/test_requirements.txt ]; then pip install -r app/tests/test_requirements.txt; fi + # Install SAM model (may be needed for imports, even though tests use mocks) + pip install git+https://github.com/facebookresearch/segment-anything.git - name: Set test mode environment variable run: | @@ -34,34 +40,40 @@ jobs: cd app/tests python run_unittests.py - web-build: + web-validation: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: 'web/package.json' - - - name: Install dependencies - working-directory: ./web - run: npm ci - - - name: Build - working-directory: ./web - run: npm run build + - name: Validate HTML files + run: | + echo "Validating web static files..." + # Check if main HTML files exist + test -f web/index.html || (echo "Missing index.html" && exit 1) + test -f web/styles.css || (echo "Missing styles.css" && exit 1) + test -d web/js || (echo "Missing js directory" && exit 1) + echo "Web files validation passed" - - name: Run linting - working-directory: ./web - run: npm run lint || true + - name: Check JavaScript syntax + run: | + echo "Checking JavaScript syntax..." + # Use Python to check basic JavaScript syntax if Node.js is available + if command -v node >/dev/null 2>&1; then + for js_file in web/js/*.js; do + if [ -f "$js_file" ]; then + echo "Checking $js_file..." + node -c "$js_file" || (echo "Syntax error in $js_file" && exit 1) + fi + done + else + echo "Node.js not available, skipping JS syntax check..." + fi + echo "JavaScript validation completed" docker-build: runs-on: ubuntu-latest - needs: [app-tests, web-build] + needs: [app-tests, web-validation] if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') steps: @@ -70,9 +82,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - name: Build and tag Docker images + - name: Build and test Docker images run: | + echo "Building Docker images..." docker compose build + echo "Docker build completed successfully" # Uncomment and configure the following if you want to push to Docker Hub or another registry # - name: Login to Docker Hub @@ -83,4 +97,4 @@ jobs: # # - name: Push Docker images # run: | - # docker compose push + # docker compose push \ No newline at end of file diff --git a/.gitignore b/.gitignore index 90ee09e..d7b91ec 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,9 @@ cython_debug/ # PyPI configuration file .pypirc + +# SAT-Annotator specific directories +annotations/ +uploads/ +logs/ +app/logs/ diff --git a/Dockerfile.app b/Dockerfile.app index 8e394d9..ad3cb1e 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -1,13 +1,14 @@ # Build stage -FROM python:3.11-slim as builder +FROM python:3.12-slim as builder WORKDIR /app # Install build dependencies -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ wget \ git \ + && apt-get upgrade -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -18,23 +19,23 @@ ENV PATH="/opt/venv/bin:$PATH" # Copy and install requirements COPY app/requirements.txt . -# Install specific PyTorch CPU-only version to save space +# Install PyTorch (CPU version for smaller Docker image) and other dependencies RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \ - pip install --no-cache-dir torch==2.2.0+cpu torchvision==0.17.0+cpu -f https://download.pytorch.org/whl/torch_stable.html && \ - pip install --no-cache-dir opencv-python-headless pillow fastapi uvicorn python-multipart && \ - pip install --no-cache-dir supervision python-jose[cryptography] && \ + pip install --no-cache-dir torch==2.5.1+cpu torchvision==0.20.1+cpu --index-url https://download.pytorch.org/whl/cpu && \ + pip install --no-cache-dir -r requirements.txt && \ pip install --no-cache-dir 'git+https://github.com/facebookresearch/segment-anything.git' # Final stage -FROM python:3.11-slim +FROM python:3.12-slim WORKDIR /app # Install runtime dependencies only -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install -y --no-install-recommends \ libgl1-mesa-glx \ libglib2.0-0 \ wget \ + && apt-get upgrade -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -45,11 +46,20 @@ ENV PATH="/opt/venv/bin:$PATH" # Create necessary directories RUN mkdir -p /app/models /app/annotations /app/uploads +# Create a non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser + # Download the SAM model checkpoint (only in the final stage) -RUN wget --no-verbose https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth -P /app/models/ +RUN wget --no-verbose https://dl.fbaipublicfiles.com/segment-anything/sam_vit_h_4b8939.pth -P /app/models/ # Copy the application COPY . . +# Change ownership to the non-root user +RUN chown -R appuser:appuser /app + +# Switch to the non-root user +USER appuser + # Command to run the application CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/Dockerfile.web b/Dockerfile.web index 37d5eff..57108ab 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -1,20 +1,27 @@ -FROM node:22-alpine +FROM python:3.12-slim WORKDIR /web -# Install netcat for health checking -RUN apk add --no-cache netcat-openbsd - -COPY web/package.json web/package-lock.json ./ -RUN npm install +# Install basic tools and security updates +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && apt-get upgrade -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +# Copy static files COPY web . -# Make the wait script executable -COPY web/wait-for-backend.sh /wait-for-backend.sh -RUN chmod +x /wait-for-backend.sh +# Create a non-root user for security +RUN groupadd -r webuser && useradd -r -g webuser webuser + +# Change ownership to the non-root user +RUN chown -R webuser:webuser /web + +# Switch to the non-root user +USER webuser -EXPOSE 5173 +EXPOSE 8080 -# Use the wait script before starting the app -CMD ["/wait-for-backend.sh", "npm", "run", "dev"] +# Serve static files using Python's built-in server +CMD ["python", "-m", "http.server", "8080"] diff --git a/README.md b/README.md index 65ed17b..a1a9d35 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This project is sponsored by the Egyptian Space Agency (EgSA). - Point-prompt based segmentation - Automatic polygon generation from segmentation masks - JSON export format support +- Smart caching system for repeated segmentation operations **Planned:** - Multiple prompt types (box, points, text) @@ -41,25 +42,26 @@ This project is sponsored by the Egyptian Space Agency (EgSA). - **Storage**: Session-based in-memory storage - **AI Models**: - Segment Anything Model (SAM) - - PyTorch + - PyTorch with CUDA support - **Image Processing**: - OpenCV - - Pillow + - Pillow (PIL) - **Containerization**: Docker (optional) - **Data Processing**: NumPy -- **Frontend**: React, TypeScript, Tailwind CSS +- **Frontend**: Vanilla HTML5/CSS3/JavaScript (no frameworks) +- **API**: Pure RESTful API architecture for efficient data handling +- **Optimization**: Smart caching and instant preprocessing for maximum performance ## Installation & Setup ### Prerequisites - Git -- Python 3.10+ (Python 3.12 recommended for local development) -- Node.js and npm (for frontend) +- Python 3.10+ (Python 3.11+ recommended) - Docker and Docker Compose (optional, for containerized deployment) -- CUDA-capable GPU (optional, for faster segmentation) +- CUDA-capable GPU (optional, for faster AI segmentation) -### Quick Start with Docker +### Quick Start with Docker (Recommended) 1. Clone the repository: ```bash @@ -71,8 +73,9 @@ cd sat-annotator ```bash docker-compose up --build ``` + *Note: The SAM model will be automatically downloaded during the Docker build process.* -3. Access the API at `http://localhost:8000` and the frontend at `http://localhost:5173` +3. Access the application at `http://localhost:8000` ### Local Development Setup @@ -82,9 +85,10 @@ git clone https://github.com/yourusername/sat-annotator.git cd sat-annotator ``` -2. Download the SAM model: - - Download the SAM model checkpoint from [https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth](https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth) - - Place it in the `models/` directory +2. Download the SAM model (required for local development): + - Download the SAM model checkpoint: [sam_vit_h_4b8939.pth](https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth) + - Create a `models/` directory in the project root if it doesn't exist + - Place the downloaded file in the `models/` directory 3. Set up Python environment: ```bash @@ -93,45 +97,153 @@ python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate # Install backend dependencies -pip install -r app/requirements_simplified.txt +pip install -r app/requirements.txt pip install git+https://github.com/facebookresearch/segment-anything.git ``` -4. Run the backend: + **Note on PyTorch versions:** + - `requirements.txt`: Contains CUDA version of PyTorch for local development with GPU acceleration + - `requirements-ci.txt`: Contains CPU version of PyTorch for CI/testing environments + - If you don't have CUDA support, install PyTorch CPU version first: + ```bash + pip install torch==2.5.1+cpu torchvision==0.20.1+cpu --index-url https://download.pytorch.org/whl/cpu + ``` + +4. Run the application: ```bash uvicorn app.main:app --reload ``` -5. Set up and run the frontend (in a separate terminal): -```bash -cd web -npm install -npm run dev +5. Access the application at `http://localhost:8000` + +### Note: Frontend is Integrated + +The frontend is served directly by the FastAPI backend as static files. No separate Node.js setup is required. + +**Docker vs Local Development:** +- **Docker**: SAM model downloads automatically during build process +- **Local Development**: Manual model download required (as shown above) + +## Project Structure + +``` +sat-annotator/ +├── .github/ # CI/CD workflows +│ └── ci.yml # GitHub Actions pipeline +├── app/ # Backend application +│ ├── main.py # FastAPI application entry point +│ ├── requirements.txt # Python dependencies (CUDA version for local dev) +│ ├── requirements-ci.txt # Python dependencies (CPU version for CI/testing) +│ ├── routers/ # API route handlers +│ │ ├── session_images.py # Image upload, retrieval, management +│ │ └── session_segmentation.py # AI segmentation and annotations +│ ├── storage/ # Session management +│ │ ├── session_store.py # In-memory session storage +│ │ └── session_manager.py # Session cookie management +│ ├── utils/ # Utility modules +│ │ ├── image_processing.py # Image handling and validation +│ │ └── sam_model.py # SAM model integration +│ ├── schemas/ # Pydantic data models +│ │ └── session_schemas.py # Request/response models +│ ├── tests/ # Unit tests +│ │ ├── README.md # Testing documentation +│ │ ├── run_unittests.py # Test runner +│ │ ├── mocks.py # Mock objects for testing +│ │ ├── test_requirements.txt # Testing dependencies +│ │ ├── generate_test_requirements.py # Dependency generator +│ │ └── unittest_*.py # Individual test files +│ └── logs/ # Application logs (created at runtime) +├── web/ # Frontend application +│ ├── index.html # Main application interface +│ ├── styles.css # Application styling +│ └── js/ # JavaScript modules +│ ├── app.js # Main application logic +│ ├── api.js # API communication +│ ├── canvas.js # Canvas drawing and interaction +│ ├── annotations.js # Annotation management +│ └── utils.js # Utility functions +├── models/ # AI model files +│ └── sam_vit_h_4b8939.pth # SAM model (downloaded/mounted) +├── uploads/ # User uploaded images (created at runtime) +├── annotations/ # Generated annotation files (created at runtime) +├── logs/ # Application logs (created at runtime) +├── data/ # Sample/test data (optional) +├── docs/ # Project documentation +├── venv/ # Python virtual environment (optional) +├── .gitignore # Git ignore rules +├── docker-compose.yml # Docker orchestration +├── Dockerfile.app # Backend container definition +├── Dockerfile.web # Frontend container definition +├── LICENSE # MIT License +└── README.md # This file ``` -6. Access the API at `http://localhost:8000` and the frontend at `http://localhost:5173` +### Runtime Directories + +These directories are created automatically by the application: + +- **`uploads/`**: Stores user-uploaded satellite images +- **`annotations/`**: Stores AI-generated and manual annotation JSON files +- **`logs/`** & **`app/logs/`**: Application log files for debugging +- **`models/`**: Contains the SAM AI model (auto-downloaded in Docker) + +### Development Notes + +- Runtime directories are ignored by Git (see `.gitignore`) +- The application creates necessary directories on startup +- Session data is stored in memory and cleared on restart +- Console logs (for debugging) are embedded in JavaScript source files ## Usage -### Testing the API +### Web Interface -#### Root Endpoint +1. Start the application (Docker or local) +2. Open `http://localhost:8000` in your browser +3. Upload satellite images via drag & drop or file picker +4. Use AI segmentation tools or manual annotation +5. Export annotations in JSON format + +### API Reference + +The application provides a comprehensive REST API for programmatic access: + +#### Health Check ```bash -curl http://localhost:8000/ +curl http://localhost:8000/health ``` Expected response: ```json { - "message": "Welcome to the Satellite Image Annotation Tool" + "status": "healthy" } ``` -#### Upload an Image +#### Session Management + +##### Get Session Information +```bash +curl http://localhost:8000/api/session-info/ +``` + +##### Clear Session Data +```bash +curl -X DELETE http://localhost:8000/api/session/ +``` + +##### Export Session Data +```bash +curl -X POST http://localhost:8000/api/export-session/ +``` + +#### Image Management + +##### Upload an Image ```bash curl -X POST http://localhost:8000/api/upload-image/ \ -H "Content-Type: multipart/form-data" \ - -F "file=@./data/test_image.jpg" + -F "file=@./data/satellite-image.tif" ``` Expected response: @@ -140,54 +252,50 @@ Expected response: "success": true, "message": "File uploaded successfully", "image": { - "file_name": "test_image.jpg", - "file_path": "uploads/b1030d37-9fa8-4823-b5ad-43ee4cd22e5c.jpg", - "resolution": "540x360", + "image_id": "uuid-string", + "file_name": "satellite-image.tif", + "file_path": "uploads/uuid-filename.tif", + "resolution": "1024x768", "source": "user_upload", - "image_id": 1, - "capture_date": "2025-02-26T19:43:14.121927", - "created_at": "2025-02-26T19:43:14.121927" + "capture_date": "2025-06-11T10:30:00.000Z", + "created_at": "2025-06-11T10:30:00.000Z" } } ``` -#### Retrieve All Images +##### Retrieve All Images ```bash curl http://localhost:8000/api/images/ ``` -Expected response: -```json -[ - { - "file_name": "test_image.jpg", - "file_path": "uploads/b1030d37-9fa8-4823-b5ad-43ee4cd22e5c.jpg", - "resolution": "540x360", - "source": "user_upload", - "image_id": 1, - "capture_date": "2025-02-26T19:43:14.121927", - "created_at": "2025-02-26T19:43:14.121927" - }, - { - "file_name": "test_image.jpg", - "file_path": "uploads/d8defaa1-f5c9-400f-86e7-f2ffc4ce0d3c.jpg", - "resolution": "540x360", - "source": "user_upload", - "image_id": 2, - "capture_date": "2025-02-26T20:42:35.066164", - "created_at": "2025-02-26T20:42:35.066164" - } -] +##### Get Specific Image +```bash +curl http://localhost:8000/api/images/{image_id}/ ``` -#### Generate Segmentation +##### Delete Image and Annotations +```bash +curl -X DELETE http://localhost:8000/api/images/{image_id} +``` + +#### AI Segmentation + +##### Preprocess Image for Segmentation +```bash +curl -X POST http://localhost:8000/api/preprocess/ \ + -H "Content-Type: application/json" \ + -d '{"image_id": "your-image-id"}' +``` + +##### Generate Point-Based Segmentation ```bash curl -X POST http://localhost:8000/api/segment/ \ -H "Content-Type: application/json" \ -d '{ - "image_id": 1, + "image_id": "your-image-id", "x": 0.5, - "y": 0.5 + "y": 0.3, + "label": "Building" }' ``` @@ -195,53 +303,116 @@ Expected response: ```json { "success": true, - "polygon": [[x1,y1], [x2,y2], ...], - "annotation_id": 1 + "polygon": [[0.1, 0.2], [0.3, 0.2], [0.3, 0.4], [0.1, 0.4]], + "annotation_id": "annotation-uuid", + "label": "Building", + "confidence": 0.92 } ``` -#### API Documentation -- Interactive API docs: http://localhost:8000/docs -- Alternative API docs: http://localhost:8000/redoc +#### Annotation Management -## API Documentation +##### Create Manual Annotation +```bash +curl -X POST http://localhost:8000/api/annotations/ \ + -H "Content-Type: application/json" \ + -d '{ + "image_id": "your-image-id", + "polygon": [[0.1, 0.2], [0.3, 0.2], [0.3, 0.4], [0.1, 0.4]], + "label": "Water", + "annotation_type": "manual" + }' +``` -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/` | GET | Health check, returns welcome message | -| `/api/upload-image/` | POST | Upload satellite imagery | -| `/api/images/` | GET | Retrieve list of all uploaded images | -| `/api/segment/` | POST | Generate segmentation from point prompt | +##### Update Annotation +```bash +curl -X PUT http://localhost:8000/api/annotations/{annotation_id} \ + -H "Content-Type: application/json" \ + -d '{ + "label": "Updated Label", + "polygon": [[0.1, 0.2], [0.3, 0.2], [0.3, 0.4], [0.1, 0.4]] + }' +``` -## Database Schema +##### Delete Annotation +```bash +curl -X DELETE http://localhost:8000/api/annotations/{annotation_id} +``` -The application uses PostgreSQL with the following structure: +##### Get Image Annotations +```bash +curl http://localhost:8000/api/annotations/{image_id} +``` + +### Testing the API -- **images**: Stores metadata about uploaded satellite images -- **labels**: Contains annotation categories (building, road, etc.) -- **ai_models**: Tracks machine learning models used for annotation -- **annotation_files**: Records annotation data associated with images - - New: Supports auto-generated annotations from SAM +#### Root Endpoint +```bash +curl http://localhost:8000/ +``` -Relationships: -- An image can have multiple annotation files -- Annotation files can be linked to AI models that generated them -- Annotations store JSON formatted polygons +Expected response: +```json +{ + "message": "Welcome to the Satellite Image Annotation Tool" +} +``` + +For comprehensive API examples, see the **API Reference** section above. + +## API Documentation + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check for container orchestration | +| `/api/upload-image/` | POST | Upload satellite imagery (TIFF, PNG, JPG) | +| `/api/images/` | GET | Retrieve all uploaded images | +| `/api/images/{id}/` | GET | Get specific image by ID | +| `/api/images/{id}` | DELETE | Delete image and associated annotations | +| `/api/preprocess/` | POST | Prepare image for AI segmentation | +| `/api/segment/` | POST | Generate AI segmentation from point | +| `/api/annotations/` | POST | Create manual annotation | +| `/api/annotations/{id}` | PUT | Update existing annotation | +| `/api/annotations/{id}` | DELETE | Delete annotation | +| `/api/annotations/{image_id}` | GET | Get all annotations for image | +| `/api/session-info/` | GET | Get current session information | +| `/api/session/` | DELETE | Clear all session data | +| `/api/export-session/` | POST | Export session data as JSON | + +**Interactive Documentation:** +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +## Session Management + +The application uses session-based in-memory storage for temporary data management: + +- **Image Storage**: Uploaded images are stored in the uploads directory +- **Session Data**: Image metadata and annotations are kept in memory during the session +- **Annotations**: Generated polygons from SAM segmentation are stored as JSON +- **Temporary Files**: Session data is cleared when the application restarts + +Benefits: +- No database setup required for quick deployment +- Simplified development and testing +- Stateless application design +- Easy horizontal scaling ## Development Roadmap - [x] Project setup with FastAPI - [x] Docker containerization -- [x] Database integration with PostgreSQL +- [x] Session-based storage architecture - [x] Image upload functionality - [x] Image metadata extraction - [x] SAM model integration - [x] Point-prompt segmentation - [x] JSON export +- [x] Vanilla HTML/CSS/JavaScript frontend +- [x] Professional codebase cleanup and optimization - [ ] Multiple prompt types support - [ ] Manual annotation interface - [ ] Additional export formats -- [ ] User authentication ## License diff --git a/app/db/database.py b/app/db/database.py deleted file mode 100644 index a865ff3..0000000 --- a/app/db/database.py +++ /dev/null @@ -1,17 +0,0 @@ -from sqlalchemy import create_engine -from sqlalchemy.orm import declarative_base, sessionmaker -import os - -DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/sat_annotator") - -engine = create_engine(DATABASE_URL) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() - -# Dependency to get DB session -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() \ No newline at end of file diff --git a/app/db/models.py b/app/db/models.py deleted file mode 100644 index 9e8506a..0000000 --- a/app/db/models.py +++ /dev/null @@ -1,49 +0,0 @@ -from sqlalchemy import Column, Integer, String, Text, Float, DateTime, Boolean, ForeignKey -from sqlalchemy.sql import func -from sqlalchemy.orm import relationship -from app.db.database import Base - -class Image(Base): - __tablename__ = "images" - - image_id = Column(Integer, primary_key=True, index=True) - file_name = Column(String(255), nullable=False) - file_path = Column(Text, nullable=False) - resolution = Column(String(50)) - capture_date = Column(DateTime, server_default=func.now()) - source = Column(String(100)) - created_at = Column(DateTime, server_default=func.now()) - - annotations = relationship("AnnotationFile", back_populates="image") - -class AIModel(Base): - __tablename__ = "ai_models" - - model_id = Column(Integer, primary_key=True, index=True) - model_name = Column(String(255), nullable=False) - version = Column(String(50)) - trained_on = Column(Text) - accuracy = Column(Float) - created_at = Column(DateTime, server_default=func.now()) - - annotations = relationship("AnnotationFile", back_populates="model") - -class AnnotationFile(Base): - __tablename__ = "annotation_files" - - annotation_id = Column(Integer, primary_key=True, index=True) - image_id = Column(Integer, ForeignKey("images.image_id", ondelete="CASCADE")) - file_path = Column(Text, nullable=False) - created_at = Column(DateTime, server_default=func.now()) - auto_generated = Column(Boolean, default=False) - model_id = Column(Integer, ForeignKey("ai_models.model_id", ondelete="SET NULL"), nullable=True) - - image = relationship("Image", back_populates="annotations") - model = relationship("AIModel", back_populates="annotations") - -class Label(Base): - __tablename__ = "labels" - - label_id = Column(Integer, primary_key=True, index=True) - name = Column(String(100), unique=True, nullable=False) - description = Column(Text) \ No newline at end of file diff --git a/app/main.py b/app/main.py index e503382..41d25c6 100644 --- a/app/main.py +++ b/app/main.py @@ -19,31 +19,21 @@ try: # For uvicorn from root directory from app.routers import session_images, session_segmentation - print("Using app.routers imports") + logger.info("Using app.routers imports") except ImportError: try: # For running directly from app directory from routers import session_images, session_segmentation - print("Using direct routers imports") + logger.info("Using direct routers imports") except ImportError as e: - print(f"Import error: {e}") + logger.error(f"Import error: {e}") # Final fallback - try with explicit path manipulation sys.path.insert(0, os.path.dirname(app_dir)) from app.routers import session_images, session_segmentation - print("Using fallback app.routers imports") - -try: - from app.utils.test_utils import is_test_mode - test_mode = is_test_mode() -except ImportError: - try: - from utils.test_utils import is_test_mode - test_mode = is_test_mode() - except ImportError: - test_mode = False + logger.info("Using fallback app.routers imports") # Determine if we're running in Docker or locally -in_docker = os.path.exists('/.dockerenv') and not test_mode +in_docker = os.path.exists('/.dockerenv') # Set paths based on environment base_path = Path("/app") if in_docker else Path(".") @@ -79,13 +69,6 @@ def health_check(): app.include_router(session_images.router, prefix="/api", tags=["images"]) app.include_router(session_segmentation.router, prefix="/api", tags=["segmentation"]) -try: - from app.websocket_notify import router as websocket_notify_router -except ImportError: - from websocket_notify import router as websocket_notify_router - -app.include_router(websocket_notify_router, prefix="/api", tags=["websocket"]) - # Mount the uploads directory for static file serving app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads") diff --git a/app/requirements-ci.txt b/app/requirements-ci.txt new file mode 100644 index 0000000..895fd9c --- /dev/null +++ b/app/requirements-ci.txt @@ -0,0 +1,48 @@ +# Core FastAPI web framework +fastapi==0.115.8 +uvicorn==0.34.3 +pydantic==2.10.6 +pydantic-core==2.27.2 +annotated-types==0.7.0 +python-multipart==0.0.20 +starlette==0.45.3 + +# Image processing and AI - PyTorch CPU version for CI/testing +# Note: torch and torchvision are installed separately in CI workflow with correct index +opencv-python==4.11.0.86 +pillow==11.2.1 +numpy==2.0.2 + +# PyTorch dependencies +filelock==3.18.0 +fsspec==2025.5.1 +jinja2==3.1.6 +networkx==3.5 +sympy==1.13.1 +mpmath==1.3.0 +MarkupSafe==3.0.2 + +# HTTP client and utilities +requests==2.32.3 +urllib3==2.4.0 +httpcore==1.0.9 + +# Security for session management +python-jose==3.5.0 +cryptography==45.0.4 +cffi==1.17.1 +pycparser==2.22 +ecdsa==0.19.1 +pyasn1==0.6.1 +rsa==4.9.1 + +# Core Python web dependencies +click==8.1.8 +h11==0.16.0 +anyio==4.8.0 +sniffio==1.3.1 +idna==3.10 +charset-normalizer==3.4.1 +typing_extensions==4.14.0 +packaging==24.2 +certifi==2025.1.31 diff --git a/app/requirements.txt b/app/requirements.txt index 7a49c0d..adde4da 100644 Binary files a/app/requirements.txt and b/app/requirements.txt differ diff --git a/app/requirements_simplified.txt b/app/requirements_simplified.txt deleted file mode 100644 index 7a182e0..0000000 --- a/app/requirements_simplified.txt +++ /dev/null @@ -1,11 +0,0 @@ -fastapi>=0.95.0 -pydantic>=2.0.0 -uvicorn>=0.17.0 -python-multipart -numpy>=1.22.0 -pillow -opencv-python>=4.6.0 -torch -torchvision -supervision -python-jose[cryptography] diff --git a/app/routers/images.py b/app/routers/images.py deleted file mode 100644 index a172dff..0000000 --- a/app/routers/images.py +++ /dev/null @@ -1,90 +0,0 @@ -from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, status -from sqlalchemy.orm import Session -from app.db.database import get_db -from app.db.models import Image as ImageModel -from app.utils.image_processing import save_upload_file, validate_image_file -from app.schemas.images import UploadResponse, Image, ImageCreate -from typing import List - -router = APIRouter() - -@router.post("/upload-image/", response_model=UploadResponse) -async def upload_image( - file: UploadFile = File(...), - db: Session = Depends(get_db) -): - """ - Upload a satellite image file for annotation. - - Supports JPG, PNG, TIFF and GeoTIFF formats. - """ - # Validate file type - if not file: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No file provided" - ) - - if not validate_image_file(file): - raise HTTPException( - status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - detail="File type not supported. Please upload JPG, PNG, TIFF or GeoTIFF" - ) - - # Process and save the file - try: - file_info = await save_upload_file(file) - - # Save to database - db_image = ImageModel( - file_name=file_info["original_filename"], - file_path=file_info["path"], - resolution=file_info["resolution"], - source="user_upload" - ) - - db.add(db_image) - db.commit() - db.refresh(db_image) - - return UploadResponse( - success=True, - message="File uploaded successfully", - image=Image.from_orm(db_image) - ) - - except Exception as e: - db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error uploading file: {str(e)}" - ) - -@router.get("/images/", response_model=List[Image]) -def get_images( - skip: int = 0, - limit: int = 100, - db: Session = Depends(get_db) -): - """Get list of uploaded images""" - images = db.query(ImageModel).offset(skip).limit(limit).all() - return images - - - -@router.get("/images/{image_id}/", response_model=Image) -def get_image(image_id: int, db: Session = Depends(get_db)): - """ - Retrieve a specific image by its ID. - """ - # Fetch image from the database - image = db.query(ImageModel).filter(ImageModel.image_id == image_id).first() - - # Handle case where image doesn't exist - if not image: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Image with ID {image_id} not found" - ) - - return image diff --git a/app/routers/segmentation.py b/app/routers/segmentation.py deleted file mode 100644 index 4bbc5cf..0000000 --- a/app/routers/segmentation.py +++ /dev/null @@ -1,93 +0,0 @@ -from fastapi import APIRouter, HTTPException, Depends, status -from sqlalchemy.orm import Session -from app.db.database import get_db -from app.db.models import Image as ImageModel, AnnotationFile -from app.utils.sam_model import SAMSegmenter -from pydantic import BaseModel -from typing import List, Optional -import json -from pathlib import Path -import os - -router = APIRouter() -segmenter = SAMSegmenter() - -class PointPrompt(BaseModel): - image_id: int - x: float # Normalized coordinate (0-1) - y: float # Normalized coordinate (0-1) - -class SegmentationResponse(BaseModel): - success: bool - polygon: List[List[float]] # List of [x,y] coordinates - annotation_id: Optional[int] = None - -@router.post("/segment/", response_model=SegmentationResponse) -async def segment_from_point( - prompt: PointPrompt, - db: Session = Depends(get_db) -): - """Generate segmentation from a point click""" - image = db.query(ImageModel).filter(ImageModel.image_id == prompt.image_id).first() - if not image: - raise HTTPException(status_code=404, detail="Image not found") - - try: - # Handle both absolute and relative paths for image_path - image_path = image.file_path - if not os.path.isabs(image_path): - # If it's a relative path, convert to absolute path within the container - if image_path.startswith("uploads/"): - image_path = "/app/" + image_path - else: - image_path = "/app/uploads/" + os.path.basename(image_path) - - # Check if file exists - if not os.path.exists(image_path): - raise FileNotFoundError(f"Image file not found at {image_path}") - - height, width = segmenter.set_image(image_path) - - pixel_x = int(prompt.x * width) - pixel_y = int(prompt.y * height) - - mask = segmenter.predict_from_point([pixel_x, pixel_y]) - - polygon = segmenter.mask_to_polygon(mask) - if not polygon: - raise HTTPException(status_code=400, detail="Could not generate polygon from mask") - - annotation_dir = Path("/app/annotations") # Fixed path in container - annotation_dir.mkdir(exist_ok=True) - - annotation_path = annotation_dir / f"annotation_{image.image_id}_{len(polygon)}.json" - with open(annotation_path, "w") as f: - json.dump({ - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [polygon] - } - }, f) - - annotation = AnnotationFile( - image_id=image.image_id, - file_path=str(annotation_path), - auto_generated=True - ) - db.add(annotation) - db.commit() - db.refresh(annotation) - - return SegmentationResponse( - success=True, - polygon=polygon, - annotation_id=annotation.annotation_id - ) - - except Exception as e: - db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error generating segmentation: {str(e)}" - ) \ No newline at end of file diff --git a/app/routers/session_images.py b/app/routers/session_images.py index e1d0273..add1bb1 100644 --- a/app/routers/session_images.py +++ b/app/routers/session_images.py @@ -1,93 +1,17 @@ -from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, Response, Request, status, BackgroundTasks +from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, status from app.utils.image_processing import save_upload_file, validate_image_file -from app.schemas.session_schemas import UploadResponse, Image, ImageCreate +from app.schemas.session_schemas import UploadResponse, Image from app.storage.session_manager import get_session_manager, SessionManager from app.storage.session_store import session_store -from typing import List, Optional -from app.websocket_notify import manager as ws_manager -from app.routers.session_segmentation import segmenter +from typing import List import os import logging -import asyncio -import threading -import time router = APIRouter() -def run_background_segmentation(session_id: str, image_id: str): - """ - Run segmentation for the uploaded image in a background thread, save mask/polygon, and notify frontend. - """ - from app.routers.session_segmentation import segmenter - from app.storage.session_store import session_store - import numpy as np - import logging - import asyncio - import os - from pathlib import Path - import cv2 - try: - image = session_store.get_image(session_id, image_id) - if not image: - logging.error(f"No image found for segmentation: {image_id}") - return - # Get image path logic (copied from segmentation endpoint) - in_docker = os.path.exists('/.dockerenv') - if in_docker: - base_dir = "/app" - else: - base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - stored_path = image.file_path - if in_docker: - if stored_path.startswith("uploads/"): - image_path = f"/app/{stored_path}" - else: - image_path = f"/app/uploads/{os.path.basename(stored_path)}" - else: - if stored_path.startswith("uploads/"): - image_path = os.path.join(base_dir, stored_path) - else: - image_path = stored_path - if not os.path.exists(image_path): - logging.error(f"Image file not found at {image_path}") - return - # Set image and get dimensions - height, width = segmenter.set_image(image_path) - # Use center point for auto-segmentation - pixel_x = width // 2 - pixel_y = height // 2 - mask = segmenter.predict_from_point([pixel_x, pixel_y]) - # Save mask and overlay (copied from segmentation endpoint) - annotation_dir = Path(base_dir) / "annotations" - annotation_dir.mkdir(exist_ok=True) - mask_image_path = annotation_dir / f"mask_{session_id}_{image_id}.png" - cv2.imwrite(str(mask_image_path), mask) - original_image = cv2.imread(image_path) - mask_colored = cv2.applyColorMap(mask, cv2.COLORMAP_JET) - overlay = cv2.addWeighted(original_image, 0.7, mask_colored, 0.3, 0) - overlay_path = annotation_dir / f"overlay_{session_id}_{image_id}.png" - cv2.imwrite(str(overlay_path), overlay) - polygon = segmenter.mask_to_polygon(mask) - # Save annotation in session store (auto_generated=True) - session_store.add_annotation(session_id, image_id, str(mask_image_path), auto_generated=True) - # Notify frontend via WebSocket - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(ws_manager.send_message(session_id, "Image ready for annotation")) - loop.close() - logging.info(f"Background segmentation notification sent for session {session_id}") - except Exception as e: - logging.error(f"Background segmentation failed for session {session_id}: {e}") - -def notify_segmentation_ready(session_id: str, image_id: str): - thread = threading.Thread(target=run_background_segmentation, args=(session_id, image_id)) - thread.daemon = True - thread.start() - @router.post("/upload-image/", response_model=UploadResponse) async def upload_image( file: UploadFile = File(...), - background_tasks: BackgroundTasks = None, session_manager: SessionManager = Depends(get_session_manager) ): """ @@ -120,8 +44,7 @@ async def upload_image( resolution=file_info["resolution"], source="user_upload" ) - - # Convert SessionImage to the expected Image pydantic model format + # Convert SessionImage to the expected Image pydantic model format # Create an Image Pydantic model directly from the SessionImage attributes image = Image( image_id=session_image.image_id, @@ -131,13 +54,12 @@ async def upload_image( source=session_image.source, capture_date=session_image.capture_date, created_at=session_image.created_at - ) - # Trigger segmentation in the background (center point) - notify_segmentation_ready(session_id, image.image_id) + ) # Image uploaded successfully - ready for immediate preprocessing + logging.info(f"✓ Image uploaded successfully: {file_info['original_filename']}") return UploadResponse( success=True, - message="File uploaded successfully. Segmentation will be performed in the background.", + message="File uploaded successfully. Ready for annotation.", image=image ) @@ -284,5 +206,18 @@ def export_session( def get_session_id_endpoint( session_manager: SessionManager = Depends(get_session_manager) ): - """Get the current session ID for WebSocket connections""" - return {"session_id": session_manager.session_id} + """Get the current session ID for API calls""" + session_id = session_manager.session_id + # Ensure the session exists in the store + session_store.create_session(session_id) + return {"session_id": session_id} + + +@router.get("/validate-session/{session_id}/") +def validate_session(session_id: str): + """Validate if a session exists in the store""" + session_data = session_store.get_session(session_id) + if session_data: + return {"valid": True, "session_id": session_id} + else: + return {"valid": False, "session_id": None} diff --git a/app/routers/session_segmentation.py b/app/routers/session_segmentation.py index 744cb88..c26a7b3 100644 --- a/app/routers/session_segmentation.py +++ b/app/routers/session_segmentation.py @@ -18,7 +18,7 @@ log_dir.mkdir(exist_ok=True) log_filename = f"debug_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" logging.basicConfig( - level=logging.DEBUG, + level=logging.INFO, # Changed from DEBUG to INFO format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(log_dir / log_filename), @@ -30,6 +30,30 @@ router = APIRouter() segmenter = SAMSegmenter() +def construct_image_path(stored_path): + """Construct consistent image path for both preprocessing and segmentation""" + # Determine if running in Docker + in_docker = os.path.exists('/.dockerenv') + + if not os.path.isabs(stored_path): + if in_docker: + # Docker environment + if stored_path.startswith("uploads/"): + image_path = "/app/" + stored_path + else: + image_path = "/app/uploads/" + os.path.basename(stored_path) + else: + # Local environment + if stored_path.startswith("uploads/"): + base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + image_path = os.path.join(base_dir, stored_path) + else: + image_path = stored_path + else: + image_path = stored_path + + return image_path + class PointPrompt(BaseModel): image_id: str x: float @@ -40,13 +64,24 @@ class SegmentationResponse(BaseModel): polygon: List[List[float]] annotation_id: Optional[str] = None cached: bool = False + processing_time: Optional[float] = None + +class PreprocessRequest(BaseModel): + image_id: str + +class PreprocessResponse(BaseModel): + success: bool + message: str @router.post("/segment/", response_model=SegmentationResponse) async def segment_from_point( prompt: PointPrompt, session_manager: SessionManager = Depends(get_session_manager) ): - """Generate segmentation from a point click, with caching for subsequent clicks on the same image.""" + """Generate segmentation from a point click with timeout handling""" + import asyncio + import concurrent.futures + session_id = session_manager.session_id # Debug: log the received coordinates @@ -57,6 +92,9 @@ async def segment_from_point( raise HTTPException(status_code=404, detail="Image not found") try: + import time + start_time = time.time() + # Determine if running in Docker in_docker = os.path.exists('/.dockerenv') @@ -66,58 +104,67 @@ async def segment_from_point( else: base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) annotation_dir = Path(os.path.join(base_dir, "annotations")) - annotation_dir.mkdir(exist_ok=True) - - # Get the image path from the session store - stored_path = image.file_path - - # Construct image path - if in_docker: - if stored_path.startswith("uploads/"): - image_path = "/app/" + stored_path - else: - image_path = "/app/uploads/" + os.path.basename(stored_path) - else: - if stored_path.startswith("uploads/"): - image_path = os.path.join(base_dir, stored_path) - else: - image_path = stored_path + annotation_dir.mkdir(exist_ok=True) # Get the image path from the session store + stored_path = image.file_path # Construct image path using unified function + image_path = construct_image_path(stored_path) # Check if file exists if not os.path.exists(image_path): raise FileNotFoundError(f"Image file not found at {image_path}") + # Check if this is a new image or one we've already processed + is_cached = (image_path == segmenter.current_image_path and + image_path in segmenter.cache) - # Check if this is a new image or one we've already processed - is_cached = image_path == segmenter.current_image_path and image_path in segmenter.cache - - logger.debug(f"Processing image: {image_path}, cached: {is_cached}") - - # Set the image in the segmenter - height, width = segmenter.set_image(image_path) - pixel_x = int(prompt.x * width) - pixel_y = int(prompt.y * height) - - logger.debug(f"Click at coordinates: ({pixel_x}, {pixel_y}) for image size: {width}x{height}") - - # Get mask from point - mask = segmenter.predict_from_point([pixel_x, pixel_y]) - - # Save the mask - mask_image_path = annotation_dir / f"mask_{session_id}_{image.image_id}.png" - cv2.imwrite(str(mask_image_path), mask) - logger.debug(f"Mask saved at {mask_image_path}") - - # Create and save overlay - original_image = cv2.imread(image_path) - mask_colored = cv2.applyColorMap(mask, cv2.COLORMAP_JET) - overlay = cv2.addWeighted(original_image, 0.7, mask_colored, 0.3, 0) - overlay_path = annotation_dir / f"overlay_{session_id}_{image.image_id}.png" - cv2.imwrite(str(overlay_path), overlay) - logger.debug(f"Overlay saved at {overlay_path}") + logger.debug(f"Processing image: {image_path}, cached: {is_cached}, current: {segmenter.current_image_path}") + # Run segmentation in a thread pool with timeout + def run_segmentation(): + # OPTIMIZED: Only set image if it's not already the current image + # This preserves the instant segmentation after preprocessing + if segmenter.current_image_path != image_path: + logger.debug(f"Setting new image in SAM: {image_path}") + height, width = segmenter.set_image(image_path) + else: + logger.debug(f"Using already-set image (instant segmentation!)") + # Get dimensions from cache instead of re-setting + if image_path in segmenter.cache: + height, width = segmenter.cache[image_path]['image_size'] + else: + # Fallback - this shouldn't happen if preprocessing worked + logger.warning(f"Image not in cache, falling back to set_image") + height, width = segmenter.set_image(image_path) + + pixel_x = int(prompt.x * width) + pixel_y = int(prompt.y * height) + + logger.debug(f"Click at coordinates: ({pixel_x}, {pixel_y}) for image size: {width}x{height}") + + # Get mask from point (GPU accelerated) + mask_start = time.time() + mask = segmenter.predict_from_point([pixel_x, pixel_y]) + mask_time = time.time() - mask_start + logger.debug(f"Mask generation time: {mask_time:.3f}s") + + # Convert mask to polygon immediately + polygon = segmenter.mask_to_polygon(mask) + + if not polygon: + raise ValueError("Could not generate polygon from mask") + + return polygon, is_cached - polygon = segmenter.mask_to_polygon(mask) - if not polygon: - raise HTTPException(status_code=400, detail="Could not generate polygon from mask") + # Execute with timeout (30 seconds) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(run_segmentation) + try: + polygon, is_cached = await asyncio.wait_for( + asyncio.wrap_future(future), + timeout=30.0 + ) + except asyncio.TimeoutError: + raise HTTPException( + status_code=408, + detail="Segmentation timeout - image may still be processing..." + ) # Save JSON annotation_path = annotation_dir / f"annotation_{session_id}_{image.image_id}_{len(polygon)}.json" @@ -142,14 +189,21 @@ async def segment_from_point( ) logger.debug(f"Generated segmentation with {len(polygon)} points, cached: {is_cached}") + # Calculate total processing time + total_processing_time = time.time() - start_time + logger.debug(f"Total segmentation processing time: {total_processing_time:.3f}s") return SegmentationResponse( success=True, polygon=polygon, annotation_id=annotation.annotation_id if annotation else None, - cached=is_cached + cached=is_cached, + processing_time=total_processing_time ) + except HTTPException: + # Re-raise HTTP exceptions (like timeout) + raise except Exception as e: logger.error(f"Error in segmentation: {str(e)}", exc_info=True) raise HTTPException( @@ -186,7 +240,11 @@ async def get_image_annotations( file_path = ann.file_path if os.path.exists(file_path): with open(file_path, 'r') as f: - json_data = json.load(f) + json_data = json.load(f) # DEBUG: Log what we're loading + if json_data.get('features') and len(json_data['features']) > 0: + feature = json_data['features'][0] + if feature.get('geometry', {}).get('coordinates'): + coords = feature['geometry']['coordinates'] result.append({ "annotation_id": ann.annotation_id, @@ -195,6 +253,7 @@ async def get_image_annotations( "data": json_data }) except Exception as e: + logger.error(f"Error loading annotation {ann.annotation_id}: {e}") continue return result @@ -211,20 +270,8 @@ async def clear_image_cache( if not image: raise HTTPException(status_code=404, detail="Image not found") - stored_path = image.file_path - in_docker = os.path.exists('/.dockerenv') - - if in_docker: - if stored_path.startswith("uploads/"): - image_path = "/app/" + stored_path - else: - image_path = "/app/uploads/" + os.path.basename(stored_path) - else: - if stored_path.startswith("uploads/"): - base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - image_path = os.path.join(base_dir, stored_path) - else: - image_path = stored_path + # Use unified path construction + image_path = construct_image_path(image.file_path) segmenter.clear_cache(image_path) @@ -237,7 +284,8 @@ async def save_manual_annotation( ): """Save a manual annotation""" session_id = session_manager.session_id - # Verify the image exists + + # Verify the image exists image = session_store.get_image(session_id, annotation_data.image_id) if not image: raise HTTPException(status_code=404, detail="Image not found") @@ -263,10 +311,9 @@ async def save_manual_annotation( "type": annotation_data.type, "source": annotation_data.source, "created": datetime.now().isoformat() - }, - "geometry": { + }, "geometry": { "type": "Polygon", - "coordinates": [[[point[0], point[1]] for point in annotation_data.polygon]] + "coordinates": [annotation_data.polygon] # Fix: Direct polygon array, not nested } } ] @@ -391,4 +438,54 @@ async def delete_annotation( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error deleting annotation: {str(e)}" + ) + +@router.post("/preprocess/", response_model=PreprocessResponse) +async def preprocess_image( + request: PreprocessRequest, + session_manager: SessionManager = Depends(get_session_manager) +): + """Pre-generate embeddings for faster segmentation""" + try: + # Check if session exists + session_data = session_store.get_session(session_manager.session_id) + if not session_data: + raise HTTPException( + status_code=404, + detail=f"Session {session_manager.session_id} not found. Please refresh the page to create a new session." + ) + + # Get image from session + image = session_store.get_image(session_manager.session_id, request.image_id) + if not image: + raise HTTPException( + status_code=404, + detail=f"Image {request.image_id} not found in session {session_manager.session_id}" + ) # Handle both absolute and relative paths for image_path + image_path = construct_image_path(image.file_path) + + # Check if file exists + if not os.path.exists(image_path): + logger.error(f"File does not exist at: {image_path}") + raise FileNotFoundError(f"Image file not found at {image_path}") + + success = segmenter.preprocess_image(image_path) + + if success: + logger.info(f"Successfully preprocessed image {request.image_id}") + return PreprocessResponse( + success=True, + message="Image preprocessed successfully" + ) + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to preprocess image" + ) + + except Exception as e: + logger.error(f"Error preprocessing image {request.image_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error preprocessing image: {str(e)}" ) \ No newline at end of file diff --git a/app/schemas/images.py b/app/schemas/images.py deleted file mode 100644 index 530164b..0000000 --- a/app/schemas/images.py +++ /dev/null @@ -1,25 +0,0 @@ -from pydantic import BaseModel -from typing import Optional -from datetime import datetime - -class ImageBase(BaseModel): - file_name: str - file_path: str - resolution: Optional[str] = None - source: Optional[str] = None - -class ImageCreate(ImageBase): - pass - -class Image(ImageBase): - image_id: int - capture_date: datetime - created_at: datetime - - class Config: - from_attributes = True # For SQLAlchemy models (formerly orm_mode) - -class UploadResponse(BaseModel): - success: bool - message: str - image: Optional[Image] = None \ No newline at end of file diff --git a/app/storage/session_manager.py b/app/storage/session_manager.py index 2c2524f..cd35ac1 100644 --- a/app/storage/session_manager.py +++ b/app/storage/session_manager.py @@ -33,7 +33,7 @@ def set_session_cookie(response: Response, session_id: str) -> None: response.set_cookie( key=SESSION_COOKIE_NAME, value=session_id, - httponly=False, # Allow JavaScript access for WebSocket connections + httponly=True, # Prevent JavaScript access for enhanced security against XSS samesite="lax", path="/" # No expires parameter = session cookie (cleared on browser close/reload) @@ -58,14 +58,13 @@ def session_id(self) -> str: if not self._session_id: self._session_id = generate_session_id() set_session_cookie(self.response, self._session_id) - return self._session_id def clear_session(self) -> None: """Remove the session cookie""" self.response.delete_cookie( key=SESSION_COOKIE_NAME, - httponly=False, # Match the httponly setting from set_session_cookie + httponly=True, # Match the httponly setting from set_session_cookie path="/" ) diff --git a/app/storage/session_manager_backup.py b/app/storage/session_manager_backup.py deleted file mode 100644 index 0dd078a..0000000 --- a/app/storage/session_manager_backup.py +++ /dev/null @@ -1,77 +0,0 @@ -from fastapi import Request, Response, Depends -import uuid -from typing import Optional -from datetime import datetime, timedelta - -# Session cookie name -SESSION_COOKIE_NAME = "sat_annotator_session" -# Session timeout (7 days) -SESSION_TIMEOUT_DAYS = 7 - -def generate_session_id() -> str: - """Generate a unique session ID""" - return str(uuid.uuid4()) - -async def get_session_id(request: Request) -> str: - """ - Get the session ID from the request cookie or create a new one. - This function should be used as a dependency in FastAPI routes. - """ - session_id = request.cookies.get(SESSION_COOKIE_NAME) - - if not session_id: - session_id = generate_session_id() - - return session_id - -def set_session_cookie(response: Response, session_id: str) -> None: - """ - Set the session cookie in the response. - Call this function when responding to a request if a new session was created. - This creates a session cookie that expires when the browser is closed. - """ - response.set_cookie( - key=SESSION_COOKIE_NAME, - value=session_id, - httponly=False, # Allow JavaScript access for WebSocket connections - samesite="lax", - path="/" - # No expires parameter = session cookie (cleared on browser close/reload) - ) - -class SessionManager: - """ - A manager for handling session-related operations in route handlers. - Simplifies session management in API endpoints. - """ - - def __init__(self, request: Request, response: Response): - self.request = request - self.response = response - self._session_id: Optional[str] = None - @property - def session_id(self) -> str: - """Get or initialize the session ID""" - if self._session_id is None: - self._session_id = self.request.cookies.get(SESSION_COOKIE_NAME) - if not self._session_id: - self._session_id = generate_session_id() - set_session_cookie(self.response, self._session_id) - - return self._session_id - - def clear_session(self) -> None: - """Remove the session cookie""" - self.response.delete_cookie( - key=SESSION_COOKIE_NAME, - httponly=False, # Match the httponly setting from set_session_cookie - path="/" - ) - - -def get_session_manager(request: Request, response: Response) -> SessionManager: - """ - FastAPI dependency to get the session manager. - Usage: session_manager: SessionManager = Depends(get_session_manager) - """ - return SessionManager(request, response) diff --git a/app/storage/session_manager_fixed.py b/app/storage/session_manager_fixed.py deleted file mode 100644 index 2c2524f..0000000 --- a/app/storage/session_manager_fixed.py +++ /dev/null @@ -1,78 +0,0 @@ -from fastapi import Request, Response, Depends -import uuid -from typing import Optional -from datetime import datetime, timedelta - -# Session cookie name -SESSION_COOKIE_NAME = "sat_annotator_session" -# Session timeout (7 days) -SESSION_TIMEOUT_DAYS = 7 - -def generate_session_id() -> str: - """Generate a unique session ID""" - return str(uuid.uuid4()) - -async def get_session_id(request: Request) -> str: - """ - Get the session ID from the request cookie or create a new one. - This function should be used as a dependency in FastAPI routes. - """ - session_id = request.cookies.get(SESSION_COOKIE_NAME) - - if not session_id: - session_id = generate_session_id() - - return session_id - -def set_session_cookie(response: Response, session_id: str) -> None: - """ - Set the session cookie in the response. - Call this function when responding to a request if a new session was created. - This creates a session cookie that expires when the browser is closed. - """ - response.set_cookie( - key=SESSION_COOKIE_NAME, - value=session_id, - httponly=False, # Allow JavaScript access for WebSocket connections - samesite="lax", - path="/" - # No expires parameter = session cookie (cleared on browser close/reload) - ) - -class SessionManager: - """ - A manager for handling session-related operations in route handlers. - Simplifies session management in API endpoints. - """ - - def __init__(self, request: Request, response: Response): - self.request = request - self.response = response - self._session_id: Optional[str] = None - - @property - def session_id(self) -> str: - """Get or initialize the session ID""" - if self._session_id is None: - self._session_id = self.request.cookies.get(SESSION_COOKIE_NAME) - if not self._session_id: - self._session_id = generate_session_id() - set_session_cookie(self.response, self._session_id) - - return self._session_id - - def clear_session(self) -> None: - """Remove the session cookie""" - self.response.delete_cookie( - key=SESSION_COOKIE_NAME, - httponly=False, # Match the httponly setting from set_session_cookie - path="/" - ) - - -def get_session_manager(request: Request, response: Response) -> SessionManager: - """ - FastAPI dependency to get the session manager. - Usage: session_manager: SessionManager = Depends(get_session_manager) - """ - return SessionManager(request, response) diff --git a/app/tests/README.md b/app/tests/README.md index 3ef1cee..49baf56 100644 --- a/app/tests/README.md +++ b/app/tests/README.md @@ -62,4 +62,4 @@ The tests use mock objects to avoid loading heavy dependencies: ## Testing in CI/CD -For CI/CD, a GitHub Actions workflow is set up in `.github/workflows/tests.yml` that will run all unittest tests on each push to the main branch. +For CI/CD, a GitHub Actions workflow is set up in `.github/workflows/ci.yml` that will run all unittest tests on each push to the main branch. diff --git a/app/tests/run_unittests.py b/app/tests/run_unittests.py index 157c29a..e8a79d6 100644 --- a/app/tests/run_unittests.py +++ b/app/tests/run_unittests.py @@ -58,7 +58,6 @@ def main(): text=True, timeout=30 # Add timeout to catch hanging tests ) - # Print output if result.stdout: print(result.stdout) @@ -70,58 +69,66 @@ def main(): print(result.stderr) # Determine test status - if "OK" in result.stdout: - status = "✅ PASS" - elif "FAILED" in result.stdout: - status = "❌ FAIL" + stdout_content = result.stdout or "" + stderr_content = result.stderr or "" + + if "OK" in stdout_content: + status = "PASS" + elif "FAILED" in stdout_content or "ERROR" in stdout_content: + status = "FAIL" else: - status = "⚠️ UNKNOWN" + status = "UNKNOWN" except subprocess.TimeoutExpired: print("ERROR: Test timed out after 30 seconds") - status = "❌ TIMEOUT" + status = "TIMEOUT" errors += 1 result = None - - # Extract test counts if we have results + # Extract test counts if we have results if result: try: test_count = 0 fail_count = 0 - error_count = 0 # Process test counts with multiple approaches for reliability + error_count = 0 + + # Get safe content to work with + stdout_content = result.stdout or "" + stderr_content = result.stderr or "" + + # Process test counts with multiple approaches for reliability # First check for explicit "Ran X tests" in stdout (standard unittest output) - ran_match = re.search(r'Ran (\d+) test', result.stdout) + ran_match = re.search(r'Ran (\d+) test', stdout_content) if ran_match: test_count = int(ran_match.group(1)) # Also check output for "Tests run: X" (our custom output) else: - tests_run_match = re.search(r'Tests run: (\d+)', result.stdout) + tests_run_match = re.search(r'Tests run: (\d+)', stdout_content) if tests_run_match: test_count = int(tests_run_match.group(1)) else: # Count lines ending with "... ok" which typically appear for each test - ok_matches = re.findall(r'\.+ ok$', result.stdout, re.MULTILINE) + ok_matches = re.findall(r'\.+ ok$', stdout_content, re.MULTILINE) if ok_matches: test_count = len(ok_matches) else: # Look for pattern "test_name (...) ... ok" - test_ok_matches = re.findall(r'test_\w+\s+\([^)]+\).*ok', result.stdout) + test_ok_matches = re.findall(r'test_\w+\s+\([^)]+\).*ok', stdout_content) if test_ok_matches: test_count = len(test_ok_matches) else: # Look for test_* methods in output as last resort - method_matches = re.findall(r'test_\w+', result.stdout) + method_matches = re.findall(r'test_\w+', stdout_content) if method_matches: test_count = len(set(method_matches)) # Use set to remove duplicates # Check for failures - fail_matches = re.search(r'[Ff]ailures[=:]?\s*(\d+)', result.stdout) + fail_matches = re.search(r'[Ff]ailures[=:]?\s*(\d+)', stdout_content) if fail_matches: fail_count = int(fail_matches.group(1)) # Check for errors - error_matches = re.search(r'[Ee]rrors[=:]?\s*(\d+)', result.stdout) + error_matches = re.search(r'[Ee]rrors[=:]?\s*(\d+)', stdout_content) if error_matches: error_count = int(error_matches.group(1)) @@ -138,10 +145,10 @@ def main(): except Exception as e: print(f"Error parsing test results: {e}") # Still add the file to results with unknown status - results.append((test_file, "⚠️ ERROR", 0)) + results.append((test_file, "ERROR", 0)) else: # Add the timed out file - results.append((test_file, "❌ TIMEOUT", 0)) + results.append((test_file, "TIMEOUT", 0)) # Print summary print("\n\n===== TEST SUMMARY =====") @@ -156,11 +163,11 @@ def main(): # Overall status if all_tests == 0: - print("\n⚠️ WARNING: No test results were detected!") + print("\nWARNING: No test results were detected!") elif failures == 0 and errors == 0: - print(f"\n✅ ALL {all_tests} TESTS PASSED!") + print(f"\nALL {all_tests} TESTS PASSED!") else: - print(f"\n❌ TESTS FAILED: {failures + errors} issues found in {all_tests} tests.") + print(f"\nTESTS FAILED: {failures + errors} issues found in {all_tests} tests.") # Return success if all tests passed return 0 if failures == 0 and errors == 0 else 1 diff --git a/app/tests/unittest_main_api.py b/app/tests/unittest_main_api.py index 5b8e196..e882713 100644 --- a/app/tests/unittest_main_api.py +++ b/app/tests/unittest_main_api.py @@ -39,7 +39,6 @@ def setUp(self): @self.app.get("/health") def health_check(): return {"status": "healthy"} - # Add root endpoint (when frontend is not mounted) @self.app.get("/") def read_root(): @@ -50,7 +49,7 @@ def read_root(): def frontend_status(): return { "status": "not_mounted", - "message": "Frontend build not found. Run 'npm run build' in the web directory." + "message": "Frontend not mounted. Static files should be served from the web directory." } # Create a test client @@ -85,7 +84,7 @@ def test_frontend_status_endpoint(self): response.json(), { "status": "not_mounted", - "message": "Frontend build not found. Run 'npm run build' in the web directory." + "message": "Frontend not mounted. Static files should be served from the web directory." } ) diff --git a/app/tests/unittest_session_manager.py b/app/tests/unittest_session_manager.py index 9f91555..342a9cc 100644 --- a/app/tests/unittest_session_manager.py +++ b/app/tests/unittest_session_manager.py @@ -95,11 +95,10 @@ def test_set_session_cookie(self): # Call the function set_session_cookie(response, test_session_id) - # Verify cookie was set self.assertIn(SESSION_COOKIE_NAME, response.cookies) self.assertEqual(response.cookies[SESSION_COOKIE_NAME]["value"], test_session_id) - self.assertTrue(response.cookies[SESSION_COOKIE_NAME]["httponly"]) + self.assertTrue(response.cookies[SESSION_COOKIE_NAME]["httponly"]) # True to prevent JavaScript access for enhanced security against XSS self.assertEqual(response.cookies[SESSION_COOKIE_NAME]["samesite"], "lax") self.assertEqual(response.cookies[SESSION_COOKIE_NAME]["path"], "/") @@ -141,7 +140,7 @@ def test_session_manager_clear_session(self): # Verify cookie was deleted self.assertIn(SESSION_COOKIE_NAME, response._deleted_cookies) - self.assertTrue(response._deleted_cookies[SESSION_COOKIE_NAME]["httponly"]) + self.assertTrue(response._deleted_cookies[SESSION_COOKIE_NAME]["httponly"]) # True to match set_session_cookie self.assertEqual(response._deleted_cookies[SESSION_COOKIE_NAME]["path"], "/") if __name__ == "__main__": diff --git a/app/utils/db_migrate.py b/app/utils/db_migrate.py deleted file mode 100644 index 5aacb5a..0000000 --- a/app/utils/db_migrate.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -# A utility script to update image paths in the database to use absolute paths - -import os -from pathlib import Path -from sqlalchemy.orm import Session -from app.db.database import get_db, engine -from app.db.models import Base, Image -import sys - -def migrate_image_paths(): - """Update relative image paths to absolute paths in the database.""" - print("Starting database image path migration...") - - # Initialize the database session - db = next(get_db()) - try: - # Get all images from the database - images = db.query(Image).all() - updated_count = 0 - - for image in images: - # Check if path is already absolute - if os.path.isabs(image.file_path): - print(f"Image {image.image_id}: Path already absolute: {image.file_path}") - continue - - # Convert relative path to absolute - old_path = image.file_path - if old_path.startswith("uploads/"): - new_path = "/app/" + old_path - else: - new_path = "/app/uploads/" + os.path.basename(old_path) - - # Update the database record - image.file_path = new_path - updated_count += 1 - print(f"Image {image.image_id}: Updated path from {old_path} to {new_path}") - - # Commit changes if any updates were made - if updated_count > 0: - db.commit() - print(f"Migration completed successfully: {updated_count} image paths updated.") - else: - print("No image paths needed updating.") - - except Exception as e: - db.rollback() - print(f"Error during migration: {str(e)}") - finally: - db.close() - -if __name__ == "__main__": - migrate_image_paths() \ No newline at end of file diff --git a/app/utils/image_processing.py b/app/utils/image_processing.py index 7faf20f..96e378a 100644 --- a/app/utils/image_processing.py +++ b/app/utils/image_processing.py @@ -1,9 +1,13 @@ import os import uuid +import logging from pathlib import Path from fastapi import UploadFile from PIL import Image +# Set up logging +logger = logging.getLogger(__name__) + # Determine if we're running in Docker or locally in_docker = os.path.exists('/.dockerenv') @@ -49,13 +53,12 @@ async def save_upload_file(file: UploadFile) -> dict: # Remove the temporary TIFF file os.remove(temp_file_path) - - # Use the PNG file as the final file + # Use the PNG file as the final file final_file_path = png_file_path final_filename = png_filename except Exception as e: # If conversion fails, keep the original TIFF file - print(f"Warning: Failed to convert TIFF to PNG: {e}") + logger.warning(f"Failed to convert TIFF to PNG: {e}") # Get image dimensions and resolution resolution = None diff --git a/app/utils/sam_model.py b/app/utils/sam_model.py index bd69f79..201003e 100644 --- a/app/utils/sam_model.py +++ b/app/utils/sam_model.py @@ -4,79 +4,204 @@ import cv2 from pathlib import Path import os +import threading +import logging from typing import Dict, Tuple, List, Optional +# Set up logging for SAM model +logger = logging.getLogger(__name__) + class SAMSegmenter: - def __init__(self): - self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # Check if running in Docker or locally + def __init__(self): # Enhanced GPU detection and setup + if torch.cuda.is_available(): + self.device = torch.device('cuda') + logger.info(f"CUDA available! Using GPU: {torch.cuda.get_device_name(0)}") + logger.info(f"CUDA memory: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB") + # Set optimal GPU settings for SAM real-time performance + torch.backends.cudnn.benchmark = True + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.deterministic = False # For better performance + # Enable memory optimization + torch.cuda.empty_cache() + else: + self.device = torch.device('cpu') + logger.warning("CUDA not available, using CPU (will be slower)") + # CPU optimizations + torch.set_num_threads(4) # Limit CPU threads for better responsiveness + + logger.info(f"SAM Model will run on: {self.device}") + # Check if running in Docker or locally in_docker = os.path.exists('/.dockerenv') base_path = Path("/app") if in_docker else Path(".") self.sam_checkpoint = str(base_path / "models/sam_vit_h_4b8939.pth") - self.model_type = "vit_h" - + self.model_type = "vit_h" if not Path(self.sam_checkpoint).exists(): raise FileNotFoundError(f"SAM checkpoint not found at {self.sam_checkpoint}. Please download it from https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth") + logger.info(f"Loading SAM model from: {self.sam_checkpoint}") self.sam = sam_model_registry[self.model_type](checkpoint=self.sam_checkpoint) + + # Move to device and optimize for inference self.sam.to(device=self.device) + # Note: Removed half-precision to avoid dtype mismatch issues # if self.device.type == 'cuda': + # self.sam = self.sam.half() + # logger.info("Using half-precision (FP16) for faster GPU inference") + self.predictor = SamPredictor(self.sam) + logger.info("SAM model loaded successfully") # Cache for storing image embeddings and masks self.cache: Dict[str, Dict] = {} - self.current_image_path = None + self.current_image_path = None # Add thread lock to prevent concurrent access issues + self._lock = threading.Lock() + logger.info("Thread synchronization enabled for multi-image processing") def set_image(self, image_path): - """Set the image for segmentation and cache its embedding""" - # Check if we've already processed this image - if image_path in self.cache: + """Set the image for segmentation and cache its embedding with thread safety""" + with self._lock: + # Always load the image from disk to ensure we have the correct data + image = cv2.imread(image_path) + if image is None: + raise ValueError(f"Could not load image from {image_path}") + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # Check if we have cached embeddings for this image + if image_path in self.cache: + logger.debug(f"Found cached embeddings for {Path(image_path).name}") + + # Only re-set the image if it's different from the current one + if self.current_image_path != image_path: + logger.debug(f"Re-setting predictor to cached image: {Path(image_path).name}") + with torch.no_grad(): + self.predictor.set_image(image) + self.current_image_path = image_path + self._last_set_image = image_path + else: + logger.debug(f"Image already loaded - instant segmentation ready!") + + return self.cache[image_path]['image_size'] + + logger.info(f"Loading and processing new image: {Path(image_path).name}") + logger.debug(f"Image size: {image.shape[1]}x{image.shape[0]} pixels") + # Generate embeddings on GPU (this is the heavy computation) + with torch.no_grad(): # Disable gradients for faster inference + self.predictor.set_image(image) + + logger.debug(f"Image embeddings generated on {self.device}") + + # Store in cache + self.cache[image_path] = { + 'image_size': image.shape[:2], # (height, width) + 'masks': {}, # Will store generated masks + 'embeddings': None # This is implicitly stored in the predictor + } self.current_image_path = image_path - return self.cache[image_path]['image_size'] - - # Load and process the new image - image = cv2.imread(image_path) - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - self.predictor.set_image(image) - - # Store in cache - self.cache[image_path] = { - 'image_size': image.shape[:2], # (height, width) - 'masks': {}, # Will store generated masks - 'embeddings': None # This is implicitly stored in the predictor - } - self.current_image_path = image_path - - return image.shape[:2] # Return height, width + self._last_set_image = image_path + + return image.shape[:2] # Return height, width + + def preprocess_image(self, image_path): + """Pre-generate embeddings for an image without requiring immediate segmentation""" + with self._lock: + # Check if we've already processed this image + if image_path in self.cache: + logger.debug(f"Image {Path(image_path).name} already has cached embeddings") + return True + + try: + logger.info(f"Pre-processing image for faster segmentation: {Path(image_path).name}") + + # Load and process the image + image = cv2.imread(image_path) + if image is None: + raise ValueError(f"Could not load image from {image_path}") + + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + # Generate embeddings on GPU using the main predictor + with torch.no_grad(): + self.predictor.set_image(image) # Store in cache and set as current image + self.cache[image_path] = { + 'image_size': image.shape[:2], # (height, width) + 'masks': {}, # Will store generated masks + 'embeddings': None # This is implicitly stored in the predictor + } + self.current_image_path = image_path + self._last_set_image = image_path + logger.info(f"Pre-processing complete for {Path(image_path).name}") + return True + + except Exception as e: + logger.error(f"Error pre-processing image {Path(image_path).name}: {e}") + return False def predict_from_point(self, point_coords, point_labels=None): - """Generate mask from a point prompt, using cache if available""" - if self.current_image_path is None: - raise ValueError("No image set for segmentation. Call set_image() first.") - - # Convert to tuple for cache key + """Generate mask from a point prompt, using cache if available with thread safety""" + # First check cache without lock for performance point_key = tuple(point_coords) - - # Check if we already have a mask for this point - if point_key in self.cache[self.current_image_path]['masks']: + if (self.current_image_path and + self.current_image_path in self.cache and + point_key in self.cache[self.current_image_path]['masks']): return self.cache[self.current_image_path]['masks'][point_key] - # Generate new mask - point_coords_array = np.array([point_coords]) - if point_labels is None: - point_labels = np.array([1]) # 1 indicates a foreground point - - masks, scores, _ = self.predictor.predict( - point_coords=point_coords_array, - point_labels=point_labels, - multimask_output=True - ) - best_mask_idx = np.argmax(scores) - mask = masks[best_mask_idx].astype(np.uint8) * 255 # Convert to 8-bit mask - - # Cache the result - self.cache[self.current_image_path]['masks'][point_key] = mask - - return mask + with self._lock: + if self.current_image_path is None: + raise ValueError("No image set for segmentation. Call set_image() first.") + + # Ensure we have the correct image set in the predictor + if self.current_image_path not in self.cache: + raise ValueError(f"Image {self.current_image_path} not found in cache. Call set_image() first.") + + # Re-set the image if it's not the current one (safety check) + # This ensures the predictor has the correct embeddings + if hasattr(self, '_last_set_image') and self._last_set_image != self.current_image_path: + logger.debug(f"Re-setting image in predictor for thread safety: {Path(self.current_image_path).name}") + image = cv2.imread(self.current_image_path) + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + with torch.no_grad(): + self.predictor.set_image(image) + + self._last_set_image = self.current_image_path + # Double-check cache (in case another thread added it) + if point_key in self.cache[self.current_image_path]['masks']: + logger.debug(f"Using cached mask for point {point_coords} (added by another thread)") + return self.cache[self.current_image_path]['masks'][point_key] + + logger.debug(f"Generating new mask for point {point_coords} on {self.device}") + + try: + # Generate new mask with performance optimizations + point_coords_array = np.array([point_coords]) + if point_labels is None: + point_labels = np.array([1]) # 1 indicates a foreground point + + # Ensure inputs are the right data type for GPU + point_coords_array = point_coords_array.astype(np.float32) + point_labels = point_labels.astype(np.int32) + + # Use GPU optimization if available + with torch.no_grad(): # Disable gradient computation for faster inference + masks, scores, _ = self.predictor.predict( + point_coords=point_coords_array, + point_labels=point_labels, + multimask_output=True + ) + + best_mask_idx = np.argmax(scores) + mask = masks[best_mask_idx].astype(np.uint8) * 255 # Convert to 8-bit mask + + logger.debug(f"Mask generated successfully (confidence: {scores[best_mask_idx]:.3f})") + + # Cache the result + self.cache[self.current_image_path]['masks'][point_key] = mask + + return mask + + except Exception as e: + logger.error(f"Error generating mask: {e}") + # Clear CUDA cache if error occurs + if torch.cuda.is_available(): + torch.cuda.empty_cache() + raise def mask_to_polygon(self, mask): """Convert binary mask to polygon coordinates (normalized 0-1)""" diff --git a/app/utils/test_utils.py b/app/utils/test_utils.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/websocket_notify.py b/app/websocket_notify.py deleted file mode 100644 index edad9d0..0000000 --- a/app/websocket_notify.py +++ /dev/null @@ -1,61 +0,0 @@ -from fastapi import WebSocket, WebSocketDisconnect, APIRouter -from typing import Dict, List -import asyncio -import logging - -router = APIRouter() -logger = logging.getLogger("websocket_notify") - -class ConnectionManager: - def __init__(self): - self.active_connections: Dict[str, WebSocket] = {} - self.pending_messages: Dict[str, List[str]] = {} - - async def connect(self, session_id: str, websocket: WebSocket): - await websocket.accept() - self.active_connections[session_id] = websocket - logger.info(f"WebSocket connected for session {session_id}") - - # Send any pending messages for this session - if session_id in self.pending_messages: - for message in self.pending_messages[session_id]: - try: - await websocket.send_text(message) - logger.info(f"Sent pending message to {session_id}: {message}") - except Exception as e: - logger.error(f"Failed to send pending message: {e}") - # Clear pending messages after sending - del self.pending_messages[session_id] - - def disconnect(self, session_id: str): - if session_id in self.active_connections: - del self.active_connections[session_id] - logger.info(f"WebSocket disconnected for session {session_id}") - - async def send_message(self, session_id: str, message: str): - websocket = self.active_connections.get(session_id) - if websocket: - try: - await websocket.send_text(message) - logger.info(f"Sent message to {session_id}: {message}") - except Exception as e: - logger.error(f"Failed to send message to {session_id}: {e}") - # Remove connection if it's broken - self.disconnect(session_id) - else: - # Store message for when WebSocket connects - if session_id not in self.pending_messages: - self.pending_messages[session_id] = [] - self.pending_messages[session_id].append(message) - logger.info(f"Stored pending message for {session_id}: {message}") - -manager = ConnectionManager() - -@router.websocket("/ws/notify/{session_id}") -async def websocket_endpoint(websocket: WebSocket, session_id: str): - await manager.connect(session_id, websocket) - try: - while True: - await websocket.receive_text() # Keep connection alive - except WebSocketDisconnect: - manager.disconnect(session_id) diff --git a/build.bat b/build.bat deleted file mode 100644 index b04e882..0000000 --- a/build.bat +++ /dev/null @@ -1,23 +0,0 @@ -@echo off -echo Building SAT Annotator Frontend... -echo. - -echo Checking for web directory... -if not exist "web" ( - echo Error: web directory not found! - pause - exit /b 1 -) - -cd web - -echo Frontend files are ready! -echo. -echo To run the application: -echo 1. Start the backend: uvicorn app.main:app --reload -echo 2. Visit: http://localhost:8000 -echo. -echo The frontend will be served automatically by FastAPI. -echo. - -pause diff --git a/build.sh b/build.sh deleted file mode 100644 index bdfab74..0000000 --- a/build.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -echo "Building SAT Annotator Frontend..." -echo - -echo "Checking for web directory..." -if [ ! -d "web" ]; then - echo "Error: web directory not found!" - exit 1 -fi - -cd web - -echo "Frontend files are ready!" -echo -echo "To run the application:" -echo "1. Start the backend: uvicorn app.main:app --reload" -echo "2. Visit: http://localhost:8000" -echo -echo "The frontend will be served automatically by FastAPI." -echo diff --git a/docker-compose.yml b/docker-compose.yml index 2615494..766f04a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,20 +13,15 @@ services: - ENVIRONMENT=development - SESSION_SECRET=your_session_secret_key_here command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] - frontend: build: context: . dockerfile: Dockerfile.web ports: - - "5173:5173" + - "8080:8080" volumes: - ./web:/web - - /web/node_modules working_dir: /web - environment: - - VITE_API_PROXY_TARGET=http://backend:8000 - - VITE_API_URL=/api depends_on: - backend diff --git a/docs/ERD.png b/docs/ERD.png deleted file mode 100644 index b9bcce5..0000000 Binary files a/docs/ERD.png and /dev/null differ diff --git a/scripts/db/01-init-schema.sql b/scripts/db/01-init-schema.sql deleted file mode 100644 index 7348e37..0000000 --- a/scripts/db/01-init-schema.sql +++ /dev/null @@ -1,40 +0,0 @@ -CREATE TABLE images ( - image_id SERIAL PRIMARY KEY, - file_name VARCHAR(255) NOT NULL, - file_path TEXT NOT NULL, - resolution VARCHAR(50), -- e.g., "1920x1080" - capture_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - source VARCHAR(100), -- e.g., "Sentinel-2, Landsat-8" - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE labels ( - label_id SERIAL PRIMARY KEY, - name VARCHAR(100) UNIQUE NOT NULL, - description TEXT -); - -CREATE TABLE ai_models ( - model_id SERIAL PRIMARY KEY, - model_name VARCHAR(255) NOT NULL, - version VARCHAR(50), - trained_on TEXT, - accuracy FLOAT CHECK (accuracy BETWEEN 0 AND 1), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE annotation_files ( - annotation_id SERIAL PRIMARY KEY, - image_id INT REFERENCES images(image_id) ON DELETE CASCADE, - file_path TEXT NOT NULL, -- Path to the CSV file - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - auto_generated BOOLEAN DEFAULT FALSE, - model_id INT REFERENCES ai_models(model_id) ON DELETE SET NULL -); - --- Insert some default labels -INSERT INTO labels (name, description) VALUES -('building', 'Man-made structures'), -('road', 'Transportation routes'), -('vegetation', 'Trees, grass, and other plant life'), -('water', 'Lakes, rivers, ponds, and oceans'); \ No newline at end of file diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 40965d1..0000000 --- a/web/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# SAT Annotator Frontend - -A responsive web frontend for the Satellite Image Annotation Tool built with vanilla HTML, CSS, and JavaScript. - -## Features - -- **Responsive Design**: Works on desktop, tablet, and mobile devices -- **Image Upload**: Drag & drop or click to upload satellite images (JPG, PNG, TIFF, GeoTIFF) -- **AI-Powered Segmentation**: Click on image to generate AI segmentation using SAM model -- **Manual Annotation**: Draw polygons manually for precise annotation -- **Smart Label System**: Select from predefined labels or add custom labels -- **Annotation Management**: Edit labels, delete annotations, view annotation list -- **Export Functionality**: Export annotations in JSON format -- **Zoom and Pan**: Interactive canvas with zoom and pan controls -- **Keyboard Shortcuts**: Efficient workflow with keyboard shortcuts -- **Session Management**: Manages uploaded images and annotations in session - -## Tools - -1. **Select Tool (1)**: Select and manipulate existing annotations -2. **AI Point Tool (2)**: Click to generate AI segmentation at point -3. **Polygon Tool (3)**: Draw manual polygon annotations -4. **Pan Tool (4)**: Pan around the image - -## Label System - -The application includes a smart label system with: -- **Predefined Labels**: Building, Road, Vegetation, Water, Parking -- **Current Label Display**: Shows which label will be applied to new annotations -- **Custom Labels**: Add your own labels and remove them when not needed -- **Automatic Assignment**: New annotations automatically get the currently selected label -- **Easy Editing**: Click edit on any annotation to change its label - -## Keyboard Shortcuts - -- `1` - Select tool -- `2` - AI Point tool -- `3` - Polygon tool -- `4` - Pan tool -- `ESC` - Cancel drawing / Clear selection -- `DELETE` - Delete selected annotation -- `+` - Zoom in -- `-` - Zoom out -- `0` - Fit to screen - -## Usage - -1. **Upload Image**: Drag and drop or click the upload area to add satellite images -2. **Select Label**: Choose the label you want to apply to new annotations from the Labels section -3. **Select Tool**: Choose appropriate tool from the toolbar -4. **Annotate**: - - For AI segmentation: Select AI Point tool and click on features - - For manual annotation: Select Polygon tool and click to draw points - - All new annotations will automatically use the currently selected label -5. **Manage Labels**: - - Click any label button to make it active - - Add custom labels using the input field - - Remove custom labels by clicking the X button (default labels cannot be removed) -6. **Edit Existing**: Right-click any annotation to edit its label or delete it -7. **Export**: Click Export JSON to download annotations in JSON format - -## File Structure - -``` -web/ -├── index.html # Main HTML file -├── styles.css # All CSS styles -├── js/ -│ ├── app.js # Main application controller -│ ├── api.js # API communication layer -│ ├── canvas.js # Canvas management -│ ├── annotations.js # Annotation management -│ └── utils.js # Utility functions -└── package.json # Project metadata -``` - -## Browser Compatibility - -- Chrome 90+ -- Firefox 88+ -- Safari 14+ -- Edge 90+ - -## Development - -To run locally: - -```bash -cd web -python -m http.server 8080 -``` - -Then visit `http://localhost:8080` - -## JSON Export Format - -Annotations are exported in the following JSON structure: - -```json -{ - "image": { - "id": "image_id", - "filename": "image.tif", - "resolution": "1024x768", - "width": 1024, - "height": 768 - }, - "annotations": [ - { - "id": "annotation_id", - "type": "ai_segment|manual_polygon", - "label": "building", - "polygon": [[x1,y1], [x2,y2], ...], - "source": "ai|manual", - "created": "2025-06-02T...", - "points_count": 5 - } - ], - "export_info": { - "format": "json", - "exported_at": "2025-06-02T...", - "tool": "SAT Annotator", - "version": "1.0.0" - }, - "statistics": { - "total_annotations": 10, - "ai_generated": 7, - "manual": 3, - "unique_labels": 4 - } -} -``` - -## Responsive Design - -The interface adapts to different screen sizes: - -- **Desktop**: Full sidebar and canvas layout -- **Tablet**: Optimized for touch interaction -- **Mobile**: Collapsible sidebar, touch-friendly controls - -## Performance - -- Optimized canvas rendering with device pixel ratio support -- Efficient polygon hit testing for annotation selection -- Debounced resize and scroll events -- Progressive image loading diff --git a/web/canvas-debug.html b/web/canvas-debug.html deleted file mode 100644 index a06e9f1..0000000 --- a/web/canvas-debug.html +++ /dev/null @@ -1,155 +0,0 @@ - - -
- - -Check browser console for real-time logs...
New & Improved Label System!
-No more popup dialogs! The label system has been completely redesigned for a seamless annotation experience. Choose your labels before annotating and watch the magic happen!
-Choose your label first, then create annotations. No interruptions, no popups - just smooth workflow.
-Add your own labels instantly with the input field. Remove them just as easily when you're done.
-The prominent current label display shows exactly which label will be applied to new annotations.
-Right-click any annotation to edit its label or delete it. Simple prompts replace confusing dialogs.
-Same as before - drag & drop or click to upload satellite images
-NEW: Select the label you want from the Labels section in the sidebar
-Use AI Point tool or Polygon tool - your chosen label is automatically applied!
-Type in the custom label input and click + to add project-specific labels
-Right-click any annotation to quickly change its label or delete it
-Experience the improved labeling system that makes satellite image annotation faster and more intuitive.
- Launch SAT Annotator - Documentation -Welcome to the Satellite Image Annotation Tool frontend demo!
- -The frontend has been successfully built and is ready to use!
-uvicorn app.main:app --reloadSupports JPG, PNG, TIFF, and GeoTIFF formats. Drag & drop or click to browse.
-Click the AI Point tool and click on image features to generate automatic segmentation using SAM model.
-Draw precise polygon annotations manually with the polygon tool.
-Works perfectly on desktop, tablet, and mobile devices with touch support.
-Export all annotations in a comprehensive JSON format with metadata and statistics.
-
-{
- "image": {
- "id": "1",
- "filename": "satellite.tif",
- "resolution": "1024x768",
- "width": 1024,
- "height": 768
- },
- "annotations": [
- {
- "id": "ann_1",
- "type": "ai_segment",
- "label": "building",
- "polygon": [[0.1,0.1], [0.2,0.1], [0.2,0.2], [0.1,0.2]],
- "source": "ai",
- "created": "2025-06-02T...",
- "points_count": 4
- }
- ],
- "export_info": {
- "format": "json",
- "exported_at": "2025-06-02T...",
- "tool": "SAT Annotator",
- "version": "1.0.0"
- },
- "statistics": {
- "total_annotations": 5,
- "ai_generated": 3,
- "manual": 2,
- "unique_labels": 3
- }
-}
-
- On mobile devices, the interface adapts with:
-Tested and optimized for:
-Upload a satellite image to start annotating
-✨ Polygon editing now enabled! ✨
-