cjgaspari commited on
Commit
6fe70f4
Β·
1 Parent(s): c20d7cc

Added web ui;

Browse files

Added start.command for one-click setup; Updated readme.md for one-click setup;
Added images of web-ui;
Added new web viewer after ply is generated;
Cleaned up project by moving styles and js into seperate files;

README.md CHANGED
@@ -89,6 +89,21 @@ If you find our work useful, please cite the following paper:
89
 
90
  Our codebase is built using multiple opensource contributions, please see [ACKNOWLEDGEMENTS](ACKNOWLEDGEMENTS) for more details.
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  ## License
93
 
94
  Please check out the repository [LICENSE](LICENSE) before using the provided code and
 
89
 
90
  Our codebase is built using multiple opensource contributions, please see [ACKNOWLEDGEMENTS](ACKNOWLEDGEMENTS) for more details.
91
 
92
+ ## Web Interface (One-Click Setup)
93
+
94
+ For a simple web interface where you can upload images and download 3D Gaussians:
95
+
96
+ **macOS:** Double-click `start.command` in Finder.
97
+
98
+ This will automatically:
99
+ - Create the conda environment if it doesn't exist
100
+ - Install all dependencies if needed
101
+ - Start the web server at http://localhost:8000
102
+
103
+ ![](data/web-dark.png)
104
+ ![](data/web-light.png)
105
+
106
+
107
  ## License
108
 
109
  Please check out the repository [LICENSE](LICENSE) before using the provided code and
src/sharp/web/README.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sharp Web Interface
2
+
3
+ This is a web interface for the Sharp 3D prediction model.
4
+
5
+ ## Prerequisites
6
+
7
+ Make sure you have the `sharp` package installed (see root README).
8
+ Install the web dependencies:
9
+
10
+ ```bash
11
+ pip install -r requirements.txt
12
+ ```
13
+
14
+ ## Running the Server
15
+
16
+ Run the following command from the `web` directory:
17
+
18
+ ```bash
19
+ python app.py
20
+ ```
21
+
22
+ Or using uvicorn directly:
23
+
24
+ ```bash
25
+ uvicorn app:app --reload --host 0.0.0.0 --port 8000
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ 1. Open your browser and navigate to `http://localhost:8000`.
31
+ 2. Drag and drop images or click to select them.
32
+ 3. Click "Predict 3D Gaussians".
33
+ 4. A zip file containing the resulting `.ply` files will be downloaded automatically.
src/sharp/web/app.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+ import logging
4
+ import shutil
5
+ import tempfile
6
+ import zipfile
7
+ import io as python_io
8
+ import base64
9
+
10
+ from fastapi import FastAPI, Request, UploadFile, File
11
+ from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+ from fastapi.templating import Jinja2Templates
14
+ import torch
15
+ import numpy as np
16
+
17
+ # Add src to path so we can import sharp
18
+ sys.path.append(str(Path(__file__).parent.parent / "src"))
19
+
20
+ from sharp.models import (
21
+ PredictorParams,
22
+ RGBGaussianPredictor,
23
+ create_predictor,
24
+ )
25
+ from sharp.utils import io as sharp_io
26
+ from sharp.utils.gaussians import save_ply
27
+ from sharp.cli.predict import predict_image, DEFAULT_MODEL_URL
28
+
29
+ # Configure logging
30
+ logging.basicConfig(level=logging.INFO)
31
+ LOGGER = logging.getLogger(__name__)
32
+
33
+ app = FastAPI()
34
+
35
+ # Mount static files if needed (we created the dir)
36
+ app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
37
+
38
+ templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
39
+
40
+ # Global variables for the model
41
+ predictor: RGBGaussianPredictor = None
42
+ device: torch.device = None
43
+
44
+ @app.on_event("startup")
45
+ async def startup_event():
46
+ global predictor, device
47
+
48
+ # Determine device
49
+ if torch.cuda.is_available():
50
+ device_str = "cuda"
51
+ elif torch.mps.is_available():
52
+ device_str = "mps"
53
+ else:
54
+ device_str = "cpu"
55
+
56
+ device = torch.device(device_str)
57
+ LOGGER.info(f"Using device: {device}")
58
+
59
+ # Load model
60
+ LOGGER.info("Loading model...")
61
+ try:
62
+ # Try to load from cache or download
63
+ state_dict = torch.hub.load_state_dict_from_url(DEFAULT_MODEL_URL, progress=True, map_location=device)
64
+
65
+ predictor = create_predictor(PredictorParams())
66
+ predictor.load_state_dict(state_dict)
67
+ predictor.eval()
68
+ predictor.to(device)
69
+ LOGGER.info("Model loaded successfully.")
70
+ except Exception as e:
71
+ LOGGER.error(f"Failed to load model: {e}")
72
+ raise e
73
+
74
+ @app.get("/", response_class=HTMLResponse)
75
+ async def read_root(request: Request):
76
+ return templates.TemplateResponse("index.html", {"request": request})
77
+
78
+ @app.post("/predict")
79
+ async def predict(files: list[UploadFile] = File(...)):
80
+ """Process images and return PLY data for viewing or download."""
81
+ if not predictor:
82
+ return JSONResponse({"error": "Model not loaded"}, status_code=500)
83
+
84
+ # Create a temporary directory to process files
85
+ with tempfile.TemporaryDirectory() as temp_dir:
86
+ temp_path = Path(temp_dir)
87
+ results = []
88
+
89
+ for file in files:
90
+ try:
91
+ # Save uploaded file
92
+ file_path = temp_path / file.filename
93
+ with open(file_path, "wb") as buffer:
94
+ shutil.copyfileobj(file.file, buffer)
95
+
96
+ LOGGER.info(f"Processing {file.filename}")
97
+
98
+ # Load image using sharp's IO to get focal length and handle rotation
99
+ image, _, f_px = sharp_io.load_rgb(file_path)
100
+
101
+ # Run prediction
102
+ gaussians = predict_image(predictor, image, f_px, device)
103
+
104
+ # Save PLY
105
+ ply_filename = f"{file_path.stem}.ply"
106
+ ply_path = temp_path / ply_filename
107
+
108
+ height, width = image.shape[:2]
109
+ save_ply(gaussians, f_px, (height, width), ply_path)
110
+
111
+ # Read PLY file and encode as base64
112
+ with open(ply_path, "rb") as f:
113
+ ply_data = base64.b64encode(f.read()).decode("utf-8")
114
+
115
+ results.append({
116
+ "filename": file.filename,
117
+ "ply_filename": ply_filename,
118
+ "ply_data": ply_data,
119
+ "width": width,
120
+ "height": height,
121
+ "focal_length": f_px,
122
+ })
123
+
124
+ except Exception as e:
125
+ LOGGER.error(f"Error processing {file.filename}: {e}")
126
+ results.append({
127
+ "filename": file.filename,
128
+ "error": str(e),
129
+ })
130
+
131
+ return JSONResponse({"results": results})
132
+
133
+
134
+ @app.post("/predict/download")
135
+ async def predict_download(files: list[UploadFile] = File(...)):
136
+ """Process images and return a ZIP file for download."""
137
+ if not predictor:
138
+ return HTMLResponse("Model not loaded", status_code=500)
139
+
140
+ # Create a temporary directory to process files
141
+ with tempfile.TemporaryDirectory() as temp_dir:
142
+ temp_path = Path(temp_dir)
143
+ output_zip = python_io.BytesIO()
144
+
145
+ with zipfile.ZipFile(output_zip, "w") as zf:
146
+ for file in files:
147
+ try:
148
+ # Save uploaded file
149
+ file_path = temp_path / file.filename
150
+ with open(file_path, "wb") as buffer:
151
+ shutil.copyfileobj(file.file, buffer)
152
+
153
+ LOGGER.info(f"Processing {file.filename}")
154
+
155
+ # Load image using sharp's IO to get focal length and handle rotation
156
+ image, _, f_px = sharp_io.load_rgb(file_path)
157
+
158
+ # Run prediction
159
+ gaussians = predict_image(predictor, image, f_px, device)
160
+
161
+ # Save PLY
162
+ ply_filename = f"{file_path.stem}.ply"
163
+ ply_path = temp_path / ply_filename
164
+
165
+ height, width = image.shape[:2]
166
+ save_ply(gaussians, f_px, (height, width), ply_path)
167
+
168
+ # Add to zip
169
+ zf.write(ply_path, ply_filename)
170
+
171
+ except Exception as e:
172
+ LOGGER.error(f"Error processing {file.filename}: {e}")
173
+ continue
174
+
175
+ output_zip.seek(0)
176
+ return StreamingResponse(
177
+ output_zip,
178
+ media_type="application/zip",
179
+ headers={"Content-Disposition": "attachment; filename=gaussians.zip"}
180
+ )
181
+
182
+ if __name__ == "__main__":
183
+ import uvicorn
184
+ uvicorn.run(app, host="0.0.0.0", port=8000)
src/sharp/web/requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ jinja2
src/sharp/web/static/css/styles.css ADDED
@@ -0,0 +1,724 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Dark mode (default) */
3
+ --bg-primary: #0a0a0f;
4
+ --bg-secondary: #1a1a2e;
5
+ --bg-tertiary: #0f0f1a;
6
+ --text-primary: #ffffff;
7
+ --text-secondary: rgba(255, 255, 255, 0.9);
8
+ --text-muted: rgba(255, 255, 255, 0.6);
9
+ --text-dim: rgba(255, 255, 255, 0.4);
10
+ --text-dimmer: rgba(255, 255, 255, 0.3);
11
+ --card-bg: rgba(255, 255, 255, 0.03);
12
+ --card-border: rgba(255, 255, 255, 0.08);
13
+ --card-highlight: rgba(255, 255, 255, 0.05);
14
+ --upload-bg: rgba(255, 255, 255, 0.02);
15
+ --upload-border: rgba(255, 255, 255, 0.15);
16
+ --file-item-bg: rgba(255, 255, 255, 0.05);
17
+ --loader-bg: rgba(255, 255, 255, 0.03);
18
+ --particle-connection: rgba(255, 255, 255, 0.02);
19
+ --glow-color: rgba(99, 102, 241, 0.1);
20
+ --shadow-color: rgba(0, 0, 0, 0.3);
21
+ }
22
+
23
+ [data-theme="light"] {
24
+ --bg-primary: #f8fafc;
25
+ --bg-secondary: #e2e8f0;
26
+ --bg-tertiary: #f1f5f9;
27
+ --text-primary: #0f172a;
28
+ --text-secondary: #1e293b;
29
+ --text-muted: #64748b;
30
+ --text-dim: #94a3b8;
31
+ --text-dimmer: #cbd5e1;
32
+ --card-bg: rgba(255, 255, 255, 0.8);
33
+ --card-border: rgba(0, 0, 0, 0.08);
34
+ --card-highlight: rgba(255, 255, 255, 0.5);
35
+ --upload-bg: rgba(99, 102, 241, 0.02);
36
+ --upload-border: rgba(99, 102, 241, 0.2);
37
+ --file-item-bg: rgba(99, 102, 241, 0.05);
38
+ --loader-bg: rgba(99, 102, 241, 0.05);
39
+ --particle-connection: rgba(99, 102, 241, 0.08);
40
+ --glow-color: rgba(99, 102, 241, 0.15);
41
+ --shadow-color: rgba(99, 102, 241, 0.1);
42
+ }
43
+
44
+ * {
45
+ margin: 0;
46
+ padding: 0;
47
+ box-sizing: border-box;
48
+ }
49
+
50
+ body {
51
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
52
+ min-height: 100vh;
53
+ background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 50%, var(--bg-tertiary) 100%);
54
+ color: var(--text-primary);
55
+ overflow-x: hidden;
56
+ transition: background 0.3s ease, color 0.3s ease;
57
+ }
58
+
59
+ #particleCanvas {
60
+ position: fixed;
61
+ top: 0;
62
+ left: 0;
63
+ width: 100%;
64
+ height: 100%;
65
+ z-index: 0;
66
+ pointer-events: none;
67
+ }
68
+
69
+ .app-container {
70
+ position: relative;
71
+ z-index: 1;
72
+ min-height: 100vh;
73
+ display: flex;
74
+ flex-direction: column;
75
+ align-items: center;
76
+ justify-content: center;
77
+ padding: 2rem;
78
+ }
79
+
80
+ .header {
81
+ text-align: center;
82
+ margin-bottom: 2.5rem;
83
+ }
84
+
85
+ .logo {
86
+ font-size: 3rem;
87
+ font-weight: 700;
88
+ background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
89
+ -webkit-background-clip: text;
90
+ -webkit-text-fill-color: transparent;
91
+ background-clip: text;
92
+ margin-bottom: 0.5rem;
93
+ letter-spacing: -0.02em;
94
+ }
95
+
96
+ .tagline {
97
+ font-size: 1.1rem;
98
+ color: var(--text-muted);
99
+ font-weight: 400;
100
+ }
101
+
102
+ .theme-toggle {
103
+ position: fixed;
104
+ top: 1.5rem;
105
+ right: 1.5rem;
106
+ z-index: 100;
107
+ width: 48px;
108
+ height: 48px;
109
+ border-radius: 50%;
110
+ border: 1px solid var(--card-border);
111
+ background: var(--card-bg);
112
+ backdrop-filter: blur(10px);
113
+ -webkit-backdrop-filter: blur(10px);
114
+ cursor: pointer;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ transition: all 0.3s ease;
119
+ box-shadow: 0 4px 12px var(--shadow-color);
120
+ }
121
+
122
+ .theme-toggle:hover {
123
+ transform: scale(1.1);
124
+ border-color: #a855f7;
125
+ }
126
+
127
+ .theme-toggle svg {
128
+ width: 22px;
129
+ height: 22px;
130
+ transition: all 0.3s ease;
131
+ }
132
+
133
+ .theme-toggle .sun-icon {
134
+ stroke: #f59e0b;
135
+ display: none;
136
+ }
137
+
138
+ .theme-toggle .moon-icon {
139
+ stroke: var(--text-muted);
140
+ }
141
+
142
+ [data-theme="light"] .theme-toggle .sun-icon {
143
+ display: block;
144
+ }
145
+
146
+ [data-theme="light"] .theme-toggle .moon-icon {
147
+ display: none;
148
+ }
149
+
150
+ .card {
151
+ background: var(--card-bg);
152
+ backdrop-filter: blur(20px);
153
+ -webkit-backdrop-filter: blur(20px);
154
+ border: 1px solid var(--card-border);
155
+ border-radius: 24px;
156
+ padding: 2.5rem;
157
+ width: 100%;
158
+ max-width: 520px;
159
+ box-shadow:
160
+ 0 4px 24px var(--shadow-color),
161
+ 0 0 80px var(--glow-color),
162
+ inset 0 1px 0 var(--card-highlight);
163
+ transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
164
+ }
165
+
166
+ [data-theme="light"] .card {
167
+ background: rgba(255, 255, 255, 0.12);
168
+ border: 1px solid rgba(255, 255, 255, 0.3);
169
+ backdrop-filter: blur(16px);
170
+ -webkit-backdrop-filter: blur(16px);
171
+ box-shadow:
172
+ 0 8px 32px rgba(99, 102, 241, 0.1),
173
+ 0 0 60px rgba(99, 102, 241, 0.06),
174
+ 0 0 0 1px rgba(255, 255, 255, 0.3) inset;
175
+ }
176
+
177
+ .upload-zone {
178
+ border: 2px dashed var(--upload-border);
179
+ border-radius: 16px;
180
+ padding: 3rem 2rem;
181
+ text-align: center;
182
+ cursor: pointer;
183
+ transition: all 0.3s ease;
184
+ background: var(--upload-bg);
185
+ backdrop-filter: blur(10px);
186
+ -webkit-backdrop-filter: blur(10px);
187
+ position: relative;
188
+ overflow: hidden;
189
+ }
190
+
191
+ [data-theme="light"] .upload-zone {
192
+ background: rgba(255, 255, 255, 0.1);
193
+ border-color: rgba(99, 102, 241, 0.25);
194
+ box-shadow:
195
+ 0 4px 16px rgba(99, 102, 241, 0.08),
196
+ 0 0 0 1px rgba(255, 255, 255, 0.25) inset;
197
+ backdrop-filter: blur(12px);
198
+ -webkit-backdrop-filter: blur(12px);
199
+ }
200
+
201
+ [data-theme="light"] .upload-zone:hover {
202
+ background: rgba(255, 255, 255, 0.18);
203
+ border-color: rgba(99, 102, 241, 0.4);
204
+ box-shadow:
205
+ 0 8px 24px rgba(99, 102, 241, 0.12),
206
+ 0 0 0 1px rgba(255, 255, 255, 0.35) inset;
207
+ }
208
+
209
+ [data-theme="light"] .upload-zone.has-files {
210
+ background: rgba(34, 197, 94, 0.06);
211
+ border-color: rgba(34, 197, 94, 0.35);
212
+ box-shadow:
213
+ 0 4px 16px rgba(34, 197, 94, 0.08),
214
+ 0 0 0 1px rgba(255, 255, 255, 0.25) inset;
215
+ }
216
+
217
+ .upload-zone::before {
218
+ content: '';
219
+ position: absolute;
220
+ inset: 0;
221
+ background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(168, 85, 247, 0.1) 100%);
222
+ opacity: 0;
223
+ transition: opacity 0.3s ease;
224
+ }
225
+
226
+ .upload-zone:hover {
227
+ border-color: rgba(99, 102, 241, 0.5);
228
+ transform: translateY(-2px);
229
+ }
230
+
231
+ .upload-zone:hover::before {
232
+ opacity: 1;
233
+ }
234
+
235
+ .upload-zone.drag-over {
236
+ border-color: #a855f7;
237
+ background: rgba(168, 85, 247, 0.1);
238
+ }
239
+
240
+ .upload-zone.has-files {
241
+ border-color: rgba(34, 197, 94, 0.5);
242
+ background: rgba(34, 197, 94, 0.05);
243
+ }
244
+
245
+ .upload-icon {
246
+ width: 64px;
247
+ height: 64px;
248
+ margin: 0 auto 1.25rem;
249
+ position: relative;
250
+ z-index: 1;
251
+ }
252
+
253
+ .upload-icon svg {
254
+ width: 100%;
255
+ height: 100%;
256
+ stroke: var(--text-dim);
257
+ transition: stroke 0.3s ease;
258
+ }
259
+
260
+ .upload-zone:hover .upload-icon svg {
261
+ stroke: #a855f7;
262
+ }
263
+
264
+ .upload-text {
265
+ position: relative;
266
+ z-index: 1;
267
+ }
268
+
269
+ .upload-text h3 {
270
+ font-size: 1.1rem;
271
+ font-weight: 500;
272
+ margin-bottom: 0.5rem;
273
+ color: var(--text-secondary);
274
+ }
275
+
276
+ .upload-text p {
277
+ font-size: 0.875rem;
278
+ color: var(--text-dim);
279
+ }
280
+
281
+ .file-list {
282
+ margin-top: 1.5rem;
283
+ display: flex;
284
+ flex-direction: column;
285
+ gap: 0.5rem;
286
+ }
287
+
288
+ .file-item {
289
+ display: flex;
290
+ align-items: center;
291
+ gap: 0.75rem;
292
+ padding: 0.75rem 1rem;
293
+ background: var(--file-item-bg);
294
+ border-radius: 10px;
295
+ animation: slideIn 0.3s ease;
296
+ }
297
+
298
+ @keyframes slideIn {
299
+ from {
300
+ opacity: 0;
301
+ transform: translateX(-10px);
302
+ }
303
+
304
+ to {
305
+ opacity: 1;
306
+ transform: translateX(0);
307
+ }
308
+ }
309
+
310
+ .file-item .file-icon {
311
+ width: 36px;
312
+ height: 36px;
313
+ border-radius: 8px;
314
+ background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
315
+ display: flex;
316
+ align-items: center;
317
+ justify-content: center;
318
+ flex-shrink: 0;
319
+ }
320
+
321
+ .file-item .file-icon svg {
322
+ width: 18px;
323
+ height: 18px;
324
+ stroke: white;
325
+ }
326
+
327
+ .file-item .file-info {
328
+ flex: 1;
329
+ min-width: 0;
330
+ }
331
+
332
+ .file-item .file-name {
333
+ font-size: 0.875rem;
334
+ font-weight: 500;
335
+ color: var(--text-secondary);
336
+ white-space: nowrap;
337
+ overflow: hidden;
338
+ text-overflow: ellipsis;
339
+ }
340
+
341
+ .file-item .file-size {
342
+ font-size: 0.75rem;
343
+ color: var(--text-dim);
344
+ }
345
+
346
+ .file-item .file-preview {
347
+ width: 36px;
348
+ height: 36px;
349
+ border-radius: 8px;
350
+ object-fit: cover;
351
+ }
352
+
353
+ .submit-btn {
354
+ width: 100%;
355
+ margin-top: 1.5rem;
356
+ padding: 1rem 1.5rem;
357
+ font-size: 1rem;
358
+ font-weight: 600;
359
+ font-family: inherit;
360
+ color: white;
361
+ background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
362
+ border: none;
363
+ border-radius: 12px;
364
+ cursor: pointer;
365
+ transition: all 0.3s ease;
366
+ position: relative;
367
+ overflow: hidden;
368
+ }
369
+
370
+ .submit-btn::before {
371
+ content: '';
372
+ position: absolute;
373
+ inset: 0;
374
+ background: linear-gradient(135deg, #4f46e5 0%, #9333ea 50%, #db2777 100%);
375
+ opacity: 0;
376
+ transition: opacity 0.3s ease;
377
+ }
378
+
379
+ .submit-btn:hover:not(:disabled) {
380
+ transform: translateY(-2px);
381
+ box-shadow: 0 8px 30px rgba(99, 102, 241, 0.4);
382
+ }
383
+
384
+ .submit-btn:hover:not(:disabled)::before {
385
+ opacity: 1;
386
+ }
387
+
388
+ .submit-btn:disabled {
389
+ opacity: 0.5;
390
+ cursor: not-allowed;
391
+ transform: none;
392
+ }
393
+
394
+ .submit-btn span {
395
+ position: relative;
396
+ z-index: 1;
397
+ display: flex;
398
+ align-items: center;
399
+ justify-content: center;
400
+ gap: 0.5rem;
401
+ }
402
+
403
+ .loader-container {
404
+ display: none;
405
+ flex-direction: column;
406
+ align-items: center;
407
+ margin-top: 2rem;
408
+ padding: 2rem;
409
+ background: var(--loader-bg);
410
+ border-radius: 16px;
411
+ }
412
+
413
+ .loader-container.active {
414
+ display: flex;
415
+ }
416
+
417
+ .loader {
418
+ width: 48px;
419
+ height: 48px;
420
+ position: relative;
421
+ }
422
+
423
+ .loader::before,
424
+ .loader::after {
425
+ content: '';
426
+ position: absolute;
427
+ inset: 0;
428
+ border-radius: 50%;
429
+ border: 3px solid transparent;
430
+ }
431
+
432
+ .loader::before {
433
+ border-top-color: #6366f1;
434
+ animation: spin 1s linear infinite;
435
+ }
436
+
437
+ .loader::after {
438
+ border-right-color: #a855f7;
439
+ animation: spin 1.5s linear infinite reverse;
440
+ }
441
+
442
+ @keyframes spin {
443
+ to {
444
+ transform: rotate(360deg);
445
+ }
446
+ }
447
+
448
+ .loader-text {
449
+ margin-top: 1rem;
450
+ font-size: 0.875rem;
451
+ color: var(--text-muted);
452
+ }
453
+
454
+ .loader-subtext {
455
+ font-size: 0.75rem;
456
+ color: var(--text-dimmer);
457
+ margin-top: 0.25rem;
458
+ }
459
+
460
+ .results {
461
+ margin-top: 1.5rem;
462
+ }
463
+
464
+ .result-item {
465
+ display: flex;
466
+ align-items: center;
467
+ gap: 0.75rem;
468
+ padding: 1rem 1.25rem;
469
+ background: rgba(34, 197, 94, 0.1);
470
+ border: 1px solid rgba(34, 197, 94, 0.2);
471
+ border-radius: 12px;
472
+ animation: fadeIn 0.5s ease;
473
+ }
474
+
475
+ @keyframes fadeIn {
476
+ from {
477
+ opacity: 0;
478
+ transform: scale(0.95);
479
+ }
480
+
481
+ to {
482
+ opacity: 1;
483
+ transform: scale(1);
484
+ }
485
+ }
486
+
487
+ .result-item .success-icon {
488
+ width: 40px;
489
+ height: 40px;
490
+ background: rgba(34, 197, 94, 0.2);
491
+ border-radius: 50%;
492
+ display: flex;
493
+ align-items: center;
494
+ justify-content: center;
495
+ flex-shrink: 0;
496
+ }
497
+
498
+ .result-item .success-icon svg {
499
+ width: 20px;
500
+ height: 20px;
501
+ stroke: #22c55e;
502
+ }
503
+
504
+ .result-item .result-text {
505
+ flex: 1;
506
+ }
507
+
508
+ .result-item .result-title {
509
+ font-weight: 600;
510
+ color: #22c55e;
511
+ font-size: 0.9375rem;
512
+ }
513
+
514
+ .result-item .result-desc {
515
+ font-size: 0.8125rem;
516
+ color: var(--text-muted);
517
+ margin-top: 0.125rem;
518
+ }
519
+
520
+ .footer {
521
+ margin-top: 2rem;
522
+ text-align: center;
523
+ font-size: 0.8125rem;
524
+ color: var(--text-dimmer);
525
+ }
526
+
527
+ .footer a {
528
+ color: var(--text-muted);
529
+ text-decoration: none;
530
+ transition: color 0.2s ease;
531
+ }
532
+
533
+ .footer a:hover {
534
+ color: #a855f7;
535
+ }
536
+
537
+ /* Viewer styles */
538
+ .viewer-container {
539
+ display: none;
540
+ flex-direction: column;
541
+ width: 100%;
542
+ max-width: 900px;
543
+ margin-top: 1.5rem;
544
+ }
545
+
546
+ .viewer-container.active {
547
+ display: flex;
548
+ }
549
+
550
+ .viewer-header {
551
+ display: flex;
552
+ align-items: center;
553
+ justify-content: space-between;
554
+ padding: 1rem 1.25rem;
555
+ background: var(--card-bg);
556
+ border: 1px solid var(--card-border);
557
+ border-radius: 16px 16px 0 0;
558
+ backdrop-filter: blur(10px);
559
+ -webkit-backdrop-filter: blur(10px);
560
+ }
561
+
562
+ .viewer-title {
563
+ display: flex;
564
+ align-items: center;
565
+ gap: 0.75rem;
566
+ }
567
+
568
+ .viewer-title h3 {
569
+ font-size: 1rem;
570
+ font-weight: 600;
571
+ color: var(--text-secondary);
572
+ }
573
+
574
+ .viewer-title .file-badge {
575
+ font-size: 0.75rem;
576
+ padding: 0.25rem 0.75rem;
577
+ background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.2));
578
+ border-radius: 20px;
579
+ color: var(--text-muted);
580
+ }
581
+
582
+ .viewer-actions {
583
+ display: flex;
584
+ gap: 0.5rem;
585
+ }
586
+
587
+ .viewer-btn {
588
+ padding: 0.5rem 1rem;
589
+ font-size: 0.875rem;
590
+ font-weight: 500;
591
+ font-family: inherit;
592
+ color: var(--text-secondary);
593
+ background: var(--file-item-bg);
594
+ border: 1px solid var(--card-border);
595
+ border-radius: 8px;
596
+ cursor: pointer;
597
+ transition: all 0.2s ease;
598
+ display: flex;
599
+ align-items: center;
600
+ gap: 0.5rem;
601
+ }
602
+
603
+ .viewer-btn:hover {
604
+ background: rgba(99, 102, 241, 0.15);
605
+ border-color: rgba(99, 102, 241, 0.3);
606
+ }
607
+
608
+ .viewer-btn svg {
609
+ width: 16px;
610
+ height: 16px;
611
+ }
612
+
613
+ .viewer-btn.primary {
614
+ background: linear-gradient(135deg, #6366f1, #a855f7);
615
+ border: none;
616
+ color: white;
617
+ }
618
+
619
+ .viewer-btn.primary:hover {
620
+ transform: translateY(-1px);
621
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
622
+ }
623
+
624
+ .viewer-canvas-container {
625
+ position: relative;
626
+ width: 100%;
627
+ height: 500px;
628
+ background: #000;
629
+ border-left: 1px solid var(--card-border);
630
+ border-right: 1px solid var(--card-border);
631
+ overflow: hidden;
632
+ }
633
+
634
+ .viewer-canvas-container canvas {
635
+ width: 100% !important;
636
+ height: 100% !important;
637
+ }
638
+
639
+ .viewer-controls-hint {
640
+ position: absolute;
641
+ bottom: 1rem;
642
+ left: 50%;
643
+ transform: translateX(-50%);
644
+ padding: 0.5rem 1rem;
645
+ background: rgba(0, 0, 0, 0.7);
646
+ backdrop-filter: blur(10px);
647
+ border-radius: 8px;
648
+ font-size: 0.75rem;
649
+ color: rgba(255, 255, 255, 0.7);
650
+ pointer-events: none;
651
+ transition: opacity 0.3s ease;
652
+ }
653
+
654
+ .viewer-footer {
655
+ display: flex;
656
+ align-items: center;
657
+ justify-content: center;
658
+ padding: 0.75rem;
659
+ background: var(--card-bg);
660
+ border: 1px solid var(--card-border);
661
+ border-radius: 0 0 16px 16px;
662
+ backdrop-filter: blur(10px);
663
+ -webkit-backdrop-filter: blur(10px);
664
+ }
665
+
666
+ .viewer-footer span {
667
+ font-size: 0.75rem;
668
+ color: var(--text-dim);
669
+ }
670
+
671
+ .viewer-footer a {
672
+ color: var(--text-secondary);
673
+ text-decoration: none;
674
+ transition: color 0.2s ease;
675
+ }
676
+
677
+ .viewer-footer a:hover {
678
+ color: var(--text-primary);
679
+ text-decoration: underline;
680
+ }
681
+
682
+ .back-btn {
683
+ display: flex;
684
+ align-items: center;
685
+ gap: 0.5rem;
686
+ padding: 0.75rem 1.25rem;
687
+ font-size: 0.875rem;
688
+ font-weight: 500;
689
+ font-family: inherit;
690
+ color: var(--text-muted);
691
+ background: transparent;
692
+ border: 1px solid var(--card-border);
693
+ border-radius: 10px;
694
+ cursor: pointer;
695
+ transition: all 0.2s ease;
696
+ margin-bottom: 1rem;
697
+ }
698
+
699
+ .back-btn:hover {
700
+ color: var(--text-secondary);
701
+ border-color: var(--text-dim);
702
+ }
703
+
704
+ .back-btn svg {
705
+ width: 18px;
706
+ height: 18px;
707
+ }
708
+
709
+ /* Hide card when viewing */
710
+ .card.hidden {
711
+ display: none;
712
+ }
713
+
714
+ .header.minimized {
715
+ margin-bottom: 1rem;
716
+ }
717
+
718
+ .header.minimized .logo {
719
+ font-size: 2rem;
720
+ }
721
+
722
+ .header.minimized .tagline {
723
+ display: none;
724
+ }
src/sharp/web/static/js/main.js ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Global state for PLY data (on window so module script can access)
2
+ window.currentPlyData = null;
3
+ window.currentPlyFilename = null;
4
+
5
+ // Particle system for background
6
+ const canvas = document.getElementById('particleCanvas');
7
+ const ctx = canvas.getContext('2d');
8
+ let particles = [];
9
+ let animationId;
10
+ let mouseX = 0, mouseY = 0;
11
+
12
+ function resizeCanvas() {
13
+ canvas.width = window.innerWidth;
14
+ canvas.height = window.innerHeight;
15
+ }
16
+
17
+ class Particle {
18
+ constructor() {
19
+ this.reset();
20
+ }
21
+
22
+ reset() {
23
+ this.x = Math.random() * canvas.width;
24
+ this.y = Math.random() * canvas.height;
25
+ this.z = Math.random() * 1000;
26
+ this.baseSize = Math.random() * 2 + 0.5;
27
+ this.color = this.getColor();
28
+ this.vx = (Math.random() - 0.5) * 0.3;
29
+ this.vy = (Math.random() - 0.5) * 0.3;
30
+ this.vz = (Math.random() - 0.5) * 2;
31
+ }
32
+
33
+ getColor() {
34
+ const colors = [
35
+ { r: 99, g: 102, b: 241 }, // indigo
36
+ { r: 168, g: 85, b: 247 }, // purple
37
+ { r: 236, g: 72, b: 153 }, // pink
38
+ { r: 59, g: 130, b: 246 }, // blue
39
+ { r: 139, g: 92, b: 246 }, // violet
40
+ ];
41
+ return colors[Math.floor(Math.random() * colors.length)];
42
+ }
43
+
44
+ update() {
45
+ // Mouse interaction
46
+ const dx = mouseX - this.x;
47
+ const dy = mouseY - this.y;
48
+ const dist = Math.sqrt(dx * dx + dy * dy);
49
+ if (dist < 150) {
50
+ const force = (150 - dist) / 150;
51
+ this.vx -= (dx / dist) * force * 0.5;
52
+ this.vy -= (dy / dist) * force * 0.5;
53
+ }
54
+
55
+ this.x += this.vx;
56
+ this.y += this.vy;
57
+ this.z += this.vz;
58
+
59
+ // Damping
60
+ this.vx *= 0.99;
61
+ this.vy *= 0.99;
62
+
63
+ // Wrap around
64
+ if (this.x < 0) this.x = canvas.width;
65
+ if (this.x > canvas.width) this.x = 0;
66
+ if (this.y < 0) this.y = canvas.height;
67
+ if (this.y > canvas.height) this.y = 0;
68
+ if (this.z < 0 || this.z > 1000) this.vz *= -1;
69
+ }
70
+
71
+ draw() {
72
+ const perspective = 1000 / (1000 + this.z);
73
+ const size = this.baseSize * perspective * 3;
74
+ const isLightMode = document.documentElement.getAttribute('data-theme') === 'light';
75
+ const alpha = perspective * (isLightMode ? 0.4 : 0.6);
76
+
77
+ ctx.beginPath();
78
+ const gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, size * 2);
79
+ gradient.addColorStop(0, `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${alpha})`);
80
+ gradient.addColorStop(1, `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, 0)`);
81
+ ctx.fillStyle = gradient;
82
+ ctx.arc(this.x, this.y, size * 2, 0, Math.PI * 2);
83
+ ctx.fill();
84
+ }
85
+ }
86
+
87
+ function initParticles() {
88
+ particles = [];
89
+ const count = Math.min(200, Math.floor((canvas.width * canvas.height) / 8000));
90
+ for (let i = 0; i < count; i++) {
91
+ particles.push(new Particle());
92
+ }
93
+ }
94
+
95
+ function animate() {
96
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
97
+
98
+ particles.forEach(p => {
99
+ p.update();
100
+ p.draw();
101
+ });
102
+
103
+ // Draw connections
104
+ const isLightMode = document.documentElement.getAttribute('data-theme') === 'light';
105
+ ctx.strokeStyle = isLightMode ? 'rgba(99, 102, 241, 0.08)' : 'rgba(255, 255, 255, 0.02)';
106
+ ctx.lineWidth = 0.5;
107
+ for (let i = 0; i < particles.length; i++) {
108
+ for (let j = i + 1; j < particles.length; j++) {
109
+ const dx = particles[i].x - particles[j].x;
110
+ const dy = particles[i].y - particles[j].y;
111
+ const dist = Math.sqrt(dx * dx + dy * dy);
112
+ if (dist < 100) {
113
+ ctx.beginPath();
114
+ ctx.moveTo(particles[i].x, particles[i].y);
115
+ ctx.lineTo(particles[j].x, particles[j].y);
116
+ ctx.stroke();
117
+ }
118
+ }
119
+ }
120
+
121
+ animationId = requestAnimationFrame(animate);
122
+ }
123
+
124
+ window.addEventListener('resize', () => {
125
+ resizeCanvas();
126
+ initParticles();
127
+ });
128
+
129
+ document.addEventListener('mousemove', (e) => {
130
+ mouseX = e.clientX;
131
+ mouseY = e.clientY;
132
+ });
133
+
134
+ resizeCanvas();
135
+ initParticles();
136
+ animate();
137
+
138
+ // Theme toggle functionality
139
+ const themeToggle = document.getElementById('themeToggle');
140
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
141
+
142
+ function setTheme(theme) {
143
+ document.documentElement.setAttribute('data-theme', theme);
144
+ localStorage.setItem('theme', theme);
145
+ }
146
+
147
+ function getPreferredTheme() {
148
+ const stored = localStorage.getItem('theme');
149
+ if (stored) return stored;
150
+ return prefersDark.matches ? 'dark' : 'light';
151
+ }
152
+
153
+ // Initialize theme
154
+ setTheme(getPreferredTheme());
155
+
156
+ themeToggle.addEventListener('click', () => {
157
+ const current = document.documentElement.getAttribute('data-theme');
158
+ setTheme(current === 'light' ? 'dark' : 'light');
159
+ });
160
+
161
+ // Listen for system theme changes
162
+ prefersDark.addEventListener('change', (e) => {
163
+ if (!localStorage.getItem('theme')) {
164
+ setTheme(e.matches ? 'dark' : 'light');
165
+ }
166
+ });
167
+
168
+ // Upload functionality
169
+ const dropZone = document.getElementById('dropZone');
170
+ const fileInput = document.getElementById('fileInput');
171
+ const fileList = document.getElementById('fileList');
172
+ const form = document.getElementById('uploadForm');
173
+ const loaderContainer = document.getElementById('loaderContainer');
174
+ const results = document.getElementById('results');
175
+ const submitBtn = document.getElementById('submitBtn');
176
+
177
+ dropZone.addEventListener('click', () => fileInput.click());
178
+
179
+ dropZone.addEventListener('dragover', (e) => {
180
+ e.preventDefault();
181
+ dropZone.classList.add('drag-over');
182
+ });
183
+
184
+ dropZone.addEventListener('dragleave', () => {
185
+ dropZone.classList.remove('drag-over');
186
+ });
187
+
188
+ dropZone.addEventListener('drop', (e) => {
189
+ e.preventDefault();
190
+ dropZone.classList.remove('drag-over');
191
+ fileInput.files = e.dataTransfer.files;
192
+ updateFileList();
193
+ });
194
+
195
+ fileInput.addEventListener('change', updateFileList);
196
+
197
+ function formatFileSize(bytes) {
198
+ if (bytes < 1024) return bytes + ' B';
199
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
200
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
201
+ }
202
+
203
+ function updateFileList() {
204
+ fileList.innerHTML = '';
205
+
206
+ if (fileInput.files.length > 0) {
207
+ dropZone.classList.add('has-files');
208
+ } else {
209
+ dropZone.classList.remove('has-files');
210
+ }
211
+
212
+ for (const file of fileInput.files) {
213
+ const div = document.createElement('div');
214
+ div.className = 'file-item';
215
+
216
+ // Create preview
217
+ const reader = new FileReader();
218
+ reader.onload = (e) => {
219
+ const preview = div.querySelector('.file-preview');
220
+ if (preview) preview.src = e.target.result;
221
+ };
222
+ reader.readAsDataURL(file);
223
+
224
+ div.innerHTML = `
225
+ <img class="file-preview" src="" alt="">
226
+ <div class="file-info">
227
+ <div class="file-name">${file.name}</div>
228
+ <div class="file-size">${formatFileSize(file.size)}</div>
229
+ </div>
230
+ `;
231
+ fileList.appendChild(div);
232
+ }
233
+ }
234
+
235
+ form.addEventListener('submit', async (e) => {
236
+ e.preventDefault();
237
+ if (fileInput.files.length === 0) return;
238
+
239
+ submitBtn.disabled = true;
240
+ dropZone.style.display = 'none';
241
+ fileList.style.display = 'none';
242
+ submitBtn.style.display = 'none';
243
+ loaderContainer.classList.add('active');
244
+ results.innerHTML = '';
245
+
246
+ const formData = new FormData();
247
+ for (const file of fileInput.files) {
248
+ formData.append('files', file);
249
+ }
250
+
251
+ try {
252
+ const response = await fetch('/predict', {
253
+ method: 'POST',
254
+ body: formData
255
+ });
256
+
257
+ if (response.ok) {
258
+ const data = await response.json();
259
+
260
+ if (data.results && data.results.length > 0) {
261
+ const result = data.results[0];
262
+
263
+ if (result.error) {
264
+ showError(result.error);
265
+ } else {
266
+ // Store the PLY data and show the viewer
267
+ window.currentPlyData = result.ply_data;
268
+ window.currentPlyFilename = result.ply_filename;
269
+
270
+ // Show viewer (check if module loaded)
271
+ if (typeof window.showViewer === 'function') {
272
+ window.showViewer(result);
273
+ } else {
274
+ // Fallback: offer download if viewer module failed to load
275
+ showError('3D viewer failed to load. Click the download button to get your PLY file.');
276
+ // Trigger download
277
+ downloadPly(result.ply_data, result.ply_filename);
278
+ }
279
+ }
280
+ }
281
+ } else {
282
+ const error = await response.text();
283
+ showError(error);
284
+ // Restore upload UI on error
285
+ dropZone.style.display = '';
286
+ fileList.style.display = '';
287
+ submitBtn.style.display = '';
288
+ }
289
+ } catch (err) {
290
+ showError(err.message);
291
+ // Restore upload UI on error
292
+ dropZone.style.display = '';
293
+ fileList.style.display = '';
294
+ submitBtn.style.display = '';
295
+ } finally {
296
+ submitBtn.disabled = false;
297
+ loaderContainer.classList.remove('active');
298
+ }
299
+ });
300
+
301
+ function showError(message) {
302
+ results.innerHTML = `
303
+ <div class="result-item" style="background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.2);">
304
+ <div class="success-icon" style="background: rgba(239, 68, 68, 0.2);">
305
+ <svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
306
+ <line x1="18" y1="6" x2="6" y2="18"></line>
307
+ <line x1="6" y1="6" x2="18" y2="18"></line>
308
+ </svg>
309
+ </div>
310
+ <div class="result-text">
311
+ <div class="result-title" style="color: #ef4444;">Error</div>
312
+ <div class="result-desc">${message}</div>
313
+ </div>
314
+ </div>
315
+ `;
316
+ }
317
+
318
+ function downloadPly(plyData, filename) {
319
+ const binaryString = atob(plyData);
320
+ const bytes = new Uint8Array(binaryString.length);
321
+ for (let i = 0; i < binaryString.length; i++) {
322
+ bytes[i] = binaryString.charCodeAt(i);
323
+ }
324
+ const blob = new Blob([bytes], { type: 'application/octet-stream' });
325
+ const url = URL.createObjectURL(blob);
326
+ const a = document.createElement('a');
327
+ a.href = url;
328
+ a.download = filename;
329
+ a.click();
330
+ URL.revokeObjectURL(url);
331
+ }
src/sharp/web/static/js/viewer.js ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
2
+ import * as THREE from 'three';
3
+
4
+ // Make THREE available globally for the viewer
5
+ window.THREE = THREE;
6
+ window.GaussianSplats3D = GaussianSplats3D;
7
+
8
+ // Viewer state
9
+ let viewer = null;
10
+ let initialCameraPosition = null;
11
+ let initialCameraTarget = null;
12
+
13
+ // DOM elements
14
+ const card = document.querySelector('.card');
15
+ const header = document.querySelector('.header');
16
+ const viewerContainer = document.getElementById('viewerContainer');
17
+ const viewerCanvasContainer = document.getElementById('viewerCanvasContainer');
18
+ const viewerFilename = document.getElementById('viewerFilename');
19
+ const backBtn = document.getElementById('backBtn');
20
+ const resetViewBtn = document.getElementById('resetViewBtn');
21
+ const downloadBtn = document.getElementById('downloadBtn');
22
+ const controlsHint = document.getElementById('controlsHint');
23
+
24
+ // Expose showViewer to global scope
25
+ window.showViewer = async function (result) {
26
+ // Update filename badge
27
+ viewerFilename.textContent = result.ply_filename;
28
+
29
+ // Hide card and show viewer
30
+ card.classList.add('hidden');
31
+ header.classList.add('minimized');
32
+ viewerContainer.classList.add('active');
33
+
34
+ // Decode base64 PLY data
35
+ const binaryString = atob(result.ply_data);
36
+ const bytes = new Uint8Array(binaryString.length);
37
+ for (let i = 0; i < binaryString.length; i++) {
38
+ bytes[i] = binaryString.charCodeAt(i);
39
+ }
40
+ const plyBlob = new Blob([bytes], { type: 'application/octet-stream' });
41
+ const plyUrl = URL.createObjectURL(plyBlob);
42
+
43
+ // Clean up previous viewer if exists
44
+ if (viewer) {
45
+ viewer.dispose();
46
+ viewer = null;
47
+ // Clear the container
48
+ while (viewerCanvasContainer.firstChild) {
49
+ if (viewerCanvasContainer.firstChild.id !== 'controlsHint') {
50
+ viewerCanvasContainer.removeChild(viewerCanvasContainer.firstChild);
51
+ } else {
52
+ break;
53
+ }
54
+ }
55
+ }
56
+
57
+ // Wait for container to be visible and sized
58
+ await new Promise(resolve => setTimeout(resolve, 100));
59
+
60
+ try {
61
+ // Create the Gaussian Splat viewer
62
+ viewer = new GaussianSplats3D.Viewer({
63
+ cameraUp: [0, -1, 0],
64
+ initialCameraPosition: [0, 0, -3],
65
+ initialCameraLookAt: [0, 0, 0],
66
+ rootElement: viewerCanvasContainer,
67
+ sharedMemoryForWorkers: false,
68
+ dynamicScene: false,
69
+ sceneRevealMode: GaussianSplats3D.SceneRevealMode.Instant,
70
+ antialiased: true,
71
+ });
72
+
73
+ // Load the PLY file - specify format since blob URLs don't have extensions
74
+ await viewer.addSplatScene(plyUrl, {
75
+ splatAlphaRemovalThreshold: 5,
76
+ showLoadingUI: false,
77
+ progressiveLoad: false,
78
+ format: GaussianSplats3D.SceneFormat.Ply,
79
+ });
80
+
81
+ viewer.start();
82
+
83
+ // Store initial camera state for reset
84
+ if (viewer.camera) {
85
+ initialCameraPosition = viewer.camera.position.clone();
86
+ initialCameraTarget = new THREE.Vector3(0, 0, 0);
87
+ }
88
+
89
+ // Hide controls hint after a few seconds
90
+ setTimeout(() => {
91
+ controlsHint.style.opacity = '0';
92
+ }, 5000);
93
+
94
+ // Cleanup blob URL after loading
95
+ URL.revokeObjectURL(plyUrl);
96
+
97
+ } catch (error) {
98
+ console.error('Error loading Gaussian Splat:', error);
99
+ viewerCanvasContainer.innerHTML = `
100
+ <div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #ef4444; text-align: center; padding: 2rem;">
101
+ <div>
102
+ <p style="font-size: 1.1rem; margin-bottom: 0.5rem;">Failed to load 3D viewer</p>
103
+ <p style="font-size: 0.875rem; opacity: 0.7;">${error.message}</p>
104
+ </div>
105
+ </div>
106
+ `;
107
+ }
108
+ };
109
+
110
+ // Back button handler
111
+ backBtn.addEventListener('click', () => {
112
+ // Clean up viewer
113
+ if (viewer) {
114
+ viewer.dispose();
115
+ viewer = null;
116
+ }
117
+
118
+ // Clear the canvas container except for the hint
119
+ const hint = document.getElementById('controlsHint');
120
+ viewerCanvasContainer.innerHTML = '';
121
+ if (hint) {
122
+ hint.style.opacity = '1';
123
+ viewerCanvasContainer.appendChild(hint);
124
+ }
125
+
126
+ // Restore upload UI elements
127
+ const dropZone = document.getElementById('dropZone');
128
+ const fileList = document.getElementById('fileList');
129
+ const submitBtn = document.getElementById('submitBtn');
130
+ dropZone.style.display = '';
131
+ fileList.style.display = '';
132
+ submitBtn.style.display = '';
133
+
134
+ // Show card and hide viewer
135
+ card.classList.remove('hidden');
136
+ header.classList.remove('minimized');
137
+ viewerContainer.classList.remove('active');
138
+ });
139
+
140
+ // Reset view button handler
141
+ resetViewBtn.addEventListener('click', () => {
142
+ if (viewer && viewer.camera && initialCameraPosition) {
143
+ viewer.camera.position.copy(initialCameraPosition);
144
+ viewer.camera.lookAt(initialCameraTarget);
145
+ if (viewer.controls) {
146
+ viewer.controls.target.copy(initialCameraTarget);
147
+ viewer.controls.update();
148
+ }
149
+ }
150
+ });
151
+
152
+ // Download button handler
153
+ downloadBtn.addEventListener('click', () => {
154
+ if (window.currentPlyData && window.currentPlyFilename) {
155
+ const binaryString = atob(window.currentPlyData);
156
+ const bytes = new Uint8Array(binaryString.length);
157
+ for (let i = 0; i < binaryString.length; i++) {
158
+ bytes[i] = binaryString.charCodeAt(i);
159
+ }
160
+ const blob = new Blob([bytes], { type: 'application/octet-stream' });
161
+ const url = URL.createObjectURL(blob);
162
+ const a = document.createElement('a');
163
+ a.href = url;
164
+ a.download = window.currentPlyFilename;
165
+ a.click();
166
+ URL.revokeObjectURL(url);
167
+ }
168
+ });
169
+
170
+ // Handle window resize for the viewer
171
+ window.addEventListener('resize', () => {
172
+ if (viewer && viewerContainer.classList.contains('active')) {
173
+ // The viewer should handle resize automatically
174
+ }
175
+ });
src/sharp/web/templates/index.html ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Sharp</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="{{ url_for('static', path='css/styles.css') }}">
12
+ <script type="importmap">
13
+ {
14
+ "imports": {
15
+ "three": "https://esm.sh/three@0.164.0",
16
+ "@mkkellogg/gaussian-splats-3d": "https://esm.sh/@mkkellogg/gaussian-splats-3d@0.4.6"
17
+ }
18
+ }
19
+ </script>
20
+ </head>
21
+
22
+ <body>
23
+ <canvas id="particleCanvas"></canvas>
24
+
25
+ <button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">
26
+ <svg class="sun-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"
27
+ stroke-linejoin="round">
28
+ <circle cx="12" cy="12" r="5" />
29
+ <line x1="12" y1="1" x2="12" y2="3" />
30
+ <line x1="12" y1="21" x2="12" y2="23" />
31
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
32
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
33
+ <line x1="1" y1="12" x2="3" y2="12" />
34
+ <line x1="21" y1="12" x2="23" y2="12" />
35
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
36
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
37
+ </svg>
38
+ <svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"
39
+ stroke-linejoin="round">
40
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
41
+ </svg>
42
+ </button>
43
+
44
+ <div class="app-container">
45
+ <header class="header">
46
+ <h1 class="logo">Sharp</h1>
47
+ <p class="tagline">Transform images into Gaussian Splats</p>
48
+ </header>
49
+
50
+ <div class="card">
51
+ <form id="uploadForm">
52
+ <div class="upload-zone" id="dropZone">
53
+ <div class="upload-icon">
54
+ <svg viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round"
55
+ stroke-linejoin="round">
56
+ <path d="M12 16V4m0 0l-4 4m4-4l4 4" />
57
+ <path d="M3 20h18" />
58
+ </svg>
59
+ </div>
60
+ <div class="upload-text">
61
+ <h3>Drop your image here</h3>
62
+ <p>or click to browse</p>
63
+ </div>
64
+ <input type="file" id="fileInput" name="files" accept="image/*" style="display: none">
65
+ </div>
66
+
67
+ <div class="file-list" id="fileList"></div>
68
+
69
+ <button type="submit" class="submit-btn" id="submitBtn">
70
+ <span>
71
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
72
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
73
+ <path d="M12 3v18M3 12l9 9 9-9" />
74
+ </svg>
75
+ Generate
76
+ </span>
77
+ </button>
78
+ </form>
79
+
80
+ <div class="loader-container" id="loaderContainer">
81
+ <div class="loader"></div>
82
+ <p class="loader-text">Generating Gaussian Splats...</p>
83
+ <p class="loader-subtext">This may take a moment</p>
84
+ </div>
85
+
86
+ <div class="results" id="results"></div>
87
+ </div>
88
+
89
+ <!-- 3D Viewer Container -->
90
+ <div class="viewer-container" id="viewerContainer">
91
+ <button class="back-btn" id="backBtn">
92
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
93
+ stroke-linejoin="round">
94
+ <path d="M19 12H5M12 19l-7-7 7-7" />
95
+ </svg>
96
+ Back to Upload
97
+ </button>
98
+
99
+ <div class="viewer-header">
100
+ <div class="viewer-title">
101
+ <h3>3D Gaussian Splat Viewer</h3>
102
+ <span class="file-badge" id="viewerFilename">output.ply</span>
103
+ </div>
104
+ <div class="viewer-actions">
105
+ <button class="viewer-btn" id="resetViewBtn">
106
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
107
+ stroke-linecap="round" stroke-linejoin="round">
108
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
109
+ <path d="M3 3v5h5" />
110
+ </svg>
111
+ Reset View
112
+ </button>
113
+ <button class="viewer-btn primary" id="downloadBtn">
114
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
115
+ stroke-linecap="round" stroke-linejoin="round">
116
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
117
+ <polyline points="7 10 12 15 17 10" />
118
+ <line x1="12" y1="15" x2="12" y2="3" />
119
+ </svg>
120
+ Download PLY
121
+ </button>
122
+ </div>
123
+ </div>
124
+
125
+ <div class="viewer-canvas-container" id="viewerCanvasContainer">
126
+ <div class="viewer-controls-hint" id="controlsHint">
127
+ πŸ–±οΈ Left click + drag to rotate β€’ Scroll to zoom β€’ Right click + drag to pan
128
+ </div>
129
+ </div>
130
+
131
+ <div class="viewer-footer">
132
+ <span>Powered by <a href="https://threejs.org/" target="_blank" rel="noopener">Three.js</a> & <a
133
+ href="https://github.com/mkkellogg/GaussianSplats3D" target="_blank" rel="noopener">Gaussian
134
+ Splats 3D</a></span>
135
+ </div>
136
+ </div>
137
+
138
+ <footer class="footer">
139
+ <p><a href="https://apple.github.io/ml-sharp/" target="_blank" rel="noopener">Mescheder et al., 2025</a> β€’
140
+ Sharp: Monocular View Synthesis in Less Than a Second</p>
141
+ </footer>
142
+ </div>
143
+
144
+ <!-- Main application script -->
145
+ <script src="{{ url_for('static', path='js/main.js') }}"></script>
146
+
147
+ <!-- 3D Gaussian Splat Viewer Module -->
148
+ <script type="module" src="{{ url_for('static', path='js/viewer.js') }}"></script>
149
+ </body>
150
+
151
+ </html>
start.command ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Sharp Web Interface - One-Click Launcher
3
+ # Double-click this file to start the Sharp web interface
4
+
5
+ # Change to the script's directory
6
+ cd "$(dirname "$0")"
7
+
8
+ ENV_NAME="sharp"
9
+ PYTHON_VERSION="3.13"
10
+
11
+ echo "======================================"
12
+ echo " Sharp 3D Prediction - Web Interface"
13
+ echo "======================================"
14
+ echo ""
15
+
16
+ # Check if conda is available
17
+ if ! command -v conda &> /dev/null; then
18
+ echo "❌ Conda is not installed or not in PATH."
19
+ echo ""
20
+ echo "Please install Miniconda or Anaconda first:"
21
+ echo " https://docs.conda.io/en/latest/miniconda.html"
22
+ echo ""
23
+ read -p "Press Enter to exit..."
24
+ exit 1
25
+ fi
26
+
27
+ # Initialize conda for this shell session
28
+ eval "$(conda shell.bash hook)"
29
+
30
+ # Check if the environment exists
31
+ if ! conda env list | grep -q "^${ENV_NAME} "; then
32
+ echo "πŸ“¦ Creating conda environment '${ENV_NAME}' with Python ${PYTHON_VERSION}..."
33
+ conda create -n "$ENV_NAME" python="$PYTHON_VERSION" -y
34
+ if [ $? -ne 0 ]; then
35
+ echo "❌ Failed to create conda environment."
36
+ read -p "Press Enter to exit..."
37
+ exit 1
38
+ fi
39
+ echo "βœ… Environment created."
40
+ echo ""
41
+ fi
42
+
43
+ # Activate the environment
44
+ echo "πŸ”„ Activating conda environment '${ENV_NAME}'..."
45
+ conda activate "$ENV_NAME"
46
+ if [ $? -ne 0 ]; then
47
+ echo "❌ Failed to activate conda environment."
48
+ read -p "Press Enter to exit..."
49
+ exit 1
50
+ fi
51
+
52
+ # Check if sharp is installed by trying to import it
53
+ echo "πŸ” Checking if dependencies are installed..."
54
+ if ! python -c "import sharp" 2>/dev/null; then
55
+ echo "πŸ“¦ Installing project dependencies (this may take a few minutes)..."
56
+ pip install -r requirements.txt
57
+ if [ $? -ne 0 ]; then
58
+ echo "❌ Failed to install requirements."
59
+ read -p "Press Enter to exit..."
60
+ exit 1
61
+ fi
62
+ echo "βœ… Dependencies installed."
63
+ echo ""
64
+ fi
65
+
66
+ # Check if web dependencies are installed
67
+ if ! python -c "import fastapi" 2>/dev/null; then
68
+ echo "πŸ“¦ Installing web interface dependencies..."
69
+ pip install -r src/sharp/web/requirements.txt
70
+ if [ $? -ne 0 ]; then
71
+ echo "❌ Failed to install web requirements."
72
+ read -p "Press Enter to exit..."
73
+ exit 1
74
+ fi
75
+ echo "βœ… Web dependencies installed."
76
+ echo ""
77
+ fi
78
+
79
+ echo "======================================"
80
+ echo "πŸš€ Starting Sharp Web Interface..."
81
+ echo "======================================"
82
+ echo ""
83
+ echo "Open your browser and go to:"
84
+ echo ""
85
+ echo " πŸ‘‰ http://localhost:8000"
86
+ echo ""
87
+ echo "Press Ctrl+C to stop the server."
88
+ echo ""
89
+
90
+ # Start the web server
91
+ python src/sharp/web/app.py
92
+
93
+ # Keep terminal open if server stops unexpectedly
94
+ read -p "Press Enter to exit..."