Spaces:
Sleeping
Sleeping
Upload the api endpoint and app.
Browse files- Dockerfile +29 -0
- README.md +149 -12
- app.py +98 -0
- depth_texture_mask.py +163 -0
- requirements.txt +11 -0
- static/app.js +229 -0
- static/styles.css +145 -0
- templates/index.html +103 -0
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set environment variables
|
| 5 |
+
ENV PYTHONUNBUFFERED 1
|
| 6 |
+
# Set the port Uvicorn will listen on
|
| 7 |
+
ENV PORT 8000
|
| 8 |
+
|
| 9 |
+
# Set the working directory in the container
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Install system dependencies needed by some Python packages (e.g., OpenCV)
|
| 13 |
+
RUN apt-get update && \
|
| 14 |
+
apt-get install -y --no-install-recommends \
|
| 15 |
+
build-essential \
|
| 16 |
+
libglib2.0-0 \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
COPY requirements.txt /app/requirements.txt
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
COPY . /app
|
| 22 |
+
|
| 23 |
+
RUN python -c "import depth_texture_mask; depth_texture_mask.init_midas()"
|
| 24 |
+
|
| 25 |
+
# Expose the port
|
| 26 |
+
EXPOSE ${PORT}
|
| 27 |
+
|
| 28 |
+
# Run using uvicorn
|
| 29 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
README.md
CHANGED
|
@@ -1,12 +1,149 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Structura AI: Depth & Structural Masking Web Application
|
| 2 |
+
|
| 3 |
+
Structura AI is a web-based image analysis tool that generates a structural mask by combining monocular depth estimation and texture-based feature detection.
|
| 4 |
+
The system integrates MiDaS (DPT-Hybrid) for depth prediction with OpenCV-based edge, corner, and texture fusion to produce a detailed, high-frequency structural representation of any input image.
|
| 5 |
+
|
| 6 |
+
This mask is useful for segmentation workflows, diffusion model conditioning, preprocessing for generative AI, architectural analysis, and general computer vision pipelines.
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## Features
|
| 11 |
+
|
| 12 |
+
* MiDaS Depth Estimation: High-quality depth prediction using the DPT-Hybrid model.
|
| 13 |
+
* Combined Structural Masking: Fusion of depth gradients, edges (Canny), Laplacian detail, and Harris corner responses.
|
| 14 |
+
* FastAPI Backend: Clean API endpoint (`POST /mask/`) returning PNG masks with inference time metadata.
|
| 15 |
+
* Web UI: A simple and modern interface built with HTML, CSS, and JavaScript allowing image upload, preview, overlay visualization, and mask download.
|
| 16 |
+
* Docker Support: Fully dockerized and ready for deployment on services such as Hugging Face Spaces.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## Demo
|
| 21 |
+
|
| 22 |
+
This section is reserved for demo images and demo videos.
|
| 23 |
+
Add examples such as:
|
| 24 |
+
|
| 25 |
+
* Original input image
|
| 26 |
+
* Generated structural mask
|
| 27 |
+
* Overlay visualization
|
| 28 |
+
* A short screen recording demonstrating the UI
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Technology Stack
|
| 33 |
+
|
| 34 |
+
Backend: FastAPI, Uvicorn
|
| 35 |
+
AI / Computer Vision: PyTorch, MiDaS, OpenCV (headless), NumPy
|
| 36 |
+
Frontend: HTML, CSS, JavaScript
|
| 37 |
+
Deployment: Docker, Hugging Face Spaces
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## Repository Structure
|
| 42 |
+
|
| 43 |
+
```
|
| 44 |
+
structura-ai/
|
| 45 |
+
├── app.py
|
| 46 |
+
├── depth_texture_mask.py
|
| 47 |
+
├── requirements.txt
|
| 48 |
+
├── Dockerfile
|
| 49 |
+
├── templates/
|
| 50 |
+
│ └── index.html
|
| 51 |
+
└── static/
|
| 52 |
+
├── styles.css
|
| 53 |
+
├── app.js
|
| 54 |
+
└── logo.svg
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## Local Installation and Usage
|
| 60 |
+
|
| 61 |
+
### 1. Clone the Repository
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
git clone https://github.com/PritamTheCoder/midas-depth-texture-mask-api.git
|
| 65 |
+
cd midas-depth-texture-mask-api
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
### 2. Create and Activate a Virtual Environment
|
| 69 |
+
|
| 70 |
+
```
|
| 71 |
+
python -m venv venv
|
| 72 |
+
|
| 73 |
+
# Windows
|
| 74 |
+
venv\Scripts\activate
|
| 75 |
+
|
| 76 |
+
# macOS / Linux
|
| 77 |
+
source venv/bin/activate
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
### 3. Install Dependencies
|
| 81 |
+
|
| 82 |
+
```
|
| 83 |
+
pip install -r requirements.txt
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### 4. Start the Application
|
| 87 |
+
|
| 88 |
+
```
|
| 89 |
+
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### 5. Access the Web UI
|
| 93 |
+
|
| 94 |
+
Open the browser and navigate to:
|
| 95 |
+
[http://127.0.0.1:8000/](http://127.0.0.1:8000/)
|
| 96 |
+
|
| 97 |
+
The MiDaS model weights will automatically download on the first startup.
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## Deployment on Hugging Face Spaces (Docker)
|
| 102 |
+
|
| 103 |
+
The project includes a Dockerfile configured for seamless deployment on Hugging Face Spaces.
|
| 104 |
+
|
| 105 |
+
### Required File Structure
|
| 106 |
+
|
| 107 |
+
Ensure the following files are present at the repository root:
|
| 108 |
+
|
| 109 |
+
```
|
| 110 |
+
Dockerfile
|
| 111 |
+
app.py
|
| 112 |
+
depth_texture_mask.py
|
| 113 |
+
requirements.txt
|
| 114 |
+
templates/
|
| 115 |
+
static/
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### Deployment Steps
|
| 119 |
+
|
| 120 |
+
1. Create a new Hugging Face Space.
|
| 121 |
+
2. Select "Docker" as the runtime.
|
| 122 |
+
3. Upload or commit all repository files.
|
| 123 |
+
4. Hugging Face will automatically:
|
| 124 |
+
|
| 125 |
+
* Build the Docker image
|
| 126 |
+
* Install dependencies
|
| 127 |
+
* Expose the correct port
|
| 128 |
+
* Start the FastAPI server
|
| 129 |
+
|
| 130 |
+
The application will then be available as a hosted interactive web demo.
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
## License
|
| 135 |
+
|
| 136 |
+
This project is licensed under the MIT License.
|
| 137 |
+
See the LICENSE file for details.
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## Acknowledgements
|
| 142 |
+
|
| 143 |
+
MiDaS by Intel-ISL
|
| 144 |
+
FastAPI for backend framework
|
| 145 |
+
OpenCV for feature detection and image processing
|
| 146 |
+
PyTorch for deep learning inference support
|
| 147 |
+
Hugging Face for deployment infrastructure
|
| 148 |
+
|
| 149 |
+
---
|
app.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
import io
|
| 3 |
+
import logging
|
| 4 |
+
import traceback
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException, Request
|
| 8 |
+
from fastapi.responses import StreamingResponse, HTMLResponse
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
from fastapi.templating import Jinja2Templates
|
| 11 |
+
from starlette.responses import RedirectResponse
|
| 12 |
+
from PIL import Image
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
import depth_texture_mask # make sure depth_texture_mask.py is at repo root
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("uvicorn.error")
|
| 18 |
+
app = FastAPI(title="Depth & Structural Masking API with UI")
|
| 19 |
+
|
| 20 |
+
# Mount static folder for CSS/JS/images
|
| 21 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 22 |
+
templates = Jinja2Templates(directory="templates")
|
| 23 |
+
|
| 24 |
+
@app.on_event("startup")
|
| 25 |
+
async def startup_event():
|
| 26 |
+
try:
|
| 27 |
+
logger.info("Initializing MiDaS model...")
|
| 28 |
+
# This will initialize the heavy model once
|
| 29 |
+
depth_texture_mask.init_midas()
|
| 30 |
+
logger.info("MiDaS initialized.")
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.exception("Error initializing MiDaS: %s", e)
|
| 33 |
+
|
| 34 |
+
@app.get("/", response_class=HTMLResponse)
|
| 35 |
+
async def index(request: Request):
|
| 36 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 37 |
+
|
| 38 |
+
def pil_image_from_uploadfile(upload_file: UploadFile) -> Image.Image:
|
| 39 |
+
contents = upload_file.file.read()
|
| 40 |
+
img = Image.open(io.BytesIO(contents)).convert("RGB")
|
| 41 |
+
upload_file.file.close()
|
| 42 |
+
return img
|
| 43 |
+
|
| 44 |
+
def numpy_from_pil(pil_img: Image.Image) -> np.ndarray:
|
| 45 |
+
return np.asarray(pil_img)
|
| 46 |
+
|
| 47 |
+
def pil_from_mask_array(mask: np.ndarray) -> Image.Image:
|
| 48 |
+
arr = mask.copy()
|
| 49 |
+
if np.issubdtype(arr.dtype, np.floating):
|
| 50 |
+
if arr.max() <= 1.0:
|
| 51 |
+
arr = (arr * 255.0).astype("uint8")
|
| 52 |
+
else:
|
| 53 |
+
arr = np.clip(arr, 0, 255).astype("uint8")
|
| 54 |
+
else:
|
| 55 |
+
arr = np.clip(arr, 0, 255).astype("uint8")
|
| 56 |
+
if arr.ndim == 3 and arr.shape[2] == 3:
|
| 57 |
+
arr = (0.2989 * arr[...,0] + 0.5870 * arr[...,1] + 0.1140 * arr[...,2]).astype("uint8")
|
| 58 |
+
return Image.fromarray(arr, mode="L")
|
| 59 |
+
|
| 60 |
+
@app.post("/mask/", response_class=StreamingResponse)
|
| 61 |
+
async def generate_mask_endpoint(file: UploadFile = File(...)):
|
| 62 |
+
"""
|
| 63 |
+
Accept an image file and return a PNG mask.
|
| 64 |
+
Adds a response header 'X-Inference-Time-ms' with inference time in milliseconds.
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
if not file.content_type.startswith("image/"):
|
| 68 |
+
raise HTTPException(status_code=415, detail="Unsupported file type.")
|
| 69 |
+
# read + convert
|
| 70 |
+
pil_img = pil_image_from_uploadfile(file)
|
| 71 |
+
input_np = numpy_from_pil(pil_img)
|
| 72 |
+
|
| 73 |
+
# Call model & measure time
|
| 74 |
+
start = time.perf_counter()
|
| 75 |
+
mask = depth_texture_mask.generate_texture_depth_mask(input_np, mask_only=True)
|
| 76 |
+
end = time.perf_counter()
|
| 77 |
+
infer_ms = int((end - start) * 1000)
|
| 78 |
+
|
| 79 |
+
if mask is None:
|
| 80 |
+
raise HTTPException(status_code=500, detail="Mask generation failed.")
|
| 81 |
+
|
| 82 |
+
mask_pil = pil_from_mask_array(mask)
|
| 83 |
+
buf = io.BytesIO()
|
| 84 |
+
mask_pil.save(buf, format="PNG")
|
| 85 |
+
buf.seek(0)
|
| 86 |
+
|
| 87 |
+
headers = {"X-Inference-Time-ms": str(infer_ms)}
|
| 88 |
+
return StreamingResponse(buf, media_type="image/png", headers=headers)
|
| 89 |
+
except HTTPException:
|
| 90 |
+
raise
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error("Error in /mask/: %s", e)
|
| 93 |
+
logger.debug(traceback.format_exc())
|
| 94 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 95 |
+
|
| 96 |
+
@app.get("/ui")
|
| 97 |
+
async def redirect_ui():
|
| 98 |
+
return RedirectResponse("/")
|
depth_texture_mask.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# depth_texture_mask.py
|
| 2 |
+
# Modified: lazy MiDaS init and safe for server use.
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import cv2
|
| 6 |
+
import torch
|
| 7 |
+
import numpy as np
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
|
| 10 |
+
# Globals (initialized by init_midas)
|
| 11 |
+
midas = None
|
| 12 |
+
midas_transforms = None
|
| 13 |
+
transform = None
|
| 14 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 15 |
+
_midas_initialized = False
|
| 16 |
+
|
| 17 |
+
def init_midas(model_name="DPT_Hybrid", device_override=None, force_reload=False):
|
| 18 |
+
"""
|
| 19 |
+
Initialize/load the MiDaS model and transforms into global variables.
|
| 20 |
+
Call this once (e.g., at FastAPI startup).
|
| 21 |
+
"""
|
| 22 |
+
global midas, midas_transforms, transform, device, _midas_initialized
|
| 23 |
+
|
| 24 |
+
if device_override is not None:
|
| 25 |
+
device = device_override
|
| 26 |
+
else:
|
| 27 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 28 |
+
|
| 29 |
+
if _midas_initialized and not force_reload:
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
# Use torch.hub to load MiDaS transforms & model
|
| 33 |
+
# NOTE: this will download if not cached
|
| 34 |
+
midas = torch.hub.load("intel-isl/MiDaS", model_name, pretrained=True)
|
| 35 |
+
midas_transforms = torch.hub.load("intel-isl/MiDaS", "transforms")
|
| 36 |
+
# choose the appropriate transform (DPT / midas small has different names)
|
| 37 |
+
if hasattr(midas_transforms, "dpt_transform"):
|
| 38 |
+
transform = midas_transforms.dpt_transform
|
| 39 |
+
elif hasattr(midas_transforms, "small_transform"):
|
| 40 |
+
transform = midas_transforms.small_transform
|
| 41 |
+
else:
|
| 42 |
+
# fallback: try a generic 'transform'
|
| 43 |
+
transform = getattr(midas_transforms, "transform", None)
|
| 44 |
+
|
| 45 |
+
midas.to(device).eval()
|
| 46 |
+
_midas_initialized = True
|
| 47 |
+
return
|
| 48 |
+
|
| 49 |
+
def _ensure_initialized():
|
| 50 |
+
if not _midas_initialized:
|
| 51 |
+
init_midas()
|
| 52 |
+
|
| 53 |
+
def generate_texture_depth_mask(input_data, mask_only=False):
|
| 54 |
+
"""
|
| 55 |
+
Generate a texture + depth structural mask.
|
| 56 |
+
|
| 57 |
+
Supports:
|
| 58 |
+
- File paths (.jpg, .png)
|
| 59 |
+
- NumPy arrays (H,W,C) RGB or RGBA
|
| 60 |
+
- List of inputs (batch mode)
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
mask_only=False:
|
| 64 |
+
- Single: (fig, mask)
|
| 65 |
+
- Batch: list of (fig, mask)
|
| 66 |
+
|
| 67 |
+
mask_only=True:
|
| 68 |
+
- Single: mask
|
| 69 |
+
- Batch: list of masks
|
| 70 |
+
"""
|
| 71 |
+
_ensure_initialized()
|
| 72 |
+
|
| 73 |
+
def _process_single(image_source):
|
| 74 |
+
# Load image (array or file path)
|
| 75 |
+
if isinstance(image_source, np.ndarray):
|
| 76 |
+
img_rgb = image_source
|
| 77 |
+
if img_rgb.shape[-1] == 4:
|
| 78 |
+
img_rgb = img_rgb[:, :, :3]
|
| 79 |
+
img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
|
| 80 |
+
elif isinstance(image_source, str) and os.path.isfile(image_source):
|
| 81 |
+
img_bgr = cv2.imread(image_source)
|
| 82 |
+
if img_bgr is None:
|
| 83 |
+
raise ValueError(f"Could not read {image_source}")
|
| 84 |
+
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
| 85 |
+
else:
|
| 86 |
+
raise TypeError("Input must be a file path or NumPy image array.")
|
| 87 |
+
|
| 88 |
+
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
|
| 89 |
+
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
|
| 90 |
+
|
| 91 |
+
# Depth (MiDaS)
|
| 92 |
+
t = transform(img_rgb).to(device)
|
| 93 |
+
if t.ndim == 3:
|
| 94 |
+
t = t.unsqueeze(0)
|
| 95 |
+
|
| 96 |
+
with torch.no_grad():
|
| 97 |
+
depth = midas(t)
|
| 98 |
+
depth = torch.nn.functional.interpolate(
|
| 99 |
+
depth.unsqueeze(1),
|
| 100 |
+
size=gray.shape,
|
| 101 |
+
mode="bicubic",
|
| 102 |
+
align_corners=False
|
| 103 |
+
).squeeze()
|
| 104 |
+
|
| 105 |
+
depth = depth.cpu().numpy()
|
| 106 |
+
depth = cv2.normalize(depth, None, 0, 255, cv2.NORM_MINMAX)
|
| 107 |
+
depth_mask = cv2.convertScaleAbs(255 - depth)
|
| 108 |
+
|
| 109 |
+
# Texture features
|
| 110 |
+
canny = cv2.Canny(blurred, 40, 120)
|
| 111 |
+
lap = cv2.convertScaleAbs(cv2.Laplacian(blurred, cv2.CV_64F))
|
| 112 |
+
|
| 113 |
+
corners = cv2.cornerHarris(np.float32(blurred), 2, 3, 0.04)
|
| 114 |
+
corners = cv2.dilate(corners, None)
|
| 115 |
+
corner_mask = np.zeros_like(gray)
|
| 116 |
+
corner_mask[corners > 0.01 * corners.max()] = 255
|
| 117 |
+
|
| 118 |
+
edges_all = cv2.addWeighted(canny, 0.6, lap, 0.4, 0)
|
| 119 |
+
mask = cv2.bitwise_or(edges_all, corner_mask)
|
| 120 |
+
mask = cv2.addWeighted(mask, 0.8, depth_mask, 0.2, 0)
|
| 121 |
+
|
| 122 |
+
noise = np.random.randint(0, 60, gray.shape, dtype=np.uint8)
|
| 123 |
+
mask = cv2.addWeighted(mask, 1.0, noise, 0.2, 0)
|
| 124 |
+
mask = cv2.convertScaleAbs(mask)
|
| 125 |
+
|
| 126 |
+
if mask_only:
|
| 127 |
+
return mask
|
| 128 |
+
|
| 129 |
+
# Visualization mode
|
| 130 |
+
fig, ax = plt.subplots(1, 2, figsize=(14, 6))
|
| 131 |
+
ax[0].imshow(img_rgb)
|
| 132 |
+
ax[0].set_title("Original Image")
|
| 133 |
+
ax[0].axis("off")
|
| 134 |
+
|
| 135 |
+
ax[1].imshow(mask, cmap="gray")
|
| 136 |
+
ax[1].set_title("Texture + Depth Structural Mask")
|
| 137 |
+
ax[1].axis("off")
|
| 138 |
+
|
| 139 |
+
plt.tight_layout()
|
| 140 |
+
return fig, mask
|
| 141 |
+
|
| 142 |
+
# Batch support
|
| 143 |
+
if isinstance(input_data, list):
|
| 144 |
+
return [_process_single(item) for item in input_data]
|
| 145 |
+
|
| 146 |
+
return _process_single(input_data)
|
| 147 |
+
|
| 148 |
+
# CLI entrypoint preserved for local use
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
import argparse
|
| 151 |
+
parser = argparse.ArgumentParser()
|
| 152 |
+
parser.add_argument("--input", type=str, required=True)
|
| 153 |
+
parser.add_argument("--save", type=str, default="./mask_img.png")
|
| 154 |
+
parser.add_argument("--mask_only", action="store_true")
|
| 155 |
+
args = parser.parse_args()
|
| 156 |
+
output = generate_texture_depth_mask(args.input, mask_only=args.mask_only)
|
| 157 |
+
if args.mask_only:
|
| 158 |
+
mask = output
|
| 159 |
+
else:
|
| 160 |
+
fig, mask = output
|
| 161 |
+
cv2.imwrite(args.save, mask)
|
| 162 |
+
|
| 163 |
+
print(f"[OK] Saved mask to {args.save}")
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.95.2
|
| 2 |
+
uvicorn[standard]==0.22.0
|
| 3 |
+
jinja2==3.1.2
|
| 4 |
+
Pillow==10.0.1
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
torch==2.3.1
|
| 7 |
+
torchvision==0.18.1
|
| 8 |
+
opencv-python-headless==4.8.1.78
|
| 9 |
+
timm==0.9.2
|
| 10 |
+
matplotlib==3.8.1
|
| 11 |
+
python-multipart==0.0.6
|
static/app.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// static/app.js
|
| 2 |
+
(() => {
|
| 3 |
+
// DOM Elements
|
| 4 |
+
const els = {
|
| 5 |
+
fileInput: document.getElementById('fileInput'),
|
| 6 |
+
dropZone: document.getElementById('dropZone'),
|
| 7 |
+
settingsGroup: document.getElementById('settingsGroup'),
|
| 8 |
+
processBtn: document.getElementById('processBtn'),
|
| 9 |
+
downloadBtn: document.getElementById('downloadBtn'),
|
| 10 |
+
canvasContainer: document.getElementById('canvasContainer'),
|
| 11 |
+
mainCanvas: document.getElementById('mainCanvas'),
|
| 12 |
+
emptyState: document.getElementById('emptyState'),
|
| 13 |
+
opacityInput: document.getElementById('opacity'),
|
| 14 |
+
opacityVal: document.getElementById('opacityVal'),
|
| 15 |
+
colorInput: document.getElementById('color'),
|
| 16 |
+
loader: document.getElementById('loader'),
|
| 17 |
+
loadingStep: document.getElementById('loadingStep'),
|
| 18 |
+
timeInfo: document.getElementById('timeInfo'),
|
| 19 |
+
resInfo: document.getElementById('resInfo'),
|
| 20 |
+
statusIndicator: document.getElementById('statusIndicator'),
|
| 21 |
+
tabs: document.querySelectorAll('.tab')
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
// State
|
| 25 |
+
let state = {
|
| 26 |
+
originalImg: null,
|
| 27 |
+
maskImg: null,
|
| 28 |
+
currentView: 'original',
|
| 29 |
+
isProcessing: false
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
// --- Logic ---
|
| 33 |
+
|
| 34 |
+
// Switch between Empty State and Canvas State (Fixes the UI Bug)
|
| 35 |
+
function toggleView(hasImage) {
|
| 36 |
+
if (hasImage) {
|
| 37 |
+
els.emptyState.classList.add('hidden');
|
| 38 |
+
els.canvasContainer.classList.remove('hidden');
|
| 39 |
+
els.settingsGroup.classList.remove('disabled');
|
| 40 |
+
els.settingsGroup.classList.add('active');
|
| 41 |
+
} else {
|
| 42 |
+
els.emptyState.classList.remove('hidden');
|
| 43 |
+
els.canvasContainer.classList.add('hidden');
|
| 44 |
+
els.settingsGroup.classList.add('disabled');
|
| 45 |
+
els.settingsGroup.classList.remove('active');
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Improved Rendering Engine
|
| 50 |
+
function renderCanvas() {
|
| 51 |
+
if (!state.originalImg) return;
|
| 52 |
+
|
| 53 |
+
const ctx = els.mainCanvas.getContext('2d');
|
| 54 |
+
const w = state.originalImg.width;
|
| 55 |
+
const h = state.originalImg.height;
|
| 56 |
+
|
| 57 |
+
// Resize canvas to match image resolution
|
| 58 |
+
if (els.mainCanvas.width !== w || els.mainCanvas.height !== h) {
|
| 59 |
+
els.mainCanvas.width = w;
|
| 60 |
+
els.mainCanvas.height = h;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
ctx.clearRect(0, 0, w, h);
|
| 64 |
+
|
| 65 |
+
// 1. Draw Background (Original Image)
|
| 66 |
+
if (state.currentView === 'original' || state.currentView === 'overlay') {
|
| 67 |
+
ctx.drawImage(state.originalImg, 0, 0);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// 2. Draw Mask/Overlay
|
| 71 |
+
if (state.maskImg) {
|
| 72 |
+
if (state.currentView === 'mask') {
|
| 73 |
+
// Plain mask view
|
| 74 |
+
ctx.globalCompositeOperation = 'source-over';
|
| 75 |
+
ctx.globalAlpha = 1.0;
|
| 76 |
+
ctx.drawImage(state.maskImg, 0, 0);
|
| 77 |
+
}
|
| 78 |
+
else if (state.currentView === 'overlay') {
|
| 79 |
+
// Sophisticated Overlay
|
| 80 |
+
// Prepare offscreen buffer for tinted mask
|
| 81 |
+
const offscreen = document.createElement('canvas');
|
| 82 |
+
offscreen.width = w; offscreen.height = h;
|
| 83 |
+
const oCtx = offscreen.getContext('2d');
|
| 84 |
+
|
| 85 |
+
// A. Draw mask (White=Structure, Black=Empty)
|
| 86 |
+
oCtx.drawImage(state.maskImg, 0, 0);
|
| 87 |
+
|
| 88 |
+
// B. Tint: Fill with color only where mask exists (Source-In)
|
| 89 |
+
oCtx.globalCompositeOperation = 'source-in';
|
| 90 |
+
oCtx.fillStyle = els.colorInput.value;
|
| 91 |
+
oCtx.fillRect(0, 0, w, h);
|
| 92 |
+
|
| 93 |
+
// C. Draw onto main canvas with opacity
|
| 94 |
+
// 'source-over' blends normally on top of the original image
|
| 95 |
+
ctx.globalCompositeOperation = 'source-over';
|
| 96 |
+
ctx.globalAlpha = parseInt(els.opacityInput.value) / 100;
|
| 97 |
+
ctx.drawImage(offscreen, 0, 0);
|
| 98 |
+
|
| 99 |
+
// Reset context
|
| 100 |
+
ctx.globalAlpha = 1.0;
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
async function handleFile(file) {
|
| 106 |
+
if (!file || !file.type.startsWith('image/')) return alert("Invalid image.");
|
| 107 |
+
|
| 108 |
+
setLoading(true, "Loading...");
|
| 109 |
+
try {
|
| 110 |
+
const url = URL.createObjectURL(file);
|
| 111 |
+
state.originalImg = await loadImage(url);
|
| 112 |
+
state.maskImg = null;
|
| 113 |
+
state.currentView = 'original';
|
| 114 |
+
|
| 115 |
+
els.resInfo.textContent = `${state.originalImg.width} × ${state.originalImg.height} px`;
|
| 116 |
+
els.timeInfo.textContent = "—";
|
| 117 |
+
|
| 118 |
+
toggleView(true);
|
| 119 |
+
updateTabs();
|
| 120 |
+
renderCanvas();
|
| 121 |
+
|
| 122 |
+
els.processBtn.disabled = false;
|
| 123 |
+
els.downloadBtn.disabled = true;
|
| 124 |
+
} catch (e) {
|
| 125 |
+
console.error(e);
|
| 126 |
+
alert("Error loading image.");
|
| 127 |
+
toggleView(false);
|
| 128 |
+
} finally {
|
| 129 |
+
setLoading(false);
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
async function generateMask() {
|
| 134 |
+
if (!state.originalImg) return;
|
| 135 |
+
setLoading(true, "Analyzing Structure...");
|
| 136 |
+
|
| 137 |
+
try {
|
| 138 |
+
const formData = new FormData();
|
| 139 |
+
formData.append('file', els.fileInput.files[0]);
|
| 140 |
+
|
| 141 |
+
const resp = await fetch('/mask/', { method: 'POST', body: formData });
|
| 142 |
+
if (!resp.ok) throw new Error("Server Error");
|
| 143 |
+
|
| 144 |
+
els.timeInfo.textContent = (resp.headers.get('X-Inference-Time-ms') || '—') + ' ms';
|
| 145 |
+
|
| 146 |
+
const blob = await resp.blob();
|
| 147 |
+
state.maskImg = await loadImage(URL.createObjectURL(blob));
|
| 148 |
+
|
| 149 |
+
state.currentView = 'overlay';
|
| 150 |
+
updateTabs();
|
| 151 |
+
renderCanvas();
|
| 152 |
+
|
| 153 |
+
els.downloadBtn.disabled = false;
|
| 154 |
+
} catch (e) {
|
| 155 |
+
console.error(e);
|
| 156 |
+
alert("Analysis failed.");
|
| 157 |
+
} finally {
|
| 158 |
+
setLoading(false);
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// --- Helpers ---
|
| 163 |
+
function loadImage(src) {
|
| 164 |
+
return new Promise((resolve, reject) => {
|
| 165 |
+
const img = new Image();
|
| 166 |
+
img.onload = () => resolve(img);
|
| 167 |
+
img.onerror = reject;
|
| 168 |
+
img.src = src;
|
| 169 |
+
});
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function setLoading(active, text) {
|
| 173 |
+
state.isProcessing = active;
|
| 174 |
+
if (active) {
|
| 175 |
+
els.loader.classList.remove('hidden');
|
| 176 |
+
els.loadingStep.textContent = text;
|
| 177 |
+
els.statusIndicator.textContent = "Busy";
|
| 178 |
+
els.statusIndicator.className = "status-indicator working";
|
| 179 |
+
} else {
|
| 180 |
+
els.loader.classList.add('hidden');
|
| 181 |
+
els.statusIndicator.textContent = "Ready";
|
| 182 |
+
els.statusIndicator.className = "status-indicator";
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function updateTabs() {
|
| 187 |
+
els.tabs.forEach(t => {
|
| 188 |
+
if(t.dataset.view === state.currentView) t.classList.add('active');
|
| 189 |
+
else t.classList.remove('active');
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// --- Listeners ---
|
| 194 |
+
els.fileInput.addEventListener('change', e => handleFile(e.target.files[0]));
|
| 195 |
+
|
| 196 |
+
els.dropZone.addEventListener('dragover', e => { e.preventDefault(); els.dropZone.style.borderColor = 'var(--primary)'; });
|
| 197 |
+
els.dropZone.addEventListener('dragleave', e => { e.preventDefault(); els.dropZone.style.borderColor = 'var(--border)'; });
|
| 198 |
+
els.dropZone.addEventListener('drop', e => {
|
| 199 |
+
e.preventDefault();
|
| 200 |
+
els.dropZone.style.borderColor = 'var(--border)';
|
| 201 |
+
const f = e.dataTransfer.files[0];
|
| 202 |
+
els.fileInput.files = e.dataTransfer.files;
|
| 203 |
+
handleFile(f);
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
els.processBtn.addEventListener('click', generateMask);
|
| 207 |
+
els.opacityInput.addEventListener('input', e => {
|
| 208 |
+
els.opacityVal.textContent = e.target.value + '%';
|
| 209 |
+
renderCanvas();
|
| 210 |
+
});
|
| 211 |
+
els.colorInput.addEventListener('input', renderCanvas);
|
| 212 |
+
|
| 213 |
+
els.tabs.forEach(t => t.addEventListener('click', () => {
|
| 214 |
+
if(t.dataset.view !== 'original' && !state.maskImg) return;
|
| 215 |
+
state.currentView = t.dataset.view;
|
| 216 |
+
updateTabs();
|
| 217 |
+
renderCanvas();
|
| 218 |
+
}));
|
| 219 |
+
|
| 220 |
+
els.downloadBtn.addEventListener('click', () => {
|
| 221 |
+
const link = document.createElement('a');
|
| 222 |
+
link.download = `structura_result_${Date.now()}.png`;
|
| 223 |
+
link.href = els.mainCanvas.toDataURL('image/png');
|
| 224 |
+
link.click();
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
// Init state
|
| 228 |
+
toggleView(false);
|
| 229 |
+
})();
|
static/styles.css
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* static/styles.css */
|
| 2 |
+
:root {
|
| 3 |
+
--bg-body: #0b0f19; /* Darker, cleaner background */
|
| 4 |
+
--bg-sidebar: #111827; /* Rich dark gray */
|
| 5 |
+
--bg-card: #1f2937;
|
| 6 |
+
--border: #374151;
|
| 7 |
+
--text-main: #f9fafb;
|
| 8 |
+
--text-muted: #9ca3af;
|
| 9 |
+
--primary: #3b82f6; /* Intel Blue */
|
| 10 |
+
--primary-hover: #2563eb;
|
| 11 |
+
--radius: 6px; /* Tighter radius for 'Pro' look */
|
| 12 |
+
--sidebar-w: 300px;
|
| 13 |
+
--font-sans: 'Inter', sans-serif;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
* { box-sizing: border-box; }
|
| 17 |
+
|
| 18 |
+
body {
|
| 19 |
+
margin: 0;
|
| 20 |
+
height: 100vh;
|
| 21 |
+
font-family: var(--font-sans);
|
| 22 |
+
background-color: var(--bg-body);
|
| 23 |
+
color: var(--text-main);
|
| 24 |
+
overflow: hidden;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* === UTILITY TO FIX BUG === */
|
| 28 |
+
.hidden { display: none !important; }
|
| 29 |
+
|
| 30 |
+
/* Layout Shell */
|
| 31 |
+
.app-shell { display: flex; height: 100%; width: 100%; }
|
| 32 |
+
|
| 33 |
+
/* Sidebar */
|
| 34 |
+
.sidebar {
|
| 35 |
+
width: var(--sidebar-w);
|
| 36 |
+
background-color: var(--bg-sidebar);
|
| 37 |
+
border-right: 1px solid var(--border);
|
| 38 |
+
display: flex;
|
| 39 |
+
flex-direction: column;
|
| 40 |
+
padding: 24px;
|
| 41 |
+
flex-shrink: 0;
|
| 42 |
+
z-index: 20;
|
| 43 |
+
box-shadow: 4px 0 24px rgba(0,0,0,0.2);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.logo-area {
|
| 47 |
+
font-size: 20px; font-weight: 700; letter-spacing: -0.5px;
|
| 48 |
+
margin-bottom: 40px; display: flex; align-items: center; gap: 12px;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.section-label {
|
| 52 |
+
display: block; font-size: 11px; text-transform: uppercase;
|
| 53 |
+
letter-spacing: 1px; color: var(--text-muted); margin-bottom: 10px; font-weight: 600;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.control-group { margin-bottom: 24px; }
|
| 57 |
+
.control-group.disabled { opacity: 0.5; pointer-events: none; }
|
| 58 |
+
.control-group.active { opacity: 1; pointer-events: auto; }
|
| 59 |
+
|
| 60 |
+
/* Upload Box */
|
| 61 |
+
.upload-box {
|
| 62 |
+
border: 1px dashed var(--border);
|
| 63 |
+
border-radius: var(--radius);
|
| 64 |
+
padding: 24px; text-align: center; cursor: pointer; position: relative;
|
| 65 |
+
background: rgba(255,255,255,0.02); transition: all 0.2s;
|
| 66 |
+
}
|
| 67 |
+
.upload-box:hover { border-color: var(--primary); background: rgba(59, 130, 246, 0.05); }
|
| 68 |
+
.upload-box input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
| 69 |
+
.upload-icon { font-size: 24px; display: block; margin-bottom: 8px; color: var(--text-muted); }
|
| 70 |
+
.upload-box p { margin: 0; font-size: 13px; color: var(--text-muted); }
|
| 71 |
+
|
| 72 |
+
/* Settings */
|
| 73 |
+
.setting-item { margin-bottom: 16px; }
|
| 74 |
+
.setting-item label { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 8px; color: var(--text-muted); }
|
| 75 |
+
input[type="range"] { width: 100%; accent-color: var(--primary); }
|
| 76 |
+
input[type="color"] { width: 100%; height: 36px; border: none; padding: 0; background: none; cursor: pointer; }
|
| 77 |
+
|
| 78 |
+
.divider { height: 1px; background: var(--border); margin: 20px 0; }
|
| 79 |
+
|
| 80 |
+
/* Buttons */
|
| 81 |
+
.btn {
|
| 82 |
+
width: 100%; padding: 10px 16px; border-radius: var(--radius);
|
| 83 |
+
border: none; font-family: var(--font-sans); font-size: 13px; font-weight: 500;
|
| 84 |
+
cursor: pointer; margin-bottom: 10px; transition: background 0.2s;
|
| 85 |
+
}
|
| 86 |
+
.btn-primary { background: var(--primary); color: white; }
|
| 87 |
+
.btn-primary:hover { background: var(--primary-hover); }
|
| 88 |
+
.btn-secondary { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
|
| 89 |
+
.btn-secondary:hover { border-color: var(--text-main); color: var(--text-main); }
|
| 90 |
+
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
| 91 |
+
|
| 92 |
+
.sidebar-footer { margin-top: auto; font-size: 12px; color: var(--text-muted); }
|
| 93 |
+
.stat-row { display: flex; justify-content: space-between; margin-bottom: 6px; }
|
| 94 |
+
.mono { font-family: monospace; color: var(--text-main); }
|
| 95 |
+
|
| 96 |
+
/* Workspace */
|
| 97 |
+
.workspace { flex: 1; display: flex; flex-direction: column; background-color: #000; position: relative; }
|
| 98 |
+
|
| 99 |
+
.toolbar {
|
| 100 |
+
height: 56px; border-bottom: 1px solid var(--border);
|
| 101 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 102 |
+
padding: 0 24px; background: var(--bg-body);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.tabs { display: flex; gap: 4px; background: var(--bg-sidebar); padding: 4px; border-radius: 6px; }
|
| 106 |
+
.tab {
|
| 107 |
+
background: transparent; border: none; color: var(--text-muted);
|
| 108 |
+
padding: 6px 12px; font-size: 13px; border-radius: 4px; cursor: pointer;
|
| 109 |
+
}
|
| 110 |
+
.tab.active { background: var(--bg-card); color: var(--text-main); font-weight: 500; }
|
| 111 |
+
|
| 112 |
+
.status-indicator { font-size: 12px; color: var(--text-muted); }
|
| 113 |
+
.status-indicator.working { color: var(--primary); }
|
| 114 |
+
|
| 115 |
+
.viewport {
|
| 116 |
+
flex: 1; display: flex; align-items: center; justify-content: center;
|
| 117 |
+
position: relative; overflow: hidden; padding: 20px;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.empty-state {
|
| 121 |
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
| 122 |
+
text-align: center; color: var(--text-muted); width: 100%; height: 100%;
|
| 123 |
+
}
|
| 124 |
+
.empty-icon { font-size: 48px; opacity: 0.3; margin-bottom: 16px; }
|
| 125 |
+
|
| 126 |
+
.canvas-container {
|
| 127 |
+
box-shadow: 0 0 0 1px #222, 0 20px 50px rgba(0,0,0,0.5);
|
| 128 |
+
max-width: 95%; max-height: 95%; display: flex;
|
| 129 |
+
}
|
| 130 |
+
canvas { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
|
| 131 |
+
|
| 132 |
+
/* Loader */
|
| 133 |
+
.loader-overlay {
|
| 134 |
+
position: absolute; inset: 0; background: rgba(11, 15, 25, 0.8);
|
| 135 |
+
backdrop-filter: blur(4px); display: flex; flex-direction: column;
|
| 136 |
+
align-items: center; justify-content: center; z-index: 50;
|
| 137 |
+
}
|
| 138 |
+
.spinner {
|
| 139 |
+
width: 40px; height: 40px; border: 3px solid rgba(255,255,255,0.1);
|
| 140 |
+
border-radius: 50%; border-top-color: var(--primary);
|
| 141 |
+
animation: spin 1s linear infinite; margin-bottom: 16px;
|
| 142 |
+
}
|
| 143 |
+
.loading-text { font-weight: 500; font-size: 14px; }
|
| 144 |
+
|
| 145 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
templates/index.html
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"/>
|
| 6 |
+
<title>Structura AI | Professional Analysis</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="stylesheet" href="/static/styles.css">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
|
| 14 |
+
<div class="app-shell">
|
| 15 |
+
<aside class="sidebar">
|
| 16 |
+
<div class="logo-area">
|
| 17 |
+
<div class="logo-icon">
|
| 18 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 19 |
+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
| 20 |
+
</svg>
|
| 21 |
+
</div>
|
| 22 |
+
<span>Structura</span>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div class="control-group">
|
| 26 |
+
<label class="section-label">Input Source</label>
|
| 27 |
+
<div id="dropZone" class="upload-box">
|
| 28 |
+
<input id="fileInput" type="file" accept="image/*" />
|
| 29 |
+
<span class="upload-icon">↑</span>
|
| 30 |
+
<p>Click or Drop Image</p>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div class="control-group disabled" id="settingsGroup">
|
| 35 |
+
<label class="section-label">Visualization</label>
|
| 36 |
+
|
| 37 |
+
<div class="setting-item">
|
| 38 |
+
<label>Mask Tint</label>
|
| 39 |
+
<input id="color" type="color" value="#3b82f6" />
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div class="setting-item">
|
| 43 |
+
<label>Overlay Opacity <span id="opacityVal">60%</span></label>
|
| 44 |
+
<input id="opacity" type="range" min="0" max="100" value="60" />
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div class="divider"></div>
|
| 48 |
+
|
| 49 |
+
<button id="processBtn" class="btn btn-primary full-width">
|
| 50 |
+
<span>Generate Structure</span>
|
| 51 |
+
</button>
|
| 52 |
+
|
| 53 |
+
<button id="downloadBtn" class="btn btn-secondary full-width" disabled>
|
| 54 |
+
Download Result
|
| 55 |
+
</button>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div class="sidebar-footer">
|
| 59 |
+
<div class="stat-row">
|
| 60 |
+
<span>Inference</span>
|
| 61 |
+
<span id="timeInfo" class="mono">—</span>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="stat-row">
|
| 64 |
+
<span>Resolution</span>
|
| 65 |
+
<span id="resInfo" class="mono">—</span>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</aside>
|
| 69 |
+
|
| 70 |
+
<main class="workspace">
|
| 71 |
+
<header class="toolbar">
|
| 72 |
+
<div class="tabs">
|
| 73 |
+
<button class="tab active" data-view="overlay">Composite</button>
|
| 74 |
+
<button class="tab" data-view="mask">Mask Only</button>
|
| 75 |
+
<button class="tab" data-view="original">Original</button>
|
| 76 |
+
</div>
|
| 77 |
+
<div class="status-indicator" id="statusIndicator">Ready</div>
|
| 78 |
+
</header>
|
| 79 |
+
|
| 80 |
+
<div class="viewport">
|
| 81 |
+
<div id="emptyState" class="empty-state">
|
| 82 |
+
<div class="empty-icon">⬚</div>
|
| 83 |
+
<h3>No Image Loaded</h3>
|
| 84 |
+
<p>Upload an image to begin analysis.</p>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div id="canvasContainer" class="canvas-container hidden">
|
| 88 |
+
<canvas id="mainCanvas"></canvas>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div id="loader" class="loader-overlay hidden">
|
| 92 |
+
<div class="spinner"></div>
|
| 93 |
+
<div class="loading-text">
|
| 94 |
+
<span id="loadingStep">Processing...</span>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</main>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<script src="/static/app.js"></script>
|
| 102 |
+
</body>
|
| 103 |
+
</html>
|