everydaycats commited on
Commit
fe1a826
·
verified ·
1 Parent(s): 84a8f0a

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +157 -61
main.py CHANGED
@@ -1,25 +1,80 @@
1
- from fastapi import FastAPI, UploadFile, File, HTTPException, Query, Form
2
- from fastapi.responses import FileResponse, HTMLResponse
3
  import os
4
- import shutil
5
  import uuid
 
 
6
  import requests
7
  from typing import Optional
8
- from app.utils import run_inference
9
 
 
 
10
  from huggingface_hub import login
 
11
 
 
12
  hf_token = os.environ.get("HF_TOKEN")
13
- print(hf_token)
14
- login(token=hf_token)
 
 
 
 
 
 
 
15
 
 
 
 
16
 
17
  app = FastAPI(title="Stable Fast 3D API")
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- # Embedded HTML page on root
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  @app.get("/", response_class=HTMLResponse)
22
- async def root():
23
  html = """
24
  <!doctype html>
25
  <html>
@@ -27,7 +82,6 @@ async def root():
27
  <meta charset="utf-8" />
28
  <title>Stable Fast 3D API — Demo</title>
29
  <meta name="viewport" content="width=device-width,initial-scale=1" />
30
- <!-- model-viewer for GLB preview -->
31
  <script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
32
  <style>
33
  body { font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; padding: 24px; background:#f7f8fb; color:#111; }
@@ -63,7 +117,6 @@ async def root():
63
  <div class="status" id="status">Status: idle</div>
64
  <a id="downloadLink" class="download-link" style="display:none" target="_blank">Download model (click if not auto-downloaded)</a>
65
 
66
- <!-- Model preview -->
67
  <model-viewer id="preview" camera-controls auto-rotate environment-image="neutral" style="display:none;"></model-viewer>
68
  </div>
69
 
@@ -108,7 +161,6 @@ async def root():
108
  if (hasFile) {
109
  formData.append('image', fileInput.files[0]);
110
  } else {
111
- // we send the image_url as form field; server accepts this in POST
112
  formData.append('image_url', urlInput.value.trim());
113
  }
114
 
@@ -129,10 +181,8 @@ async def root():
129
  const contentDisposition = resp.headers.get('content-disposition') || '';
130
  const suggestedName = (contentDisposition.match(/filename=([^;]+)/) || [])[1] || 'model.glb';
131
 
132
- // Create object URL
133
  const url = URL.createObjectURL(blob);
134
 
135
- // Auto-download
136
  const a = document.createElement('a');
137
  a.href = url;
138
  a.download = suggestedName.replace(/"/g, '');
@@ -140,13 +190,11 @@ async def root():
140
  a.click();
141
  a.remove();
142
 
143
- // Show download link in case auto-download blocked
144
  downloadLink.href = url;
145
  downloadLink.download = a.download;
146
  downloadLink.textContent = 'Download ' + a.download;
147
  downloadLink.style.display = 'inline-block';
148
 
149
- // Try to preview with model-viewer
150
  try {
151
  preview.src = url;
152
  preview.style.display = 'block';
@@ -155,8 +203,7 @@ async def root():
155
  }
156
 
157
  status.textContent = 'Status: Done — model generated.';
158
- // Release URL after some time (keeping it available for a bit)
159
- setTimeout(() => URL.revokeObjectURL(url), 5 * 60 * 1000); // revoke after 5 min
160
 
161
  } catch (err) {
162
  console.error(err);
@@ -172,76 +219,125 @@ async def root():
172
  return HTMLResponse(content=html, status_code=200)
173
 
174
 
175
- # Accept both an uploaded file OR an image_url via POST form
176
- @app.post("/generate-3d/")
177
- async def generate_3d_model_upload(
178
  image: Optional[UploadFile] = File(None),
179
  image_url: Optional[str] = Form(None),
 
180
  ):
181
- """Generate 3D model from uploaded image file or from provided image URL (POST)"""
182
- return await process_image(image=image, image_url=image_url)
183
-
 
 
184
 
185
- # Keep the existing GET-based URL endpoint (for direct API use)
186
- @app.get("/generate-3d/")
187
- async def generate_3d_model_url(image_url: str = Query(..., description="URL of the image to convert to 3D")):
188
- """Generate 3D model from image URL (GET)"""
189
- return await process_image(image_url=image_url)
190
 
 
 
 
 
 
 
191
 
192
- async def process_image(image: Optional[UploadFile] = None, image_url: Optional[str] = None):
193
- # Create unique ID for this request
194
- temp_id = str(uuid.uuid4())
195
- tmp_dir = "/app/tmp"
196
- os.makedirs(tmp_dir, exist_ok=True)
197
 
198
- input_path = os.path.join(tmp_dir, f"{temp_id}.png")
199
- output_dir = os.path.join(tmp_dir, f"{temp_id}_output")
 
 
 
 
 
 
 
 
200
  os.makedirs(output_dir, exist_ok=True)
201
 
 
 
202
  try:
203
- # Handle image from upload or URL
204
  if image is not None:
205
- with open(input_path, "wb") as f:
206
- shutil.copyfileobj(image.file, f)
207
  elif image_url:
208
- response = requests.get(image_url, stream=True, timeout=30)
209
- if response.status_code != 200:
210
- raise HTTPException(status_code=400, detail="Could not download image from URL")
211
- with open(input_path, "wb") as f:
212
- for chunk in response.iter_content(chunk_size=8192):
213
- f.write(chunk)
 
 
214
  else:
215
- raise HTTPException(status_code=400, detail="Either image file or image URL must be provided")
216
-
217
- # Run the inference (your existing function)
218
- glb_path = run_inference(input_path, output_dir)
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  if not glb_path or not os.path.exists(glb_path):
221
- raise HTTPException(status_code=500, detail="Failed to generate 3D model")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
- # Return the GLB file for download
 
 
224
  return FileResponse(
225
  path=glb_path,
226
  media_type="model/gltf-binary",
227
- filename="model.glb",
228
- headers={"Content-Disposition": f"attachment; filename=model.glb"},
229
  )
230
 
231
  except HTTPException:
 
232
  raise
233
  except Exception as e:
234
- raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
235
- finally:
236
- # Clean up temporary files
237
  try:
238
  if os.path.exists(input_path):
239
  os.remove(input_path)
240
- except Exception:
241
- pass
242
- # Optionally, clean up output_dir contents (you could keep for debugging)
243
- try:
244
  if os.path.exists(output_dir):
245
- shutil.rmtree(output_dir)
246
  except Exception:
247
- pass
 
 
1
+ # app.py
 
2
  import os
 
3
  import uuid
4
+ import shutil
5
+ import logging
6
  import requests
7
  from typing import Optional
 
8
 
9
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Query, Form, BackgroundTasks
10
+ from fastapi.responses import FileResponse, HTMLResponse
11
  from huggingface_hub import login
12
+ from app.utils import run_inference
13
 
14
+ # --- Configuration / env ---
15
  hf_token = os.environ.get("HF_TOKEN")
16
+ if hf_token:
17
+ try:
18
+ login(token=hf_token)
19
+ except Exception:
20
+ # If login fails, continue (some deployments don't need it)
21
+ pass
22
+
23
+ TMP_DIR = os.environ.get("TMP_DIR", "/app/tmp")
24
+ os.makedirs(TMP_DIR, exist_ok=True)
25
 
26
+ # Logging
27
+ logging.basicConfig(level=logging.INFO)
28
+ logger = logging.getLogger("stable-fast-3d-api")
29
 
30
  app = FastAPI(title="Stable Fast 3D API")
31
 
32
+ # --- Utility helpers ---
33
+ def _cleanup_paths(input_path: Optional[str], output_dir: Optional[str]) -> None:
34
+ """Remove the input file and output directory after response has been sent."""
35
+ try:
36
+ if input_path and os.path.exists(input_path):
37
+ try:
38
+ os.remove(input_path)
39
+ logger.info("Cleaned input file: %s", input_path)
40
+ except Exception as e:
41
+ logger.warning("Failed to remove input file %s: %s", input_path, e)
42
+ except Exception as e:
43
+ logger.exception("Unexpected error while cleaning input file: %s", e)
44
+
45
+ try:
46
+ if output_dir and os.path.exists(output_dir):
47
+ try:
48
+ shutil.rmtree(output_dir, ignore_errors=True)
49
+ logger.info("Removed output directory: %s", output_dir)
50
+ except Exception as e:
51
+ logger.warning("Failed to remove output dir %s: %s", output_dir, e)
52
+ except Exception as e:
53
+ logger.exception("Unexpected error while cleaning output directory: %s", e)
54
+
55
+
56
+ def _save_upload_file(upload_file: UploadFile, dest_path: str) -> None:
57
+ """Save UploadFile to destination path (binary stream)."""
58
+ with open(dest_path, "wb") as f:
59
+ shutil.copyfileobj(upload_file.file, f)
60
+ upload_file.file.close()
61
 
62
+
63
+ def _download_to_file(url: str, dest_path: str, timeout: int = 30) -> None:
64
+ """Download a URL to a file (streaming)."""
65
+ resp = requests.get(url, stream=True, timeout=timeout)
66
+ if resp.status_code != 200:
67
+ raise HTTPException(status_code=400, detail=f"Failed to download image: status {resp.status_code}")
68
+ with open(dest_path, "wb") as f:
69
+ for chunk in resp.iter_content(chunk_size=8192):
70
+ if not chunk:
71
+ continue
72
+ f.write(chunk)
73
+
74
+
75
+ # --- Embedded UI root ---
76
  @app.get("/", response_class=HTMLResponse)
77
+ async def root_ui():
78
  html = """
79
  <!doctype html>
80
  <html>
 
82
  <meta charset="utf-8" />
83
  <title>Stable Fast 3D API — Demo</title>
84
  <meta name="viewport" content="width=device-width,initial-scale=1" />
 
85
  <script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
86
  <style>
87
  body { font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; padding: 24px; background:#f7f8fb; color:#111; }
 
117
  <div class="status" id="status">Status: idle</div>
118
  <a id="downloadLink" class="download-link" style="display:none" target="_blank">Download model (click if not auto-downloaded)</a>
119
 
 
120
  <model-viewer id="preview" camera-controls auto-rotate environment-image="neutral" style="display:none;"></model-viewer>
121
  </div>
122
 
 
161
  if (hasFile) {
162
  formData.append('image', fileInput.files[0]);
163
  } else {
 
164
  formData.append('image_url', urlInput.value.trim());
165
  }
166
 
 
181
  const contentDisposition = resp.headers.get('content-disposition') || '';
182
  const suggestedName = (contentDisposition.match(/filename=([^;]+)/) || [])[1] || 'model.glb';
183
 
 
184
  const url = URL.createObjectURL(blob);
185
 
 
186
  const a = document.createElement('a');
187
  a.href = url;
188
  a.download = suggestedName.replace(/"/g, '');
 
190
  a.click();
191
  a.remove();
192
 
 
193
  downloadLink.href = url;
194
  downloadLink.download = a.download;
195
  downloadLink.textContent = 'Download ' + a.download;
196
  downloadLink.style.display = 'inline-block';
197
 
 
198
  try {
199
  preview.src = url;
200
  preview.style.display = 'block';
 
203
  }
204
 
205
  status.textContent = 'Status: Done — model generated.';
206
+ setTimeout(() => URL.revokeObjectURL(url), 5 * 60 * 1000);
 
207
 
208
  } catch (err) {
209
  console.error(err);
 
219
  return HTMLResponse(content=html, status_code=200)
220
 
221
 
222
+ # --- API endpoints: POST and GET ---
223
+ @app.post("/generate-3d/", response_class=FileResponse)
224
+ async def generate_3d_model_post(
225
  image: Optional[UploadFile] = File(None),
226
  image_url: Optional[str] = Form(None),
227
+ background_tasks: BackgroundTasks = None,
228
  ):
229
+ """
230
+ Accept either an uploaded image file (multipart/form-data) OR an image_url form field.
231
+ The final GLB will be returned as a FileResponse; cleanup is scheduled after response.
232
+ """
233
+ return await _process_image_and_respond(image=image, image_url=image_url, background_tasks=background_tasks)
234
 
 
 
 
 
 
235
 
236
+ @app.get("/generate-3d/", response_class=FileResponse)
237
+ async def generate_3d_model_get(
238
+ image_url: str = Query(..., description="URL of the image to convert to 3D"),
239
+ background_tasks: BackgroundTasks = None,
240
+ ):
241
+ return await _process_image_and_respond(image=None, image_url=image_url, background_tasks=background_tasks)
242
 
 
 
 
 
 
243
 
244
+ # --- Core processing function ---
245
+ async def _process_image_and_respond(
246
+ image: Optional[UploadFile] = None,
247
+ image_url: Optional[str] = None,
248
+ background_tasks: BackgroundTasks = None,
249
+ ):
250
+ """Handles request, writes input file, runs inference, returns FileResponse, schedules cleanup."""
251
+ request_id = str(uuid.uuid4())
252
+ input_path = os.path.join(TMP_DIR, f"{request_id}.png")
253
+ output_dir = os.path.join(TMP_DIR, f"{request_id}_output")
254
  os.makedirs(output_dir, exist_ok=True)
255
 
256
+ logger.info("New request %s: writing input & running inference", request_id)
257
+
258
  try:
259
+ # Save input image
260
  if image is not None:
261
+ logger.info("Saving uploaded file to %s", input_path)
262
+ _save_upload_file(image, input_path)
263
  elif image_url:
264
+ logger.info("Downloading image URL to %s", input_path)
265
+ try:
266
+ _download_to_file(image_url, input_path, timeout=30)
267
+ except HTTPException:
268
+ raise
269
+ except Exception as e:
270
+ logger.exception("Downloading image failed: %s", e)
271
+ raise HTTPException(status_code=400, detail=f"Failed to download image: {e}")
272
  else:
273
+ raise HTTPException(status_code=400, detail="Either image file or image_url must be provided")
 
 
 
274
 
275
+ # Run your heavy inference / external process. It must return a glb path.
276
+ logger.info("Running inference on image: %s", input_path)
277
+ try:
278
+ glb_path = run_inference(input_path, output_dir)
279
+ except Exception as e:
280
+ logger.exception("run_inference failed: %s", e)
281
+ # debug listing
282
+ listing = []
283
+ try:
284
+ for root, dirs, files in os.walk(output_dir):
285
+ for fn in files:
286
+ listing.append(os.path.join(root, fn))
287
+ logger.info("Output directory listing after failure: %s", listing)
288
+ except Exception as e2:
289
+ logger.exception("Failed to list output dir: %s", e2)
290
+ raise HTTPException(status_code=500, detail=f"run_inference failed: {e}")
291
+
292
+ # glb_path could be None or incorrect; verify exists
293
  if not glb_path or not os.path.exists(glb_path):
294
+ # If run_inference returns directory instead, try to locate a GLB inside
295
+ found = None
296
+ for root, dirs, files in os.walk(output_dir):
297
+ for fn in files:
298
+ if fn.lower().endswith(".glb"):
299
+ found = os.path.join(root, fn)
300
+ break
301
+ if found:
302
+ break
303
+ if found:
304
+ glb_path = found
305
+ logger.info("Found GLB via fallback discovery: %s", glb_path)
306
+ else:
307
+ listing = []
308
+ for root, dirs, files in os.walk(output_dir):
309
+ for fn in files:
310
+ listing.append(os.path.join(root, fn))
311
+ logger.error("GLB not produced. output_dir listing: %s", listing)
312
+ raise HTTPException(status_code=500, detail="Failed to generate 3D model (GLB missing). Check server logs.")
313
+
314
+ # Schedule cleanup to run after response completes
315
+ if background_tasks is not None:
316
+ background_tasks.add_task(_cleanup_paths, input_path, output_dir)
317
+ logger.info("Scheduled cleanup for request %s", request_id)
318
+ else:
319
+ logger.info("No BackgroundTasks provided; output will not be deleted automatically for request %s", request_id)
320
 
321
+ # Return the GLB as FileResponse (Starlette will stat the file before sending)
322
+ filename = os.path.basename(glb_path)
323
+ logger.info("Returning GLB %s for request %s", glb_path, request_id)
324
  return FileResponse(
325
  path=glb_path,
326
  media_type="model/gltf-binary",
327
+ filename=filename,
 
328
  )
329
 
330
  except HTTPException:
331
+ # pass through HTTPException so FastAPI can send correct response
332
  raise
333
  except Exception as e:
334
+ logger.exception("Unhandled exception during process: %s", e)
335
+ # Attempt to cleanup synchronously on error (best-effort)
 
336
  try:
337
  if os.path.exists(input_path):
338
  os.remove(input_path)
 
 
 
 
339
  if os.path.exists(output_dir):
340
+ shutil.rmtree(output_dir, ignore_errors=True)
341
  except Exception:
342
+ pass
343
+ raise HTTPException(status_code=500, detail=f"Server error while processing image: {e}")