Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import os
|
| 2 |
import asyncio
|
| 3 |
-
from typing import Optional
|
| 4 |
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
from fastapi.responses import JSONResponse
|
|
@@ -12,11 +12,15 @@ import re
|
|
| 12 |
from io import BytesIO
|
| 13 |
import math
|
| 14 |
import time
|
| 15 |
-
import fitz
|
| 16 |
from PIL import Image
|
| 17 |
import google.generativeai as genai
|
| 18 |
from google.generativeai.types import HarmCategory, HarmBlockThreshold
|
|
|
|
|
|
|
|
|
|
| 19 |
|
|
|
|
| 20 |
logging.basicConfig(
|
| 21 |
level=logging.INFO,
|
| 22 |
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
@@ -27,16 +31,34 @@ logging.basicConfig(
|
|
| 27 |
)
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
|
|
|
| 30 |
GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY')
|
|
|
|
|
|
|
| 31 |
if not GOOGLE_API_KEY:
|
| 32 |
logger.warning("GOOGLE_API_KEY not set!")
|
| 33 |
else:
|
| 34 |
genai.configure(api_key=GOOGLE_API_KEY)
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
os.makedirs("uploads", exist_ok=True)
|
|
|
|
| 37 |
|
| 38 |
class FloorPlanQuery(BaseModel):
|
| 39 |
description: Optional[str] = None
|
|
|
|
|
|
|
| 40 |
|
| 41 |
class RoomQuery(BaseModel):
|
| 42 |
room_name: str
|
|
@@ -58,6 +80,7 @@ class PDF:
|
|
| 58 |
"room_dimensions": {}
|
| 59 |
}
|
| 60 |
self.analysis_result = None
|
|
|
|
| 61 |
|
| 62 |
def to_dict(self):
|
| 63 |
return {
|
|
@@ -71,12 +94,13 @@ class PDF:
|
|
| 71 |
"image_count": len(self.images) if self.images else 0,
|
| 72 |
"measurement_info": self.measurement_info,
|
| 73 |
"has_analysis": self.analysis_result is not None,
|
| 74 |
-
"room_count": len(self.analysis_result) if self.analysis_result else 0
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
class FloorPlanProcessor:
|
| 78 |
def __init__(self):
|
| 79 |
-
self.model = genai.GenerativeModel('gemini-2.
|
| 80 |
self.pdfs = {}
|
| 81 |
self.supported_image_formats = {
|
| 82 |
"image/jpeg": ".jpg",
|
|
@@ -87,7 +111,8 @@ class FloorPlanProcessor:
|
|
| 87 |
"image/webp": ".webp"
|
| 88 |
}
|
| 89 |
|
| 90 |
-
async def process_upload(self, file_content, filename, content_type):
|
|
|
|
| 91 |
pdf_id = re.sub(r'[^a-zA-Z0-9]', '_', filename)
|
| 92 |
logger.info(f"Processing {filename} (ID: {pdf_id})")
|
| 93 |
|
|
@@ -101,14 +126,22 @@ class FloorPlanProcessor:
|
|
| 101 |
f.write(file_content)
|
| 102 |
|
| 103 |
if content_type == "application/pdf":
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
elif content_type in self.supported_image_formats:
|
| 106 |
await self.process_image(pdf, file_content)
|
| 107 |
else:
|
| 108 |
raise ValueError(f"Unsupported type: {content_type}")
|
| 109 |
|
| 110 |
pdf.processed = True
|
| 111 |
-
logger.info(f"Processing complete: {pdf_id}")
|
| 112 |
return pdf_id
|
| 113 |
|
| 114 |
except Exception as e:
|
|
@@ -116,49 +149,91 @@ class FloorPlanProcessor:
|
|
| 116 |
pdf.error = str(e)
|
| 117 |
return pdf_id
|
| 118 |
|
| 119 |
-
async def process_image(self, pdf, file_content):
|
|
|
|
| 120 |
try:
|
| 121 |
img = Image.open(BytesIO(file_content))
|
| 122 |
logger.info(f"Image: {img.width}x{img.height}")
|
| 123 |
pdf.images.append(img)
|
|
|
|
| 124 |
return True
|
| 125 |
except Exception as e:
|
| 126 |
logger.error(f"Image error: {str(e)}")
|
| 127 |
pdf.error = str(e)
|
| 128 |
return False
|
| 129 |
|
| 130 |
-
async def
|
|
|
|
| 131 |
try:
|
| 132 |
pdf_document = fitz.open(stream=file_content, filetype="pdf")
|
| 133 |
pdf.page_count = len(pdf_document)
|
| 134 |
|
| 135 |
images = []
|
|
|
|
| 136 |
for page_num in range(len(pdf_document)):
|
| 137 |
page = pdf_document[page_num]
|
|
|
|
|
|
|
| 138 |
image_list = page.get_images(full=True)
|
| 139 |
|
| 140 |
-
if
|
| 141 |
-
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
|
| 142 |
-
img = Image.open(BytesIO(pix.tobytes("png")))
|
| 143 |
-
images.append(img)
|
| 144 |
-
else:
|
| 145 |
for img_info in image_list:
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
pdf.images = images
|
| 153 |
-
|
| 154 |
-
|
|
|
|
| 155 |
|
| 156 |
except Exception as e:
|
| 157 |
-
logger.error(f"
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
return False
|
| 160 |
|
| 161 |
-
async def analyze_floor_plan(self, pdf_id, description=None
|
|
|
|
|
|
|
| 162 |
pdf = self.pdfs.get(pdf_id)
|
| 163 |
if not pdf:
|
| 164 |
raise ValueError(f"PDF {pdf_id} not found")
|
|
@@ -169,25 +244,110 @@ class FloorPlanProcessor:
|
|
| 169 |
logger.info(f"\n{'='*70}")
|
| 170 |
logger.info(f"Analyzing: {pdf_id}")
|
| 171 |
logger.info(f"Images: {len(pdf.images)}")
|
|
|
|
| 172 |
logger.info(f"{'='*70}")
|
| 173 |
|
| 174 |
-
#
|
| 175 |
-
best_image = self.
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
# Try analysis with extended timeout
|
| 181 |
-
max_retries = 3
|
| 182 |
for attempt in range(max_retries):
|
| 183 |
try:
|
| 184 |
-
logger.info(f"\
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
result = await self._analyze_with_gemini(
|
| 187 |
-
|
| 188 |
-
|
| 189 |
description,
|
| 190 |
-
timeout=
|
|
|
|
| 191 |
attempt=attempt
|
| 192 |
)
|
| 193 |
|
|
@@ -198,71 +358,39 @@ class FloorPlanProcessor:
|
|
| 198 |
except asyncio.TimeoutError:
|
| 199 |
logger.warning(f"Timeout on attempt {attempt + 1}")
|
| 200 |
if attempt < max_retries - 1:
|
| 201 |
-
await asyncio.sleep(
|
| 202 |
-
continue
|
| 203 |
|
| 204 |
except Exception as e:
|
| 205 |
error_str = str(e)
|
| 206 |
logger.error(f"Attempt {attempt + 1} error: {error_str}")
|
| 207 |
|
| 208 |
# Check for retryable errors
|
| 209 |
-
if any(k in error_str.lower() for k in ['504', '503', '429', 'timeout', 'deadline']):
|
| 210 |
if attempt < max_retries - 1:
|
| 211 |
-
wait =
|
| 212 |
logger.info(f"Waiting {wait}s before retry...")
|
| 213 |
await asyncio.sleep(wait)
|
| 214 |
continue
|
| 215 |
|
| 216 |
# Non-retryable error
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
return self._generate_fallback(pdf.measurement_info)
|
| 222 |
-
|
| 223 |
-
def _select_single_best_image(self, images):
|
| 224 |
-
"""Select the single best image"""
|
| 225 |
-
if len(images) == 1:
|
| 226 |
-
return images[0]
|
| 227 |
-
|
| 228 |
-
# Score by area (largest = best for floor plans)
|
| 229 |
-
scored = [(img.size[0] * img.size[1], img) for img in images]
|
| 230 |
-
scored.sort(reverse=True, key=lambda x: x[0])
|
| 231 |
-
|
| 232 |
-
best = scored[0][1]
|
| 233 |
-
logger.info(f"Selected best from {len(images)} images")
|
| 234 |
-
return best
|
| 235 |
-
|
| 236 |
-
def _optimize_image(self, image, target_size=2048):
|
| 237 |
-
"""Optimize image for analysis"""
|
| 238 |
-
if image.mode not in ('RGB', 'L'):
|
| 239 |
-
image = image.convert('RGB')
|
| 240 |
-
|
| 241 |
-
width, height = image.size
|
| 242 |
-
|
| 243 |
-
if width > target_size or height > target_size:
|
| 244 |
-
ratio = target_size / max(width, height)
|
| 245 |
-
new_width = int(width * ratio)
|
| 246 |
-
new_height = int(height * ratio)
|
| 247 |
-
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
| 248 |
-
logger.info(f"Resized: {width}x{height} β {new_width}x{new_height}")
|
| 249 |
-
|
| 250 |
-
return image
|
| 251 |
|
| 252 |
-
async def _analyze_with_gemini(self, image, measurement_info
|
| 253 |
-
|
| 254 |
-
|
|
|
|
| 255 |
|
| 256 |
-
|
| 257 |
-
temperature = 0.2 if attempt == 0 else 0.3
|
| 258 |
-
max_tokens = 16384
|
| 259 |
|
| 260 |
-
logger.info(f"Config: temp={temperature},
|
| 261 |
|
| 262 |
start_time = time.time()
|
| 263 |
loop = asyncio.get_event_loop()
|
| 264 |
|
| 265 |
-
#
|
| 266 |
safety_settings = {
|
| 267 |
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
|
| 268 |
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
|
|
@@ -275,7 +403,7 @@ class FloorPlanProcessor:
|
|
| 275 |
[prompt, image],
|
| 276 |
generation_config=genai.GenerationConfig(
|
| 277 |
temperature=temperature,
|
| 278 |
-
max_output_tokens=
|
| 279 |
top_p=0.95,
|
| 280 |
top_k=40,
|
| 281 |
),
|
|
@@ -292,99 +420,158 @@ class FloorPlanProcessor:
|
|
| 292 |
elapsed = time.time() - start_time
|
| 293 |
logger.info(f"Response in {elapsed:.1f}s ({len(response.text)} chars)")
|
| 294 |
|
| 295 |
-
# Extract JSON
|
| 296 |
-
parsed = self.
|
| 297 |
|
| 298 |
if parsed and len(parsed) > 0:
|
| 299 |
validated = self._validate_measurements(parsed, measurement_info)
|
| 300 |
logger.info(f"Validated {len(validated)} rooms")
|
| 301 |
return validated
|
| 302 |
else:
|
| 303 |
-
logger.warning("No valid JSON found")
|
| 304 |
return None
|
| 305 |
|
| 306 |
except Exception as e:
|
| 307 |
logger.error(f"Gemini API error: {str(e)}")
|
| 308 |
raise
|
| 309 |
|
| 310 |
-
def
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
-
Returner KUN en JSON-array i dette eksakte formatet:
|
| 316 |
[
|
| 317 |
{{
|
| 318 |
"name": "Living Room",
|
| 319 |
"name_no": "Stue",
|
| 320 |
-
"area_m2":
|
| 321 |
-
"position": "
|
| 322 |
-
"dimensions_m": {{"width":
|
| 323 |
-
"windows":
|
| 324 |
-
"window_positions": ["
|
| 325 |
-
"doors":
|
| 326 |
-
"door_positions": ["
|
| 327 |
-
"connected_rooms": ["
|
| 328 |
"has_external_access": false,
|
| 329 |
"ceiling_height_m": {measurement_info['ceiling_height']},
|
| 330 |
-
"furniture": [],
|
| 331 |
"estimated": false
|
| 332 |
}}
|
| 333 |
]
|
| 334 |
|
| 335 |
-
|
| 336 |
-
1.
|
| 337 |
-
2.
|
| 338 |
-
3.
|
| 339 |
-
4.
|
| 340 |
-
5.
|
| 341 |
-
6.
|
| 342 |
-
7.
|
| 343 |
-
8.
|
| 344 |
-
9. List
|
| 345 |
-
10.
|
| 346 |
-
11.
|
| 347 |
-
12.
|
| 348 |
-
13. Returner KUN JSON-arrayen - absolutt ingen forklaringer, ingen markdown-blokker, ingen ekstra tekst
|
| 349 |
|
| 350 |
-
|
| 351 |
-
- Soverom (
|
| 352 |
- KjΓΈkken (Kitchen)
|
| 353 |
-
- Stue (Living room
|
| 354 |
-
- Bad/Baderom (Bathroom
|
| 355 |
-
- Toalett (
|
| 356 |
- Gang/Korridor (Hallway)
|
| 357 |
-
- EntrΓ© (Entrance
|
| 358 |
-
- Bod
|
| 359 |
-
- Kontor (Office
|
| 360 |
-
- Vaskerom (Laundry
|
| 361 |
-
- Terrasse
|
| 362 |
-
- Garasje (Garage
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
- Vindfang (Mudroom)
|
| 367 |
-
- Trapperom (Stairwell, Trapp)
|
| 368 |
-
- Loft/Hems (Attic, Loft)
|
| 369 |
-
- Kjeller (Basement)
|
| 370 |
|
| 371 |
-
|
| 372 |
-
Standard takhΓΈyde: {measurement_info['ceiling_height']}m
|
| 373 |
-
"""
|
| 374 |
|
| 375 |
if description:
|
| 376 |
-
prompt += f"\n\
|
| 377 |
|
| 378 |
return prompt
|
| 379 |
|
| 380 |
-
def
|
| 381 |
-
"""
|
| 382 |
if not text:
|
| 383 |
return None
|
| 384 |
|
| 385 |
-
#
|
| 386 |
text = text.strip()
|
| 387 |
-
|
|
|
|
|
|
|
|
|
|
| 388 |
text = text.strip('`').strip()
|
| 389 |
|
| 390 |
# Try direct parse
|
|
@@ -392,39 +579,54 @@ Standard takhΓΈyde: {measurement_info['ceiling_height']}m
|
|
| 392 |
data = json.loads(text)
|
| 393 |
if isinstance(data, list) and len(data) > 0:
|
| 394 |
return data
|
| 395 |
-
except json.JSONDecodeError:
|
| 396 |
-
|
| 397 |
|
| 398 |
-
# Find JSON array
|
| 399 |
patterns = [
|
| 400 |
-
r'\[\s*\{[
|
| 401 |
r'\[[\s\S]*?\]',
|
| 402 |
]
|
| 403 |
|
| 404 |
for pattern in patterns:
|
| 405 |
-
matches = list(re.finditer(pattern, text))
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
try:
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
if isinstance(data, list) and len(data) > 0:
|
| 410 |
return data
|
| 411 |
-
except:
|
|
|
|
| 412 |
continue
|
| 413 |
|
| 414 |
-
logger.warning(f"Could not extract JSON from
|
| 415 |
return None
|
| 416 |
|
| 417 |
-
def _validate_measurements(self, data, measurement_info):
|
| 418 |
-
"""
|
| 419 |
if not isinstance(data, list):
|
| 420 |
return []
|
| 421 |
|
| 422 |
ceiling = measurement_info.get('ceiling_height', 2.4)
|
|
|
|
| 423 |
|
| 424 |
for room in data:
|
| 425 |
-
#
|
| 426 |
-
room
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
room.setdefault("ceiling_height_m", ceiling)
|
| 429 |
room.setdefault("windows", 0)
|
| 430 |
room.setdefault("doors", 1)
|
|
@@ -433,51 +635,103 @@ Standard takhΓΈyde: {measurement_info['ceiling_height']}m
|
|
| 433 |
room.setdefault("connected_rooms", [])
|
| 434 |
room.setdefault("window_positions", [])
|
| 435 |
room.setdefault("door_positions", [])
|
|
|
|
|
|
|
| 436 |
|
| 437 |
# Fix dimensions
|
| 438 |
-
if "dimensions_m" not in room:
|
| 439 |
room["dimensions_m"] = {"width": 0, "length": 0}
|
| 440 |
|
| 441 |
-
|
| 442 |
-
|
|
|
|
|
|
|
| 443 |
|
|
|
|
| 444 |
if width > 0 and length > 0:
|
| 445 |
room["area_m2"] = round(width * length, 1)
|
| 446 |
-
elif
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
else:
|
|
|
|
| 452 |
room["dimensions_m"] = {"width": 3.0, "length": 3.0}
|
| 453 |
room["area_m2"] = 9.0
|
| 454 |
room["estimated"] = True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
|
| 456 |
-
return
|
| 457 |
|
| 458 |
-
def _generate_fallback(self, measurement_info):
|
| 459 |
-
"""Generate fallback structure"""
|
| 460 |
ceiling = measurement_info.get('ceiling_height', 2.4)
|
| 461 |
|
| 462 |
return [
|
| 463 |
{
|
| 464 |
-
"name": "Living Room",
|
| 465 |
-
"
|
| 466 |
-
"
|
| 467 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
"ceiling_height_m": ceiling,
|
| 469 |
"estimated": True,
|
| 470 |
-
"furniture": [],
|
| 471 |
-
"connected_rooms": [],
|
| 472 |
-
"window_positions": [],
|
| 473 |
-
"door_positions": [],
|
| 474 |
"has_external_access": False
|
| 475 |
}
|
| 476 |
]
|
| 477 |
|
|
|
|
| 478 |
app = FastAPI(
|
| 479 |
-
title="Floor Plan API",
|
| 480 |
-
version="
|
| 481 |
docs_url="/"
|
| 482 |
)
|
| 483 |
|
|
@@ -493,35 +747,52 @@ processor = FloorPlanProcessor()
|
|
| 493 |
|
| 494 |
@app.get("/status")
|
| 495 |
async def get_status():
|
|
|
|
| 496 |
return {
|
| 497 |
"status": "running",
|
| 498 |
"pdfs_count": len(processor.pdfs),
|
| 499 |
-
"model": "gemini-2.
|
|
|
|
|
|
|
| 500 |
}
|
| 501 |
|
| 502 |
@app.get("/pdfs")
|
| 503 |
async def get_pdfs():
|
|
|
|
| 504 |
return {"pdfs": [pdf.to_dict() for pdf in processor.pdfs.values()]}
|
| 505 |
|
| 506 |
@app.get("/pdf/{pdf_id}")
|
| 507 |
async def get_pdf(pdf_id: str):
|
|
|
|
| 508 |
if pdf_id not in processor.pdfs:
|
| 509 |
raise HTTPException(status_code=404, detail="PDF not found")
|
| 510 |
return processor.pdfs[pdf_id].to_dict()
|
| 511 |
|
| 512 |
@app.post("/upload")
|
| 513 |
async def upload_pdf(file: UploadFile = File(...)):
|
|
|
|
| 514 |
content_type = file.content_type.lower()
|
| 515 |
supported = ["application/pdf"] + list(processor.supported_image_formats.keys())
|
| 516 |
|
| 517 |
if content_type not in supported:
|
| 518 |
return JSONResponse(
|
| 519 |
status_code=400,
|
| 520 |
-
content={
|
|
|
|
|
|
|
|
|
|
| 521 |
)
|
| 522 |
|
| 523 |
try:
|
| 524 |
file_content = await file.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
pdf_id = await processor.process_upload(file_content, file.filename, content_type)
|
| 526 |
pdf_info = processor.pdfs[pdf_id].to_dict()
|
| 527 |
|
|
@@ -532,37 +803,53 @@ async def upload_pdf(file: UploadFile = File(...)):
|
|
| 532 |
}
|
| 533 |
except Exception as e:
|
| 534 |
logger.error(f"Upload error: {str(e)}")
|
| 535 |
-
return JSONResponse(
|
|
|
|
|
|
|
|
|
|
| 536 |
|
| 537 |
@app.post("/analyze/{pdf_id}")
|
| 538 |
async def analyze_pdf(pdf_id: str, query: FloorPlanQuery = None):
|
|
|
|
| 539 |
if pdf_id not in processor.pdfs:
|
| 540 |
raise HTTPException(status_code=404, detail="PDF not found")
|
| 541 |
|
| 542 |
pdf = processor.pdfs[pdf_id]
|
| 543 |
|
| 544 |
if not pdf.processed:
|
| 545 |
-
return JSONResponse(
|
|
|
|
|
|
|
|
|
|
| 546 |
|
| 547 |
if not pdf.images:
|
| 548 |
-
return JSONResponse(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
|
| 550 |
try:
|
|
|
|
| 551 |
description = query.description if query else None
|
|
|
|
|
|
|
| 552 |
start_time = time.time()
|
| 553 |
|
|
|
|
| 554 |
result = await asyncio.wait_for(
|
| 555 |
-
processor.analyze_floor_plan(pdf_id, description),
|
| 556 |
-
timeout=
|
| 557 |
)
|
| 558 |
|
| 559 |
elapsed = time.time() - start_time
|
| 560 |
pdf.analysis_result = result
|
| 561 |
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
for room in result
|
| 565 |
-
)
|
| 566 |
|
| 567 |
return {
|
| 568 |
"message": "Analysis complete",
|
|
@@ -571,16 +858,32 @@ async def analyze_pdf(pdf_id: str, query: FloorPlanQuery = None):
|
|
| 571 |
"rooms": result,
|
| 572 |
"analysis_time_seconds": round(elapsed, 1),
|
| 573 |
"is_estimated": is_fallback,
|
| 574 |
-
"room_count": len(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
}
|
| 576 |
|
| 577 |
except Exception as e:
|
| 578 |
logger.error(f"Analysis error: {str(e)}", exc_info=True)
|
| 579 |
|
|
|
|
| 580 |
try:
|
| 581 |
fallback = processor._generate_fallback(pdf.measurement_info)
|
| 582 |
return {
|
| 583 |
-
"message": "
|
| 584 |
"pdf_id": pdf_id,
|
| 585 |
"rooms": fallback,
|
| 586 |
"is_estimated": True,
|
|
@@ -589,18 +892,26 @@ async def analyze_pdf(pdf_id: str, query: FloorPlanQuery = None):
|
|
| 589 |
except:
|
| 590 |
return JSONResponse(
|
| 591 |
status_code=500,
|
| 592 |
-
content={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
)
|
| 594 |
|
| 595 |
@app.post("/room/{pdf_id}")
|
| 596 |
async def find_room(pdf_id: str, query: RoomQuery):
|
|
|
|
| 597 |
if pdf_id not in processor.pdfs:
|
| 598 |
raise HTTPException(status_code=404, detail="PDF not found")
|
| 599 |
|
| 600 |
pdf = processor.pdfs[pdf_id]
|
| 601 |
|
| 602 |
if not pdf.analysis_result:
|
| 603 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
| 604 |
|
| 605 |
found = []
|
| 606 |
name_lower = query.room_name.lower()
|
|
@@ -613,32 +924,165 @@ async def find_room(pdf_id: str, query: RoomQuery):
|
|
| 613 |
if en == name_lower or no == name_lower:
|
| 614 |
found.append(room)
|
| 615 |
else:
|
| 616 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
found.append(room)
|
| 618 |
|
| 619 |
if not found:
|
| 620 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
| 621 |
|
| 622 |
if len(found) == 1:
|
| 623 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
|
| 625 |
return {
|
| 626 |
-
"message":
|
| 627 |
"pdf_id": pdf_id,
|
| 628 |
-
"
|
| 629 |
}
|
| 630 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
@app.on_event("startup")
|
| 632 |
async def startup_event():
|
|
|
|
|
|
|
| 633 |
os.makedirs("uploads", exist_ok=True)
|
|
|
|
| 634 |
os.makedirs("logs", exist_ok=True)
|
| 635 |
|
| 636 |
-
logger.info("\n" + "="*
|
| 637 |
-
logger.info("Floor Plan API
|
| 638 |
-
logger.info(f"Model: gemini-2.
|
| 639 |
-
logger.info(f"API
|
| 640 |
-
logger.info(f"
|
| 641 |
-
logger.info("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
|
| 643 |
if __name__ == "__main__":
|
| 644 |
-
uvicorn.run(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import asyncio
|
| 3 |
+
from typing import Optional, List, Dict, Any
|
| 4 |
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
from fastapi.responses import JSONResponse
|
|
|
|
| 12 |
from io import BytesIO
|
| 13 |
import math
|
| 14 |
import time
|
| 15 |
+
import fitz # PyMuPDF
|
| 16 |
from PIL import Image
|
| 17 |
import google.generativeai as genai
|
| 18 |
from google.generativeai.types import HarmCategory, HarmBlockThreshold
|
| 19 |
+
import numpy as np
|
| 20 |
+
from pdf2image import convert_from_bytes
|
| 21 |
+
import tempfile
|
| 22 |
|
| 23 |
+
# Enhanced logging configuration
|
| 24 |
logging.basicConfig(
|
| 25 |
level=logging.INFO,
|
| 26 |
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
|
|
| 31 |
)
|
| 32 |
logger = logging.getLogger(__name__)
|
| 33 |
|
| 34 |
+
# Configuration
|
| 35 |
GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY')
|
| 36 |
+
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') # Optional: for GPT-4V fallback
|
| 37 |
+
|
| 38 |
if not GOOGLE_API_KEY:
|
| 39 |
logger.warning("GOOGLE_API_KEY not set!")
|
| 40 |
else:
|
| 41 |
genai.configure(api_key=GOOGLE_API_KEY)
|
| 42 |
|
| 43 |
+
# Optional: Import OpenAI for fallback
|
| 44 |
+
try:
|
| 45 |
+
import openai
|
| 46 |
+
if OPENAI_API_KEY:
|
| 47 |
+
openai.api_key = OPENAI_API_KEY
|
| 48 |
+
USE_OPENAI_FALLBACK = True
|
| 49 |
+
else:
|
| 50 |
+
USE_OPENAI_FALLBACK = False
|
| 51 |
+
except ImportError:
|
| 52 |
+
USE_OPENAI_FALLBACK = False
|
| 53 |
+
logger.info("OpenAI not available for fallback")
|
| 54 |
+
|
| 55 |
os.makedirs("uploads", exist_ok=True)
|
| 56 |
+
os.makedirs("temp", exist_ok=True)
|
| 57 |
|
| 58 |
class FloorPlanQuery(BaseModel):
|
| 59 |
description: Optional[str] = None
|
| 60 |
+
force_ocr: bool = False
|
| 61 |
+
use_high_quality: bool = True
|
| 62 |
|
| 63 |
class RoomQuery(BaseModel):
|
| 64 |
room_name: str
|
|
|
|
| 80 |
"room_dimensions": {}
|
| 81 |
}
|
| 82 |
self.analysis_result = None
|
| 83 |
+
self.extraction_method = None
|
| 84 |
|
| 85 |
def to_dict(self):
|
| 86 |
return {
|
|
|
|
| 94 |
"image_count": len(self.images) if self.images else 0,
|
| 95 |
"measurement_info": self.measurement_info,
|
| 96 |
"has_analysis": self.analysis_result is not None,
|
| 97 |
+
"room_count": len(self.analysis_result) if self.analysis_result else 0,
|
| 98 |
+
"extraction_method": self.extraction_method
|
| 99 |
}
|
| 100 |
|
| 101 |
class FloorPlanProcessor:
|
| 102 |
def __init__(self):
|
| 103 |
+
self.model = genai.GenerativeModel('gemini-2.0-flash-exp') # Using newer, faster model
|
| 104 |
self.pdfs = {}
|
| 105 |
self.supported_image_formats = {
|
| 106 |
"image/jpeg": ".jpg",
|
|
|
|
| 111 |
"image/webp": ".webp"
|
| 112 |
}
|
| 113 |
|
| 114 |
+
async def process_upload(self, file_content: bytes, filename: str, content_type: str):
|
| 115 |
+
"""Enhanced upload processing with better PDF handling"""
|
| 116 |
pdf_id = re.sub(r'[^a-zA-Z0-9]', '_', filename)
|
| 117 |
logger.info(f"Processing {filename} (ID: {pdf_id})")
|
| 118 |
|
|
|
|
| 126 |
f.write(file_content)
|
| 127 |
|
| 128 |
if content_type == "application/pdf":
|
| 129 |
+
# Try multiple extraction methods
|
| 130 |
+
success = await self.extract_images_from_pdf_enhanced(pdf, file_content)
|
| 131 |
+
if not success:
|
| 132 |
+
logger.warning("Primary extraction failed, trying fallback methods")
|
| 133 |
+
success = await self.extract_with_pdf2image(pdf, file_content)
|
| 134 |
+
|
| 135 |
+
if not success and len(pdf.images) == 0:
|
| 136 |
+
raise ValueError("Could not extract any images from PDF")
|
| 137 |
+
|
| 138 |
elif content_type in self.supported_image_formats:
|
| 139 |
await self.process_image(pdf, file_content)
|
| 140 |
else:
|
| 141 |
raise ValueError(f"Unsupported type: {content_type}")
|
| 142 |
|
| 143 |
pdf.processed = True
|
| 144 |
+
logger.info(f"Processing complete: {pdf_id} with {len(pdf.images)} images")
|
| 145 |
return pdf_id
|
| 146 |
|
| 147 |
except Exception as e:
|
|
|
|
| 149 |
pdf.error = str(e)
|
| 150 |
return pdf_id
|
| 151 |
|
| 152 |
+
async def process_image(self, pdf: PDF, file_content: bytes):
|
| 153 |
+
"""Process single image file"""
|
| 154 |
try:
|
| 155 |
img = Image.open(BytesIO(file_content))
|
| 156 |
logger.info(f"Image: {img.width}x{img.height}")
|
| 157 |
pdf.images.append(img)
|
| 158 |
+
pdf.extraction_method = "direct_image"
|
| 159 |
return True
|
| 160 |
except Exception as e:
|
| 161 |
logger.error(f"Image error: {str(e)}")
|
| 162 |
pdf.error = str(e)
|
| 163 |
return False
|
| 164 |
|
| 165 |
+
async def extract_images_from_pdf_enhanced(self, pdf: PDF, file_content: bytes) -> bool:
|
| 166 |
+
"""Enhanced PDF extraction with multiple strategies"""
|
| 167 |
try:
|
| 168 |
pdf_document = fitz.open(stream=file_content, filetype="pdf")
|
| 169 |
pdf.page_count = len(pdf_document)
|
| 170 |
|
| 171 |
images = []
|
| 172 |
+
|
| 173 |
for page_num in range(len(pdf_document)):
|
| 174 |
page = pdf_document[page_num]
|
| 175 |
+
|
| 176 |
+
# Strategy 1: Try to get embedded images
|
| 177 |
image_list = page.get_images(full=True)
|
| 178 |
|
| 179 |
+
if image_list:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
for img_info in image_list:
|
| 181 |
+
try:
|
| 182 |
+
xref = img_info[0]
|
| 183 |
+
base_image = pdf_document.extract_image(xref)
|
| 184 |
+
img = Image.open(BytesIO(base_image["image"]))
|
| 185 |
+
if img.width > 100 and img.height > 100:
|
| 186 |
+
images.append(img)
|
| 187 |
+
logger.info(f"Extracted embedded image: {img.width}x{img.height}")
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.warning(f"Failed to extract image {xref}: {str(e)}")
|
| 190 |
+
|
| 191 |
+
# Strategy 2: Render page as image (high quality)
|
| 192 |
+
if not image_list or len(images) == 0:
|
| 193 |
+
try:
|
| 194 |
+
# Higher resolution for better OCR
|
| 195 |
+
mat = fitz.Matrix(3, 3) # 3x zoom for better quality
|
| 196 |
+
pix = page.get_pixmap(matrix=mat, alpha=False)
|
| 197 |
+
img = Image.open(BytesIO(pix.tobytes("png")))
|
| 198 |
+
images.append(img)
|
| 199 |
+
logger.info(f"Rendered page {page_num + 1} as image: {img.width}x{img.height}")
|
| 200 |
+
except Exception as e:
|
| 201 |
+
logger.error(f"Failed to render page {page_num + 1}: {str(e)}")
|
| 202 |
|
| 203 |
pdf.images = images
|
| 204 |
+
pdf.extraction_method = "pymupdf"
|
| 205 |
+
logger.info(f"Extracted {len(images)} images using PyMuPDF")
|
| 206 |
+
return len(images) > 0
|
| 207 |
|
| 208 |
except Exception as e:
|
| 209 |
+
logger.error(f"PyMuPDF extraction error: {str(e)}")
|
| 210 |
+
return False
|
| 211 |
+
|
| 212 |
+
async def extract_with_pdf2image(self, pdf: PDF, file_content: bytes) -> bool:
|
| 213 |
+
"""Fallback extraction using pdf2image (requires poppler)"""
|
| 214 |
+
try:
|
| 215 |
+
# Convert PDF to images using pdf2image
|
| 216 |
+
images = convert_from_bytes(
|
| 217 |
+
file_content,
|
| 218 |
+
dpi=300, # High DPI for better quality
|
| 219 |
+
fmt='png',
|
| 220 |
+
thread_count=4,
|
| 221 |
+
use_pdftocairo=True # Better quality renderer
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
pdf.images = images
|
| 225 |
+
pdf.extraction_method = "pdf2image"
|
| 226 |
+
logger.info(f"Extracted {len(images)} images using pdf2image")
|
| 227 |
+
return len(images) > 0
|
| 228 |
+
|
| 229 |
+
except Exception as e:
|
| 230 |
+
logger.error(f"pdf2image extraction error: {str(e)}")
|
| 231 |
+
logger.info("Note: pdf2image requires poppler-utils to be installed")
|
| 232 |
return False
|
| 233 |
|
| 234 |
+
async def analyze_floor_plan(self, pdf_id: str, description: Optional[str] = None,
|
| 235 |
+
use_high_quality: bool = True) -> List[Dict[str, Any]]:
|
| 236 |
+
"""Enhanced analysis with better error handling and multiple models"""
|
| 237 |
pdf = self.pdfs.get(pdf_id)
|
| 238 |
if not pdf:
|
| 239 |
raise ValueError(f"PDF {pdf_id} not found")
|
|
|
|
| 244 |
logger.info(f"\n{'='*70}")
|
| 245 |
logger.info(f"Analyzing: {pdf_id}")
|
| 246 |
logger.info(f"Images: {len(pdf.images)}")
|
| 247 |
+
logger.info(f"Extraction method: {pdf.extraction_method}")
|
| 248 |
logger.info(f"{'='*70}")
|
| 249 |
|
| 250 |
+
# Select and optimize best image
|
| 251 |
+
best_image = self._select_best_image_enhanced(pdf.images)
|
| 252 |
+
|
| 253 |
+
# Use higher resolution for PDFs that were rendered
|
| 254 |
+
target_size = 3072 if use_high_quality else 2048
|
| 255 |
+
optimized_image = self._optimize_image(best_image, target_size=target_size)
|
| 256 |
+
|
| 257 |
+
logger.info(f"Using image: {optimized_image.size[0]}x{optimized_image.size[1]}px")
|
| 258 |
+
|
| 259 |
+
# Try primary model
|
| 260 |
+
result = await self._try_analysis_with_retries(
|
| 261 |
+
optimized_image,
|
| 262 |
+
pdf.measurement_info,
|
| 263 |
+
description,
|
| 264 |
+
max_retries=3
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
if result and len(result) > 0:
|
| 268 |
+
pdf.analysis_result = result
|
| 269 |
+
return result
|
| 270 |
+
|
| 271 |
+
# Try fallback with different model if available
|
| 272 |
+
if USE_OPENAI_FALLBACK:
|
| 273 |
+
logger.info("Trying OpenAI GPT-4V as fallback")
|
| 274 |
+
result = await self._analyze_with_gpt4v(optimized_image, pdf.measurement_info, description)
|
| 275 |
+
if result and len(result) > 0:
|
| 276 |
+
pdf.analysis_result = result
|
| 277 |
+
return result
|
| 278 |
+
|
| 279 |
+
# Final fallback
|
| 280 |
+
logger.warning("All analysis attempts failed, using fallback")
|
| 281 |
+
result = self._generate_fallback(pdf.measurement_info)
|
| 282 |
+
pdf.analysis_result = result
|
| 283 |
+
return result
|
| 284 |
+
|
| 285 |
+
def _select_best_image_enhanced(self, images: List[Image.Image]) -> Image.Image:
|
| 286 |
+
"""Enhanced image selection with quality scoring"""
|
| 287 |
+
if len(images) == 1:
|
| 288 |
+
return images[0]
|
| 289 |
+
|
| 290 |
+
best_score = -1
|
| 291 |
+
best_image = images[0]
|
| 292 |
+
|
| 293 |
+
for img in images:
|
| 294 |
+
# Score based on resolution and aspect ratio
|
| 295 |
+
area = img.width * img.height
|
| 296 |
+
aspect_ratio = img.width / img.height if img.height > 0 else 0
|
| 297 |
+
|
| 298 |
+
# Prefer landscape orientation (typical for floor plans)
|
| 299 |
+
aspect_score = 1.0 if 1.0 <= aspect_ratio <= 2.0 else 0.5
|
| 300 |
+
|
| 301 |
+
# Combine scores
|
| 302 |
+
score = area * aspect_score
|
| 303 |
+
|
| 304 |
+
if score > best_score:
|
| 305 |
+
best_score = score
|
| 306 |
+
best_image = img
|
| 307 |
+
|
| 308 |
+
logger.info(f"Selected best image from {len(images)} options")
|
| 309 |
+
return best_image
|
| 310 |
+
|
| 311 |
+
def _optimize_image(self, image: Image.Image, target_size: int = 2048) -> Image.Image:
|
| 312 |
+
"""Optimize image with enhanced preprocessing"""
|
| 313 |
+
# Convert to RGB if needed
|
| 314 |
+
if image.mode not in ('RGB', 'L'):
|
| 315 |
+
image = image.convert('RGB')
|
| 316 |
|
| 317 |
+
# Apply contrast enhancement for better OCR
|
| 318 |
+
from PIL import ImageEnhance
|
| 319 |
+
enhancer = ImageEnhance.Contrast(image)
|
| 320 |
+
image = enhancer.enhance(1.2)
|
| 321 |
+
|
| 322 |
+
# Resize if needed
|
| 323 |
+
width, height = image.size
|
| 324 |
+
if width > target_size or height > target_size:
|
| 325 |
+
ratio = target_size / max(width, height)
|
| 326 |
+
new_width = int(width * ratio)
|
| 327 |
+
new_height = int(height * ratio)
|
| 328 |
+
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
| 329 |
+
logger.info(f"Resized: {width}x{height} β {new_width}x{new_height}")
|
| 330 |
+
|
| 331 |
+
return image
|
| 332 |
+
|
| 333 |
+
async def _try_analysis_with_retries(self, image: Image.Image, measurement_info: dict,
|
| 334 |
+
description: Optional[str], max_retries: int = 3) -> Optional[List[Dict]]:
|
| 335 |
+
"""Enhanced retry logic with progressive adjustments"""
|
| 336 |
|
|
|
|
|
|
|
| 337 |
for attempt in range(max_retries):
|
| 338 |
try:
|
| 339 |
+
logger.info(f"\nAnalysis attempt {attempt + 1}/{max_retries}")
|
| 340 |
+
|
| 341 |
+
# Adjust parameters based on attempt
|
| 342 |
+
temperature = 0.1 + (attempt * 0.1) # Increase creativity on retries
|
| 343 |
+
timeout = 300 + (attempt * 150) # Increase timeout progressively
|
| 344 |
|
| 345 |
result = await self._analyze_with_gemini(
|
| 346 |
+
image,
|
| 347 |
+
measurement_info,
|
| 348 |
description,
|
| 349 |
+
timeout=timeout,
|
| 350 |
+
temperature=temperature,
|
| 351 |
attempt=attempt
|
| 352 |
)
|
| 353 |
|
|
|
|
| 358 |
except asyncio.TimeoutError:
|
| 359 |
logger.warning(f"Timeout on attempt {attempt + 1}")
|
| 360 |
if attempt < max_retries - 1:
|
| 361 |
+
await asyncio.sleep(5 * (attempt + 1))
|
|
|
|
| 362 |
|
| 363 |
except Exception as e:
|
| 364 |
error_str = str(e)
|
| 365 |
logger.error(f"Attempt {attempt + 1} error: {error_str}")
|
| 366 |
|
| 367 |
# Check for retryable errors
|
| 368 |
+
if any(k in error_str.lower() for k in ['504', '503', '429', 'timeout', 'deadline', 'rate']):
|
| 369 |
if attempt < max_retries - 1:
|
| 370 |
+
wait = 10 * (attempt + 1)
|
| 371 |
logger.info(f"Waiting {wait}s before retry...")
|
| 372 |
await asyncio.sleep(wait)
|
| 373 |
continue
|
| 374 |
|
| 375 |
# Non-retryable error
|
| 376 |
+
if attempt == max_retries - 1:
|
| 377 |
+
logger.error(f"Final attempt failed: {error_str}")
|
| 378 |
+
|
| 379 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
|
| 381 |
+
async def _analyze_with_gemini(self, image: Image.Image, measurement_info: dict,
|
| 382 |
+
description: Optional[str], timeout: int,
|
| 383 |
+
temperature: float = 0.2, attempt: int = 0) -> Optional[List[Dict]]:
|
| 384 |
+
"""Enhanced Gemini analysis with better prompting"""
|
| 385 |
|
| 386 |
+
prompt = self._create_enhanced_prompt(description, measurement_info, attempt)
|
|
|
|
|
|
|
| 387 |
|
| 388 |
+
logger.info(f"Config: temp={temperature}, timeout={timeout}s")
|
| 389 |
|
| 390 |
start_time = time.time()
|
| 391 |
loop = asyncio.get_event_loop()
|
| 392 |
|
| 393 |
+
# Safety settings
|
| 394 |
safety_settings = {
|
| 395 |
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
|
| 396 |
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
|
|
|
|
| 403 |
[prompt, image],
|
| 404 |
generation_config=genai.GenerationConfig(
|
| 405 |
temperature=temperature,
|
| 406 |
+
max_output_tokens=32768, # Increased for complex floor plans
|
| 407 |
top_p=0.95,
|
| 408 |
top_k=40,
|
| 409 |
),
|
|
|
|
| 420 |
elapsed = time.time() - start_time
|
| 421 |
logger.info(f"Response in {elapsed:.1f}s ({len(response.text)} chars)")
|
| 422 |
|
| 423 |
+
# Extract and validate JSON
|
| 424 |
+
parsed = self._extract_json_enhanced(response.text)
|
| 425 |
|
| 426 |
if parsed and len(parsed) > 0:
|
| 427 |
validated = self._validate_measurements(parsed, measurement_info)
|
| 428 |
logger.info(f"Validated {len(validated)} rooms")
|
| 429 |
return validated
|
| 430 |
else:
|
| 431 |
+
logger.warning("No valid JSON found in response")
|
| 432 |
return None
|
| 433 |
|
| 434 |
except Exception as e:
|
| 435 |
logger.error(f"Gemini API error: {str(e)}")
|
| 436 |
raise
|
| 437 |
|
| 438 |
+
async def _analyze_with_gpt4v(self, image: Image.Image, measurement_info: dict,
|
| 439 |
+
description: Optional[str]) -> Optional[List[Dict]]:
|
| 440 |
+
"""Fallback analysis using OpenAI GPT-4V"""
|
| 441 |
+
if not USE_OPENAI_FALLBACK:
|
| 442 |
+
return None
|
| 443 |
+
|
| 444 |
+
try:
|
| 445 |
+
import base64
|
| 446 |
+
from openai import OpenAI
|
| 447 |
+
|
| 448 |
+
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 449 |
+
|
| 450 |
+
# Convert image to base64
|
| 451 |
+
buffered = BytesIO()
|
| 452 |
+
image.save(buffered, format="PNG")
|
| 453 |
+
img_base64 = base64.b64encode(buffered.getvalue()).decode()
|
| 454 |
+
|
| 455 |
+
prompt = self._create_enhanced_prompt(description, measurement_info, 0)
|
| 456 |
+
|
| 457 |
+
response = client.chat.completions.create(
|
| 458 |
+
model="gpt-4-vision-preview",
|
| 459 |
+
messages=[
|
| 460 |
+
{
|
| 461 |
+
"role": "user",
|
| 462 |
+
"content": [
|
| 463 |
+
{"type": "text", "text": prompt},
|
| 464 |
+
{
|
| 465 |
+
"type": "image_url",
|
| 466 |
+
"image_url": {
|
| 467 |
+
"url": f"data:image/png;base64,{img_base64}",
|
| 468 |
+
"detail": "high"
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
]
|
| 472 |
+
}
|
| 473 |
+
],
|
| 474 |
+
max_tokens=4096,
|
| 475 |
+
temperature=0.2
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
result_text = response.choices[0].message.content
|
| 479 |
+
parsed = self._extract_json_enhanced(result_text)
|
| 480 |
+
|
| 481 |
+
if parsed and len(parsed) > 0:
|
| 482 |
+
validated = self._validate_measurements(parsed, measurement_info)
|
| 483 |
+
logger.info(f"GPT-4V found {len(validated)} rooms")
|
| 484 |
+
return validated
|
| 485 |
+
|
| 486 |
+
except Exception as e:
|
| 487 |
+
logger.error(f"GPT-4V analysis error: {str(e)}")
|
| 488 |
+
|
| 489 |
+
return None
|
| 490 |
+
|
| 491 |
+
def _create_enhanced_prompt(self, description: Optional[str], measurement_info: dict, attempt: int = 0) -> str:
|
| 492 |
+
"""Enhanced prompt with better instructions"""
|
| 493 |
|
| 494 |
+
# Adjust prompt strategy based on attempt
|
| 495 |
+
if attempt == 0:
|
| 496 |
+
approach = "Focus on text labels and room boundaries visible in the floor plan."
|
| 497 |
+
elif attempt == 1:
|
| 498 |
+
approach = "Look carefully at ALL areas, including small rooms and spaces without clear labels."
|
| 499 |
+
else:
|
| 500 |
+
approach = "Examine the entire image systematically, section by section, identifying every enclosed space."
|
| 501 |
+
|
| 502 |
+
prompt = f"""You are an expert architect analyzing floor plans. {approach}
|
| 503 |
+
|
| 504 |
+
CRITICAL: Analyze this floor plan and extract ALL rooms with their details.
|
| 505 |
+
Return ONLY a JSON array with this EXACT format:
|
| 506 |
|
|
|
|
| 507 |
[
|
| 508 |
{{
|
| 509 |
"name": "Living Room",
|
| 510 |
"name_no": "Stue",
|
| 511 |
+
"area_m2": 35.5,
|
| 512 |
+
"position": "center of plan",
|
| 513 |
+
"dimensions_m": {{"width": 6.0, "length": 5.9}},
|
| 514 |
+
"windows": 3,
|
| 515 |
+
"window_positions": ["north wall", "east wall"],
|
| 516 |
+
"doors": 2,
|
| 517 |
+
"door_positions": ["to hallway", "to kitchen"],
|
| 518 |
+
"connected_rooms": ["Kitchen", "Hallway"],
|
| 519 |
"has_external_access": false,
|
| 520 |
"ceiling_height_m": {measurement_info['ceiling_height']},
|
| 521 |
+
"furniture": ["sofa", "table"],
|
| 522 |
"estimated": false
|
| 523 |
}}
|
| 524 |
]
|
| 525 |
|
| 526 |
+
INSTRUCTIONS:
|
| 527 |
+
1. Find EVERY room visible in the floor plan
|
| 528 |
+
2. Read room names EXACTLY as shown (e.g., "SOV 1", "KJΓKKEN", "STUE", "BAD")
|
| 529 |
+
3. Read the EXACT areas shown (e.g., "25.5 mΒ²", "12.3 mΒ²")
|
| 530 |
+
4. If dimensions are shown, use them exactly
|
| 531 |
+
5. If only area is shown, calculate: width β βarea, length β βarea
|
| 532 |
+
6. Count windows (look for window symbols in walls)
|
| 533 |
+
7. Count doors (look for door swing symbols)
|
| 534 |
+
8. Identify which walls have windows/doors
|
| 535 |
+
9. List connected rooms
|
| 536 |
+
10. Check for external access
|
| 537 |
+
11. Set estimated=false ONLY if exact measurements are visible
|
| 538 |
+
12. List any visible furniture or fixtures
|
|
|
|
| 539 |
|
| 540 |
+
Common Norwegian room types:
|
| 541 |
+
- Soverom/SOV (Bedroom)
|
| 542 |
- KjΓΈkken (Kitchen)
|
| 543 |
+
- Stue (Living room)
|
| 544 |
+
- Bad/Baderom (Bathroom)
|
| 545 |
+
- WC/Toalett (Toilet)
|
| 546 |
- Gang/Korridor (Hallway)
|
| 547 |
+
- EntrΓ© (Entrance)
|
| 548 |
+
- Bod (Storage)
|
| 549 |
+
- Kontor (Office)
|
| 550 |
+
- Vaskerom (Laundry)
|
| 551 |
+
- Balkong/Terrasse (Balcony/Terrace)
|
| 552 |
+
- Garasje (Garage)
|
| 553 |
+
|
| 554 |
+
Scale: 1:{measurement_info['scale']}
|
| 555 |
+
Ceiling height: {measurement_info['ceiling_height']}m
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
|
| 557 |
+
IMPORTANT: Return ONLY the JSON array, no explanations or markdown."""
|
|
|
|
|
|
|
| 558 |
|
| 559 |
if description:
|
| 560 |
+
prompt += f"\n\nAdditional context: {description}"
|
| 561 |
|
| 562 |
return prompt
|
| 563 |
|
| 564 |
+
def _extract_json_enhanced(self, text: str) -> Optional[List[Dict]]:
|
| 565 |
+
"""Enhanced JSON extraction with better error handling"""
|
| 566 |
if not text:
|
| 567 |
return None
|
| 568 |
|
| 569 |
+
# Clean text
|
| 570 |
text = text.strip()
|
| 571 |
+
|
| 572 |
+
# Remove markdown blocks
|
| 573 |
+
text = re.sub(r'```(?:json|javascript|JSON)?\s*', '', text)
|
| 574 |
+
text = re.sub(r'```\s*$', '', text)
|
| 575 |
text = text.strip('`').strip()
|
| 576 |
|
| 577 |
# Try direct parse
|
|
|
|
| 579 |
data = json.loads(text)
|
| 580 |
if isinstance(data, list) and len(data) > 0:
|
| 581 |
return data
|
| 582 |
+
except json.JSONDecodeError as e:
|
| 583 |
+
logger.debug(f"Direct parse failed: {e}")
|
| 584 |
|
| 585 |
+
# Find JSON array patterns
|
| 586 |
patterns = [
|
| 587 |
+
r'\[\s*\{[^}]*\}(?:\s*,\s*\{[^}]*\})*\s*\]',
|
| 588 |
r'\[[\s\S]*?\]',
|
| 589 |
]
|
| 590 |
|
| 591 |
for pattern in patterns:
|
| 592 |
+
matches = list(re.finditer(pattern, text, re.DOTALL))
|
| 593 |
+
|
| 594 |
+
# Sort by length (prefer longer matches)
|
| 595 |
+
matches.sort(key=lambda m: len(m.group(0)), reverse=True)
|
| 596 |
+
|
| 597 |
+
for match in matches:
|
| 598 |
try:
|
| 599 |
+
json_str = match.group(0)
|
| 600 |
+
# Fix common issues
|
| 601 |
+
json_str = re.sub(r',\s*}', '}', json_str) # Remove trailing commas
|
| 602 |
+
json_str = re.sub(r',\s*]', ']', json_str)
|
| 603 |
+
|
| 604 |
+
data = json.loads(json_str)
|
| 605 |
if isinstance(data, list) and len(data) > 0:
|
| 606 |
return data
|
| 607 |
+
except json.JSONDecodeError as e:
|
| 608 |
+
logger.debug(f"Pattern match parse failed: {e}")
|
| 609 |
continue
|
| 610 |
|
| 611 |
+
logger.warning(f"Could not extract JSON from response")
|
| 612 |
return None
|
| 613 |
|
| 614 |
+
def _validate_measurements(self, data: List[Dict], measurement_info: dict) -> List[Dict]:
|
| 615 |
+
"""Enhanced validation with better defaults"""
|
| 616 |
if not isinstance(data, list):
|
| 617 |
return []
|
| 618 |
|
| 619 |
ceiling = measurement_info.get('ceiling_height', 2.4)
|
| 620 |
+
validated = []
|
| 621 |
|
| 622 |
for room in data:
|
| 623 |
+
# Skip invalid entries
|
| 624 |
+
if not isinstance(room, dict):
|
| 625 |
+
continue
|
| 626 |
+
|
| 627 |
+
# Ensure required fields with better defaults
|
| 628 |
+
room.setdefault("name", "Unknown Room")
|
| 629 |
+
room.setdefault("name_no", room.get("name", "Ukjent Rom"))
|
| 630 |
room.setdefault("ceiling_height_m", ceiling)
|
| 631 |
room.setdefault("windows", 0)
|
| 632 |
room.setdefault("doors", 1)
|
|
|
|
| 635 |
room.setdefault("connected_rooms", [])
|
| 636 |
room.setdefault("window_positions", [])
|
| 637 |
room.setdefault("door_positions", [])
|
| 638 |
+
room.setdefault("has_external_access", False)
|
| 639 |
+
room.setdefault("position", "unknown")
|
| 640 |
|
| 641 |
# Fix dimensions
|
| 642 |
+
if "dimensions_m" not in room or not isinstance(room["dimensions_m"], dict):
|
| 643 |
room["dimensions_m"] = {"width": 0, "length": 0}
|
| 644 |
|
| 645 |
+
dims = room["dimensions_m"]
|
| 646 |
+
width = float(dims.get("width", 0))
|
| 647 |
+
length = float(dims.get("length", 0))
|
| 648 |
+
area = float(room.get("area_m2", 0))
|
| 649 |
|
| 650 |
+
# Calculate missing values
|
| 651 |
if width > 0 and length > 0:
|
| 652 |
room["area_m2"] = round(width * length, 1)
|
| 653 |
+
elif area > 0:
|
| 654 |
+
if width > 0:
|
| 655 |
+
room["dimensions_m"]["length"] = round(area / width, 1)
|
| 656 |
+
elif length > 0:
|
| 657 |
+
room["dimensions_m"]["width"] = round(area / length, 1)
|
| 658 |
+
else:
|
| 659 |
+
# Assume square room
|
| 660 |
+
side = math.sqrt(area)
|
| 661 |
+
room["dimensions_m"]["width"] = round(side, 1)
|
| 662 |
+
room["dimensions_m"]["length"] = round(side, 1)
|
| 663 |
+
room["estimated"] = True
|
| 664 |
else:
|
| 665 |
+
# Default small room
|
| 666 |
room["dimensions_m"] = {"width": 3.0, "length": 3.0}
|
| 667 |
room["area_m2"] = 9.0
|
| 668 |
room["estimated"] = True
|
| 669 |
+
|
| 670 |
+
# Validate room has reasonable size
|
| 671 |
+
if room["area_m2"] > 0:
|
| 672 |
+
validated.append(room)
|
| 673 |
|
| 674 |
+
return validated
|
| 675 |
|
| 676 |
+
def _generate_fallback(self, measurement_info: dict) -> List[Dict]:
|
| 677 |
+
"""Generate comprehensive fallback structure"""
|
| 678 |
ceiling = measurement_info.get('ceiling_height', 2.4)
|
| 679 |
|
| 680 |
return [
|
| 681 |
{
|
| 682 |
+
"name": "Living Room",
|
| 683 |
+
"name_no": "Stue",
|
| 684 |
+
"area_m2": 35.0,
|
| 685 |
+
"position": "center",
|
| 686 |
+
"dimensions_m": {"width": 7.0, "length": 5.0},
|
| 687 |
+
"windows": 3,
|
| 688 |
+
"window_positions": ["north wall", "east wall"],
|
| 689 |
+
"doors": 2,
|
| 690 |
+
"door_positions": ["to hallway", "to kitchen"],
|
| 691 |
+
"ceiling_height_m": ceiling,
|
| 692 |
+
"estimated": True,
|
| 693 |
+
"furniture": ["sofa", "coffee table", "TV unit"],
|
| 694 |
+
"connected_rooms": ["Kitchen", "Hallway"],
|
| 695 |
+
"has_external_access": False
|
| 696 |
+
},
|
| 697 |
+
{
|
| 698 |
+
"name": "Kitchen",
|
| 699 |
+
"name_no": "KjΓΈkken",
|
| 700 |
+
"area_m2": 15.0,
|
| 701 |
+
"position": "adjacent to living room",
|
| 702 |
+
"dimensions_m": {"width": 3.0, "length": 5.0},
|
| 703 |
+
"windows": 1,
|
| 704 |
+
"window_positions": ["north wall"],
|
| 705 |
+
"doors": 1,
|
| 706 |
+
"door_positions": ["to living room"],
|
| 707 |
+
"ceiling_height_m": ceiling,
|
| 708 |
+
"estimated": True,
|
| 709 |
+
"furniture": ["cabinets", "countertop", "sink", "stove"],
|
| 710 |
+
"connected_rooms": ["Living Room"],
|
| 711 |
+
"has_external_access": False
|
| 712 |
+
},
|
| 713 |
+
{
|
| 714 |
+
"name": "Master Bedroom",
|
| 715 |
+
"name_no": "Hovedsoverom",
|
| 716 |
+
"area_m2": 20.0,
|
| 717 |
+
"position": "east side",
|
| 718 |
+
"dimensions_m": {"width": 4.0, "length": 5.0},
|
| 719 |
+
"windows": 2,
|
| 720 |
+
"window_positions": ["east wall", "south wall"],
|
| 721 |
+
"doors": 1,
|
| 722 |
+
"door_positions": ["to hallway"],
|
| 723 |
"ceiling_height_m": ceiling,
|
| 724 |
"estimated": True,
|
| 725 |
+
"furniture": ["bed", "wardrobe"],
|
| 726 |
+
"connected_rooms": ["Hallway"],
|
|
|
|
|
|
|
| 727 |
"has_external_access": False
|
| 728 |
}
|
| 729 |
]
|
| 730 |
|
| 731 |
+
# FastAPI Application
|
| 732 |
app = FastAPI(
|
| 733 |
+
title="Enhanced Floor Plan API",
|
| 734 |
+
version="2.0.0",
|
| 735 |
docs_url="/"
|
| 736 |
)
|
| 737 |
|
|
|
|
| 747 |
|
| 748 |
@app.get("/status")
|
| 749 |
async def get_status():
|
| 750 |
+
"""Get API status"""
|
| 751 |
return {
|
| 752 |
"status": "running",
|
| 753 |
"pdfs_count": len(processor.pdfs),
|
| 754 |
+
"model": "gemini-2.0-flash-exp",
|
| 755 |
+
"openai_fallback": USE_OPENAI_FALLBACK,
|
| 756 |
+
"version": "2.0.0"
|
| 757 |
}
|
| 758 |
|
| 759 |
@app.get("/pdfs")
|
| 760 |
async def get_pdfs():
|
| 761 |
+
"""List all uploaded PDFs"""
|
| 762 |
return {"pdfs": [pdf.to_dict() for pdf in processor.pdfs.values()]}
|
| 763 |
|
| 764 |
@app.get("/pdf/{pdf_id}")
|
| 765 |
async def get_pdf(pdf_id: str):
|
| 766 |
+
"""Get specific PDF details"""
|
| 767 |
if pdf_id not in processor.pdfs:
|
| 768 |
raise HTTPException(status_code=404, detail="PDF not found")
|
| 769 |
return processor.pdfs[pdf_id].to_dict()
|
| 770 |
|
| 771 |
@app.post("/upload")
|
| 772 |
async def upload_pdf(file: UploadFile = File(...)):
|
| 773 |
+
"""Upload and process PDF or image file"""
|
| 774 |
content_type = file.content_type.lower()
|
| 775 |
supported = ["application/pdf"] + list(processor.supported_image_formats.keys())
|
| 776 |
|
| 777 |
if content_type not in supported:
|
| 778 |
return JSONResponse(
|
| 779 |
status_code=400,
|
| 780 |
+
content={
|
| 781 |
+
"error": f"Unsupported file type: {content_type}",
|
| 782 |
+
"supported_types": supported
|
| 783 |
+
}
|
| 784 |
)
|
| 785 |
|
| 786 |
try:
|
| 787 |
file_content = await file.read()
|
| 788 |
+
|
| 789 |
+
# Check file size (max 50MB)
|
| 790 |
+
if len(file_content) > 50 * 1024 * 1024:
|
| 791 |
+
return JSONResponse(
|
| 792 |
+
status_code=400,
|
| 793 |
+
content={"error": "File too large (max 50MB)"}
|
| 794 |
+
)
|
| 795 |
+
|
| 796 |
pdf_id = await processor.process_upload(file_content, file.filename, content_type)
|
| 797 |
pdf_info = processor.pdfs[pdf_id].to_dict()
|
| 798 |
|
|
|
|
| 803 |
}
|
| 804 |
except Exception as e:
|
| 805 |
logger.error(f"Upload error: {str(e)}")
|
| 806 |
+
return JSONResponse(
|
| 807 |
+
status_code=500,
|
| 808 |
+
content={"error": str(e)}
|
| 809 |
+
)
|
| 810 |
|
| 811 |
@app.post("/analyze/{pdf_id}")
|
| 812 |
async def analyze_pdf(pdf_id: str, query: FloorPlanQuery = None):
|
| 813 |
+
"""Analyze floor plan and extract room information"""
|
| 814 |
if pdf_id not in processor.pdfs:
|
| 815 |
raise HTTPException(status_code=404, detail="PDF not found")
|
| 816 |
|
| 817 |
pdf = processor.pdfs[pdf_id]
|
| 818 |
|
| 819 |
if not pdf.processed:
|
| 820 |
+
return JSONResponse(
|
| 821 |
+
status_code=400,
|
| 822 |
+
content={"error": "File still processing, please wait"}
|
| 823 |
+
)
|
| 824 |
|
| 825 |
if not pdf.images:
|
| 826 |
+
return JSONResponse(
|
| 827 |
+
status_code=400,
|
| 828 |
+
content={
|
| 829 |
+
"error": "No images extracted from file",
|
| 830 |
+
"extraction_method": pdf.extraction_method,
|
| 831 |
+
"suggestion": "Try uploading a different format or higher quality file"
|
| 832 |
+
}
|
| 833 |
+
)
|
| 834 |
|
| 835 |
try:
|
| 836 |
+
# Parse query parameters
|
| 837 |
description = query.description if query else None
|
| 838 |
+
use_high_quality = query.use_high_quality if query else True
|
| 839 |
+
|
| 840 |
start_time = time.time()
|
| 841 |
|
| 842 |
+
# Extended timeout for complex floor plans
|
| 843 |
result = await asyncio.wait_for(
|
| 844 |
+
processor.analyze_floor_plan(pdf_id, description, use_high_quality),
|
| 845 |
+
timeout=1800 # 30 minutes max
|
| 846 |
)
|
| 847 |
|
| 848 |
elapsed = time.time() - start_time
|
| 849 |
pdf.analysis_result = result
|
| 850 |
|
| 851 |
+
# Check if results are estimated/fallback
|
| 852 |
+
is_fallback = all(room.get("estimated", False) for room in result) and len(result) <= 3
|
|
|
|
|
|
|
| 853 |
|
| 854 |
return {
|
| 855 |
"message": "Analysis complete",
|
|
|
|
| 858 |
"rooms": result,
|
| 859 |
"analysis_time_seconds": round(elapsed, 1),
|
| 860 |
"is_estimated": is_fallback,
|
| 861 |
+
"room_count": len(result),
|
| 862 |
+
"extraction_method": pdf.extraction_method,
|
| 863 |
+
"quality_score": self._calculate_quality_score(result)
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
except asyncio.TimeoutError:
|
| 867 |
+
logger.error(f"Analysis timeout for {pdf_id}")
|
| 868 |
+
|
| 869 |
+
# Return fallback on timeout
|
| 870 |
+
fallback = processor._generate_fallback(pdf.measurement_info)
|
| 871 |
+
return {
|
| 872 |
+
"message": "Analysis timeout - using fallback data",
|
| 873 |
+
"pdf_id": pdf_id,
|
| 874 |
+
"rooms": fallback,
|
| 875 |
+
"is_estimated": True,
|
| 876 |
+
"error": "Analysis took too long, returning estimated data"
|
| 877 |
}
|
| 878 |
|
| 879 |
except Exception as e:
|
| 880 |
logger.error(f"Analysis error: {str(e)}", exc_info=True)
|
| 881 |
|
| 882 |
+
# Try to provide fallback data
|
| 883 |
try:
|
| 884 |
fallback = processor._generate_fallback(pdf.measurement_info)
|
| 885 |
return {
|
| 886 |
+
"message": "Analysis error - using fallback data",
|
| 887 |
"pdf_id": pdf_id,
|
| 888 |
"rooms": fallback,
|
| 889 |
"is_estimated": True,
|
|
|
|
| 892 |
except:
|
| 893 |
return JSONResponse(
|
| 894 |
status_code=500,
|
| 895 |
+
content={
|
| 896 |
+
"error": str(e),
|
| 897 |
+
"pdf_id": pdf_id,
|
| 898 |
+
"suggestion": "Try re-uploading the file or using a different format"
|
| 899 |
+
}
|
| 900 |
)
|
| 901 |
|
| 902 |
@app.post("/room/{pdf_id}")
|
| 903 |
async def find_room(pdf_id: str, query: RoomQuery):
|
| 904 |
+
"""Find specific room(s) in analyzed floor plan"""
|
| 905 |
if pdf_id not in processor.pdfs:
|
| 906 |
raise HTTPException(status_code=404, detail="PDF not found")
|
| 907 |
|
| 908 |
pdf = processor.pdfs[pdf_id]
|
| 909 |
|
| 910 |
if not pdf.analysis_result:
|
| 911 |
+
raise HTTPException(
|
| 912 |
+
status_code=400,
|
| 913 |
+
detail="Floor plan not analyzed yet. Please call /analyze first"
|
| 914 |
+
)
|
| 915 |
|
| 916 |
found = []
|
| 917 |
name_lower = query.room_name.lower()
|
|
|
|
| 924 |
if en == name_lower or no == name_lower:
|
| 925 |
found.append(room)
|
| 926 |
else:
|
| 927 |
+
# Partial match
|
| 928 |
+
if name_lower in en or name_lower in no or en in name_lower or no in name_lower:
|
| 929 |
+
found.append(room)
|
| 930 |
+
|
| 931 |
+
if not found:
|
| 932 |
+
# Try fuzzy matching as fallback
|
| 933 |
+
for room in pdf.analysis_result:
|
| 934 |
+
en = room.get("name", "").lower()
|
| 935 |
+
no = room.get("name_no", "").lower()
|
| 936 |
+
|
| 937 |
+
# Check for common variations
|
| 938 |
+
if any(term in name_lower for term in [en.split()[0], no.split()[0]]) or \
|
| 939 |
+
any(term in en or term in no for term in name_lower.split()):
|
| 940 |
found.append(room)
|
| 941 |
|
| 942 |
if not found:
|
| 943 |
+
raise HTTPException(
|
| 944 |
+
status_code=404,
|
| 945 |
+
detail=f"Room '{query.room_name}' not found. Available rooms: {', '.join([r.get('name', 'Unknown') for r in pdf.analysis_result])}"
|
| 946 |
+
)
|
| 947 |
|
| 948 |
if len(found) == 1:
|
| 949 |
+
return {
|
| 950 |
+
"message": "Room found",
|
| 951 |
+
"pdf_id": pdf_id,
|
| 952 |
+
"room": found[0]
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
return {
|
| 956 |
+
"message": f"Found {len(found)} matching rooms",
|
| 957 |
+
"pdf_id": pdf_id,
|
| 958 |
+
"rooms": found,
|
| 959 |
+
"total_matches": len(found)
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
@app.post("/update_measurements/{pdf_id}")
|
| 963 |
+
async def update_measurements(pdf_id: str, measurement_info: dict):
|
| 964 |
+
"""Update measurement information for a PDF"""
|
| 965 |
+
if pdf_id not in processor.pdfs:
|
| 966 |
+
raise HTTPException(status_code=404, detail="PDF not found")
|
| 967 |
+
|
| 968 |
+
pdf = processor.pdfs[pdf_id]
|
| 969 |
+
|
| 970 |
+
# Update measurement info
|
| 971 |
+
if "scale" in measurement_info:
|
| 972 |
+
pdf.measurement_info["scale"] = measurement_info["scale"]
|
| 973 |
+
if "ceiling_height" in measurement_info:
|
| 974 |
+
pdf.measurement_info["ceiling_height"] = measurement_info["ceiling_height"]
|
| 975 |
+
if "room_dimensions" in measurement_info:
|
| 976 |
+
pdf.measurement_info["room_dimensions"].update(measurement_info["room_dimensions"])
|
| 977 |
|
| 978 |
return {
|
| 979 |
+
"message": "Measurements updated",
|
| 980 |
"pdf_id": pdf_id,
|
| 981 |
+
"measurement_info": pdf.measurement_info
|
| 982 |
}
|
| 983 |
|
| 984 |
+
@app.delete("/pdf/{pdf_id}")
|
| 985 |
+
async def delete_pdf(pdf_id: str):
|
| 986 |
+
"""Delete a PDF and its associated data"""
|
| 987 |
+
if pdf_id not in processor.pdfs:
|
| 988 |
+
raise HTTPException(status_code=404, detail="PDF not found")
|
| 989 |
+
|
| 990 |
+
# Clean up files
|
| 991 |
+
try:
|
| 992 |
+
for ext in [".pdf", ".png", ".jpg"]:
|
| 993 |
+
file_path = f"uploads/{pdf_id}{ext}"
|
| 994 |
+
if os.path.exists(file_path):
|
| 995 |
+
os.remove(file_path)
|
| 996 |
+
except Exception as e:
|
| 997 |
+
logger.warning(f"Could not delete files for {pdf_id}: {e}")
|
| 998 |
+
|
| 999 |
+
# Remove from memory
|
| 1000 |
+
del processor.pdfs[pdf_id]
|
| 1001 |
+
|
| 1002 |
+
return {
|
| 1003 |
+
"message": "PDF deleted successfully",
|
| 1004 |
+
"pdf_id": pdf_id
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
def _calculate_quality_score(rooms: List[Dict]) -> float:
|
| 1008 |
+
"""Calculate quality score for analysis results"""
|
| 1009 |
+
if not rooms:
|
| 1010 |
+
return 0.0
|
| 1011 |
+
|
| 1012 |
+
score = 0.0
|
| 1013 |
+
max_score = 100.0
|
| 1014 |
+
|
| 1015 |
+
# Check for estimated vs actual measurements
|
| 1016 |
+
non_estimated = sum(1 for r in rooms if not r.get("estimated", True))
|
| 1017 |
+
score += (non_estimated / len(rooms)) * 40
|
| 1018 |
+
|
| 1019 |
+
# Check for detailed information
|
| 1020 |
+
for room in rooms:
|
| 1021 |
+
room_score = 0
|
| 1022 |
+
if room.get("area_m2", 0) > 0:
|
| 1023 |
+
room_score += 10
|
| 1024 |
+
if room.get("windows", 0) >= 0:
|
| 1025 |
+
room_score += 5
|
| 1026 |
+
if room.get("doors", 0) >= 0:
|
| 1027 |
+
room_score += 5
|
| 1028 |
+
if room.get("connected_rooms"):
|
| 1029 |
+
room_score += 10
|
| 1030 |
+
if room.get("furniture"):
|
| 1031 |
+
room_score += 10
|
| 1032 |
+
|
| 1033 |
+
score += (room_score / len(rooms))
|
| 1034 |
+
|
| 1035 |
+
# Normalize to 0-100
|
| 1036 |
+
return min(100.0, round(score, 1))
|
| 1037 |
+
|
| 1038 |
@app.on_event("startup")
|
| 1039 |
async def startup_event():
|
| 1040 |
+
"""Initialize application on startup"""
|
| 1041 |
+
# Create necessary directories
|
| 1042 |
os.makedirs("uploads", exist_ok=True)
|
| 1043 |
+
os.makedirs("temp", exist_ok=True)
|
| 1044 |
os.makedirs("logs", exist_ok=True)
|
| 1045 |
|
| 1046 |
+
logger.info("\n" + "="*70)
|
| 1047 |
+
logger.info("Enhanced Floor Plan API v2.0")
|
| 1048 |
+
logger.info(f"Primary Model: gemini-2.0-flash-exp")
|
| 1049 |
+
logger.info(f"Gemini API: {'β SET' if GOOGLE_API_KEY else 'β NOT SET'}")
|
| 1050 |
+
logger.info(f"OpenAI Fallback: {'β AVAILABLE' if USE_OPENAI_FALLBACK else 'β NOT AVAILABLE'}")
|
| 1051 |
+
logger.info(f"Server: http://0.0.0.0:7860")
|
| 1052 |
+
logger.info("="*70 + "\n")
|
| 1053 |
+
|
| 1054 |
+
# Test PDF extraction libraries
|
| 1055 |
+
try:
|
| 1056 |
+
import fitz
|
| 1057 |
+
logger.info("β PyMuPDF available")
|
| 1058 |
+
except ImportError:
|
| 1059 |
+
logger.warning("β PyMuPDF not available - install with: pip install PyMuPDF")
|
| 1060 |
+
|
| 1061 |
+
try:
|
| 1062 |
+
from pdf2image import convert_from_bytes
|
| 1063 |
+
logger.info("β pdf2image available")
|
| 1064 |
+
except ImportError:
|
| 1065 |
+
logger.warning("β pdf2image not available - install with: pip install pdf2image")
|
| 1066 |
+
logger.warning(" Also requires poppler-utils: apt-get install poppler-utils")
|
| 1067 |
+
|
| 1068 |
+
@app.on_event("shutdown")
|
| 1069 |
+
async def shutdown_event():
|
| 1070 |
+
"""Cleanup on shutdown"""
|
| 1071 |
+
logger.info("Shutting down Floor Plan API")
|
| 1072 |
+
|
| 1073 |
+
# Optional: Clean up temporary files
|
| 1074 |
+
try:
|
| 1075 |
+
import shutil
|
| 1076 |
+
if os.path.exists("temp"):
|
| 1077 |
+
shutil.rmtree("temp")
|
| 1078 |
+
os.makedirs("temp", exist_ok=True)
|
| 1079 |
+
except Exception as e:
|
| 1080 |
+
logger.warning(f"Could not clean temp directory: {e}")
|
| 1081 |
|
| 1082 |
if __name__ == "__main__":
|
| 1083 |
+
uvicorn.run(
|
| 1084 |
+
app,
|
| 1085 |
+
host="0.0.0.0",
|
| 1086 |
+
port=7860,
|
| 1087 |
+
log_level="info"
|
| 1088 |
+
)
|