AurevinP commited on
Commit
d13d7e1
·
verified ·
1 Parent(s): 21c4e2c

Upload the api endpoint and app.

Browse files
Files changed (8) hide show
  1. Dockerfile +29 -0
  2. README.md +149 -12
  3. app.py +98 -0
  4. depth_texture_mask.py +163 -0
  5. requirements.txt +11 -0
  6. static/app.js +229 -0
  7. static/styles.css +145 -0
  8. 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
- title: Structura AI
3
- emoji: 🚀
4
- colorFrom: gray
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: 'Structura AI: Depth & Structural Masking '
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>