iplotnor commited on
Commit
d3fd8cf
Β·
verified Β·
1 Parent(s): e360a94

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +643 -199
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.5-pro')
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
- await self.extract_images_from_pdf(pdf, file_content)
 
 
 
 
 
 
 
 
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 extract_images_from_pdf(self, pdf, file_content):
 
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 not image_list:
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
- xref = img_info[0]
147
- base_image = pdf_document.extract_image(xref)
148
- img = Image.open(BytesIO(base_image["image"]))
149
- if img.width > 100 and img.height > 100:
150
- images.append(img)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
  pdf.images = images
153
- logger.info(f"Extracted {len(images)} images")
154
- return True
 
155
 
156
  except Exception as e:
157
- logger.error(f"PDF error: {str(e)}")
158
- pdf.error = str(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Use ONLY the first/best image for single file analysis
175
- best_image = self._select_single_best_image(pdf.images)
176
- optimized_image = self._optimize_image(best_image, target_size=2048)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
- logger.info(f"Using single image: {optimized_image.size[0]}x{optimized_image.size[1]}px")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
- # Try analysis with extended timeout
181
- max_retries = 3
182
  for attempt in range(max_retries):
183
  try:
184
- logger.info(f"\nAttempt {attempt + 1}/{max_retries}")
 
 
 
 
185
 
186
  result = await self._analyze_with_gemini(
187
- optimized_image,
188
- pdf.measurement_info,
189
  description,
190
- timeout=600,
 
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(10)
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 = 15 * (attempt + 1)
212
  logger.info(f"Waiting {wait}s before retry...")
213
  await asyncio.sleep(wait)
214
  continue
215
 
216
  # Non-retryable error
217
- logger.error(f"Non-retryable error: {error_str}")
218
- raise
219
-
220
- logger.warning("All attempts failed, using fallback")
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, description, timeout, attempt=0):
253
- """Analyze with Gemini API"""
254
- prompt = self._create_detailed_prompt(description, measurement_info)
 
255
 
256
- # Adjust parameters per attempt
257
- temperature = 0.2 if attempt == 0 else 0.3
258
- max_tokens = 16384
259
 
260
- logger.info(f"Config: temp={temperature}, max_tokens={max_tokens}")
261
 
262
  start_time = time.time()
263
  loop = asyncio.get_event_loop()
264
 
265
- # Create safety settings with correct format
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=max_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._extract_json(response.text)
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 _create_detailed_prompt(self, description, measurement_info):
311
- """Create detailed prompt optimized for Norwegian floor plans"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
- prompt = f"""Du er en ekspert pΓ₯ norske plantegninger. Analyser denne plantegningen nΓΈye og ekstraher ALL rom med komplette detaljer.
 
 
 
 
 
 
 
 
 
 
 
314
 
315
- Returner KUN en JSON-array i dette eksakte formatet:
316
  [
317
  {{
318
  "name": "Living Room",
319
  "name_no": "Stue",
320
- "area_m2": 0.0,
321
- "position": "beskrivelse av plassering",
322
- "dimensions_m": {{"width": 0.0, "length": 0.0}},
323
- "windows": 0,
324
- "window_positions": ["vegg plassering"],
325
- "doors": 0,
326
- "door_positions": ["plassering"],
327
- "connected_rooms": ["TilstΓΈtende rom"],
328
  "has_external_access": false,
329
  "ceiling_height_m": {measurement_info['ceiling_height']},
330
- "furniture": [],
331
  "estimated": false
332
  }}
333
  ]
334
 
335
- KRITISKE INSTRUKSJONER:
336
- 1. Finn og inkluder HVERT ENESTE rom som er synlig pΓ₯ plantegningen
337
- 2. Les romnavnene nΓΈyaktig som de stΓ₯r pΓ₯ tegningen (f.eks. "SOV 1", "KJØKKEN", "STUE", "BAD", etc.)
338
- 3. Les de eksakte arealene som er vist pΓ₯ planen (f.eks. "25.5 mΒ²", "12.3 mΒ²", etc.)
339
- 4. Hvis bredde Γ— lengde vises, bruk dem nΓΈyaktig
340
- 5. Hvis bare areal vises, beregn omtrentlige dimensjoner: bredde β‰ˆ √areal, lengde β‰ˆ √areal
341
- 6. Tell vinduer nΓΈye - se etter vinduessymboler i veggene
342
- 7. Tell dΓΈrer - se etter dΓΈrsvingsymboler
343
- 8. Identifiser hvilke vegger som har vinduer/dΓΈrer (nord, sΓΈr, ΓΈst, vest)
344
- 9. List tilstΓΈtende rom som har forbindelse til hvert rom
345
- 10. Sjekk om rommet har direkte utgang til uteomrΓ₯de
346
- 11. Sett estimated=false KUN hvis du kan lese eksakte mΓ₯l, ellers true
347
- 12. Hvis du ser mΓΈbelsymboler eller etiketter, list dem
348
- 13. Returner KUN JSON-arrayen - absolutt ingen forklaringer, ingen markdown-blokker, ingen ekstra tekst
349
 
350
- Norske romtyper Γ₯ se etter:
351
- - Soverom (SOV, Soverom, Bedroom)
352
  - KjΓΈkken (Kitchen)
353
- - Stue (Living room, Salon)
354
- - Bad/Baderom (Bathroom, Vask, WC)
355
- - Toalett (WC, Toilet)
356
  - Gang/Korridor (Hallway)
357
- - EntrΓ© (Entrance, Inngang)
358
- - Bod/Garderobe (Storage, Closet, Skap)
359
- - Kontor (Office, Arbeidsrom)
360
- - Vaskerom (Laundry, Vaskeri)
361
- - Terrasse/Balkong (Terrace, Balcony, Uteplass)
362
- - Garasje (Garage, Biloppstilling)
363
- - Spisestue (Dining room)
364
- - Sportsbod (Sports storage)
365
- - Tech/Teknisk rom (Technical room)
366
- - Vindfang (Mudroom)
367
- - Trapperom (Stairwell, Trapp)
368
- - Loft/Hems (Attic, Loft)
369
- - Kjeller (Basement)
370
 
371
- MΓ₯lestokk: 1:{measurement_info['scale']}
372
- Standard takhΓΈyde: {measurement_info['ceiling_height']}m
373
- """
374
 
375
  if description:
376
- prompt += f"\n\nBrukeren ga denne konteksten: {description}"
377
 
378
  return prompt
379
 
380
- def _extract_json(self, text):
381
- """Extract JSON from response"""
382
  if not text:
383
  return None
384
 
385
- # Remove markdown
386
  text = text.strip()
387
- text = re.sub(r'```(?:json|javascript)?\s*', '', text)
 
 
 
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
- pass
397
 
398
- # Find JSON array
399
  patterns = [
400
- r'\[\s*\{[\s\S]*?\}\s*\]',
401
  r'\[[\s\S]*?\]',
402
  ]
403
 
404
  for pattern in patterns:
405
- matches = list(re.finditer(pattern, text))
406
- for match in sorted(matches, key=lambda m: len(m.group(0)), reverse=True):
 
 
 
 
407
  try:
408
- data = json.loads(match.group(0))
 
 
 
 
 
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: {text[:300]}...")
415
  return None
416
 
417
- def _validate_measurements(self, data, measurement_info):
418
- """Validate and fix room measurements"""
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
- # Ensure required fields
426
- room.setdefault("name", "Unknown")
427
- room.setdefault("name_no", room["name"])
 
 
 
 
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
- width = room["dimensions_m"].get("width", 0)
442
- length = room["dimensions_m"].get("length", 0)
 
 
443
 
 
444
  if width > 0 and length > 0:
445
  room["area_m2"] = round(width * length, 1)
446
- elif room.get("area_m2", 0) > 0:
447
- side = math.sqrt(room["area_m2"])
448
- room["dimensions_m"]["width"] = round(side, 1)
449
- room["dimensions_m"]["length"] = round(side, 1)
450
- room["estimated"] = True
 
 
 
 
 
 
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 data
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", "name_no": "Stue",
465
- "area_m2": 35.0, "position": "center",
466
- "dimensions_m": {"width": 6.0, "length": 5.8},
467
- "windows": 2, "doors": 2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="1.0.6",
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.5-pro"
 
 
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={"error": "Unsupported file type"}
 
 
 
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(status_code=500, content={"error": str(e)})
 
 
 
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(status_code=400, content={"error": "Still processing"})
 
 
 
546
 
547
  if not pdf.images:
548
- return JSONResponse(status_code=400, content={"error": "No images"})
 
 
 
 
 
 
 
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=1200
557
  )
558
 
559
  elapsed = time.time() - start_time
560
  pdf.analysis_result = result
561
 
562
- is_fallback = any(
563
- room.get("estimated") and len(result) <= 2
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": "Error - using fallback",
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={"error": str(e), "pdf_id": pdf_id}
 
 
 
 
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(status_code=400, content={"error": "Not analyzed yet"})
 
 
 
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
- if name_lower in en or name_lower in no:
 
 
 
 
 
 
 
 
 
 
 
 
617
  found.append(room)
618
 
619
  if not found:
620
- raise HTTPException(status_code=404, content={"error": "Room not found"})
 
 
 
621
 
622
  if len(found) == 1:
623
- return {"message": "Room found", "pdf_id": pdf_id, "room": found[0]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
 
625
  return {
626
- "message": f"Found {len(found)} rooms",
627
  "pdf_id": pdf_id,
628
- "rooms": found
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" + "="*60)
637
- logger.info("Floor Plan API - Single File Mode")
638
- logger.info(f"Model: gemini-2.5-pro")
639
- logger.info(f"API Key: {'SET' if GOOGLE_API_KEY else 'NOT SET'}")
640
- logger.info(f"Port: 7860")
641
- logger.info("="*60 + "\n")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
 
643
  if __name__ == "__main__":
644
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
 
 
 
 
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
+ )