buildinves commited on
Commit
5909b52
·
verified ·
1 Parent(s): 6c23e55

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +752 -714
app.py CHANGED
@@ -1,754 +1,792 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
 
2
- **Sample lots generated for demonstration.**
3
- """
4
- return preview_img, mock_lots, summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- try:
7
- # Load either PDF image, or direct image file
8
- if image_path.lower().endswith('.pdf'):
9
- with tempfile.TemporaryDirectory() as temp_dir:
10
- images = convert_from_path(image_path, dpi=300)
11
- if images:
12
- img = np.array(images[0])
13
- else:
14
- return None, None, "Failed to convert PDF to image"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  else:
16
- img = cv2.imread(image_path)
17
- if img is None:
18
- return None, None, "Failed to load image"
19
-
20
- # Ensure it's in RGB
21
- if len(img.shape) == 2:
22
- img_rgb = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
23
- elif img.shape[2] == 4:
24
- img_rgb = cv2.cvtColor(img, cv2.COLOR_BGRA2RGB)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  else:
26
- img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- lots_detected = self.detect_lot_boundaries(gray, img_rgb, confidence)
31
- text_data = self.extract_text_from_plan(gray)
32
- lot_data = self.match_lots_with_dimensions(lots_detected, text_data, scale, auto_detect_scale)
33
- preview_img = self.create_annotated_preview(img_rgb, lot_data)
 
34
 
35
- summary = f"""
36
- ### Analysis Complete!
37
- - **Lots Detected**: {len(lot_data)}
38
- - **Scale Used**: 1:{scale if not auto_detect_scale else 'Auto-detected'}
39
- - **Confidence**: {int(confidence * 100)}%
40
 
41
- **Next Steps:**
42
- 1. Review detected lots in the table below
43
- 2. Make any necessary corrections
44
- 3. Click "Send to Optimizer" to analyze the layout
45
- """
46
- return preview_img, lot_data, summary
47
 
48
- except Exception as e:
49
- return None, None, f"Error processing plan: {str(e)}"
50
-
51
- def detect_lot_boundaries(self, gray_img, rgb_img, confidence):
52
- """Use Canny + findContours to locate rectangular-ish shapes as “lots”"""
53
- lots = []
54
- edges = cv2.Canny(gray_img, 50, 150)
55
- contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
56
- for contour in contours:
57
- area = cv2.contourArea(contour)
58
- if area > 1000:
59
- epsilon = 0.02 * cv2.arcLength(contour, True)
60
- approx = cv2.approxPolyDP(contour, epsilon, True)
61
- if 4 <= len(approx) <= 6:
62
- x, y, w, h = cv2.boundingRect(contour)
63
- aspect_ratio = float(w) / h if h > 0 else 0
64
- if 0.3 <= aspect_ratio <= 3.0:
65
- lots.append({
66
- 'contour': approx,
67
- 'bbox': (x, y, w, h),
68
- 'area': area,
69
- 'confidence': confidence
70
- })
71
- return lots
72
-
73
- def extract_text_from_plan(self, gray_img):
74
- """Run Tesseract OCR on a binary version of the plan to pull out any numbers or “L\d+” labels"""
75
- try:
76
- _, thresh = cv2.threshold(gray_img, 150, 255, cv2.THRESH_BINARY)
77
- data = pytesseract.image_to_data(thresh, output_type=pytesseract.Output.DICT)
78
- text_elements = []
79
- for i in range(len(data['text'])):
80
- conf = int(data['conf'][i])
81
- if conf > 0:
82
- text_val = data['text'][i].strip()
83
- if not text_val:
84
- continue
85
- text_elements.append({
86
- 'text': text_val,
87
- 'x': data['left'][i],
88
- 'y': data['top'][i],
89
- 'w': data['width'][i],
90
- 'h': data['height'][i]
91
- })
92
- return text_elements
93
- except Exception:
94
- return []
95
 
96
- def match_lots_with_dimensions(self, lots, text_data, scale, auto_detect_scale):
97
- """
98
- For each detected lot‐bounding‐box, look for nearby OCR text that matches:
99
- - Lot number: r'^L\d+'
100
- - A dimension in metres: r'^\d+\.?\d*m?$'
101
- If no dimension is found, estimate using pixel→metre (w/scale).
102
- """
103
- lot_info = []
104
- for i, lot in enumerate(lots):
105
- x, y, w, h = lot['bbox']
106
- lot_center = (x + w / 2, y + h / 2)
107
- lot_number = None
108
- frontage = None
109
- depth = None
110
-
111
- for text in text_data:
112
- text_center = (
113
- text['x'] + text['w'] / 2,
114
- text['y'] + text['h'] / 2
115
- )
116
- dist = np.hypot(lot_center[0] - text_center[0],
117
- lot_center[1] - text_center[1])
118
- if dist < max(w, h) * 0.5:
119
- text_val = text['text']
120
-
121
- # Check if it's a lot number like "L3" or "L12"
122
- if re.match(r'^L\d+', text_val):
123
- lot_number = text_val
124
-
125
- # Check if it's a dimension in metres, e.g. "12.5m" or "8.5"
126
- elif re.match(r'^\d+\.?\d*m?$', text_val):
127
- # Grab the numeric part
128
- num_str = re.findall(r'\d+\.?\d*', text_val)[0]
129
- dim_val = float(num_str)
130
- # If horizontally near center, assume frontage
131
- if abs(text_center[1] - lot_center[1]) < h * 0.3:
132
- frontage = dim_val
133
- else:
134
- depth = dim_val
135
-
136
- if not lot_number:
137
- lot_number = f"L{i + 1}"
138
- if frontage is None:
139
- frontage = round(w / scale * 1000, 1)
140
- if depth is None:
141
- depth = round(h / scale * 1000, 1)
142
-
143
- lot_type = "SLHC" if frontage <= 10.5 else "Standard" if frontage <= 14 else "Premium"
144
- lot_info.append({
145
- 'lot_number': lot_number,
146
- 'frontage': frontage,
147
- 'depth': depth,
148
- 'area': frontage * depth,
149
- 'type': lot_type,
150
- 'bbox': lot['bbox']
151
- })
152
-
153
- # Sort by lot_number if possible (e.g. L1, L2, L3…)
154
- try:
155
- lot_info.sort(key=lambda x: int(re.findall(r'\d+', x['lot_number'])[0]))
156
- except Exception:
157
- pass
158
 
159
- return lot_info
 
 
 
160
 
161
- def create_annotated_preview(self, img, lot_data):
162
- """Overlay bounding boxes and labels onto the plan image for preview"""
163
- if not PLAN_READER_AVAILABLE:
164
- return img
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
- annotated = img.copy()
167
- colors = {
168
- 'SLHC': (255, 0, 0),
169
- 'Standard': (0, 255, 0),
170
- 'Premium': (0, 0, 255)
171
- }
172
 
173
- for lot in lot_data:
174
- if 'bbox' in lot:
175
- x, y, w, h = lot['bbox']
176
- color = colors.get(lot['type'], (128, 128, 128))
177
- cv2.rectangle(annotated, (x, y), (x + w, y + h), color, 2)
178
- label = f"{lot['lot_number']}: {lot['frontage']}m"
179
- cv2.putText(annotated, label, (x + 5, y + 20),
180
- cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
181
- return annotated
182
-
183
- def lot_data_to_dataframe(self, lot_data):
184
- """Turn a list of lot dicts into a pandas DataFrame for display/editing"""
185
- if not lot_data:
186
- return pd.DataFrame(columns=["Lot #", "Frontage (m)", "Depth (m)", "Area (m²)", "Type"])
187
-
188
- rows = []
189
- for lot in lot_data:
190
- rows.append({
191
- "Lot #": lot['lot_number'],
192
- "Frontage (m)": lot['frontage'],
193
- "Depth (m)": lot['depth'],
194
- "Area (m²)": round(lot['area'], 1),
195
- "Type": lot['type']
196
- })
197
- return pd.DataFrame(rows)
198
-
199
- def export_lot_data_to_csv(self, df):
200
- """Return the CSV‐string representation of the lot‐DataFrame"""
201
- if df is None or df.empty:
202
- return None
203
- buffer = io.StringIO()
204
- df.to_csv(buffer, index=False)
205
- return buffer.getvalue()
206
-
207
- def convert_lot_data_to_stage_format(self, df):
208
- """
209
- Summarize manually‐edited lot DataFrame into a total stage width and common depth
210
- (so that the main optimizer can re‐run on them).
211
- """
212
- if df is None or df.empty:
213
- return None, None
214
-
215
- frontage_counts = {}
216
- for _, row in df.iterrows():
217
- frontage = float(row['Frontage (m)'])
218
- frontage_counts[frontage] = frontage_counts.get(frontage, 0) + 1
219
-
220
- total_width = sum(f * c for f, c in frontage_counts.items())
221
- depths = df['Depth (m)'].mode()
222
- common_depth = depths[0] if len(depths) > 0 else 32
223
- return total_width, common_depth
224
-
225
- def darken_color(self, hex_color, factor=0.8):
226
- """Return a darker shade of the given hex color by multiplying each channel by `factor`."""
227
  try:
228
- hex_color = hex_color.lstrip('#')
229
- r, g, b = (int(hex_color[i:i+2], 16) for i in (0, 2, 4))
230
- darker = (int(r * factor), int(g * factor), int(b * factor))
231
- return '#' + ''.join(f'{c:02x}' for c in darker)
232
- except Exception:
233
- return hex_color
234
-
235
-
236
- def create_advanced_app():
237
- optimizer = AdvancedGridOptimizer()
238
-
239
- def optimize_grid(
240
- stage_width, stage_depth,
241
- enable_8_5, enable_10_5, enable_12_5,
242
- enable_14, enable_16, enable_18,
243
- enable_corners, enable_11, enable_13_3,
244
- enable_14_8, enable_16_8,
245
- allow_custom_corners, optimization_strategy, color_scheme
246
- ):
247
- optimizer.current_scheme = color_scheme
248
-
249
- enabled_widths = []
250
- if enable_8_5: enabled_widths.append(8.5)
251
- if enable_10_5: enabled_widths.append(10.5)
252
- if enable_12_5: enabled_widths.append(12.5)
253
- if enable_14: enabled_widths.append(14.0)
254
- if enable_16: enabled_widths.append(16.0)
255
- if enable_18: enabled_widths.append(18.0)
256
-
257
- if enable_corners:
258
- if enable_11: enabled_widths.append(11.0)
259
- if enable_13_3: enabled_widths.append(13.3)
260
- if enable_14_8: enabled_widths.append(14.8)
261
- if enable_16_8: enabled_widths.append(16.8)
262
-
263
- if not enabled_widths:
264
- return None, pd.DataFrame(), "❌ Please select at least one lot width!", "", ""
265
-
266
- if optimization_strategy == "diversity_focus":
267
- optimized_solution = optimizer.optimize_with_flexible_corners(
268
- stage_width, enabled_widths, allow_custom_corners
269
- )
270
- else:
271
- optimized_solution = optimizer.optimize_with_corners_diverse(
272
- stage_width, enabled_widths, None
273
- )
274
 
275
- optimizer.current_solution = optimized_solution
 
 
 
276
 
277
- if optimized_solution:
278
- total_width = sum(w for w, _ in optimized_solution)
279
- variance = total_width - stage_width
280
- else:
281
- variance = None
282
-
283
- # —— The only change is this multiline string block, which now IS properly terminated:
284
- if (not optimized_solution) or abs(sum(w for w, _ in optimized_solution) - stage_width) > 0.001:
285
- return None, pd.DataFrame(), f"""
286
- ### ❌ Cannot achieve 100% usage with selected widths
287
-
288
- **Stage Width**: {stage_width}m
289
- **Available Widths**: {', '.join([f"{w}m" for w in sorted(enabled_widths)])}
290
-
291
- **Try:**
292
- 1. Enable more lot types for flexibility
293
- 2. Enable "Custom Corners" option
294
- 3. Try common stage widths: 84m, 105m, 126m
295
- """, "", ""
296
- # —— End of terminated multiline string block —— #
297
-
298
- fig_2d = optimizer.create_enhanced_visualization(
299
- optimized_solution, stage_width, stage_depth,
300
- "AI-Optimized Diverse Subdivision Layout",
301
- show_variance=variance
302
- )
303
 
304
- width_counts = {}
305
- for width, lot_type in optimized_solution:
306
- key = f"{width:.1f}m"
307
- if key in width_counts:
308
- width_counts[key]['count'] += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  else:
310
- if width in optimizer.lot_specifications:
311
- spec = optimizer.lot_specifications[width]
312
- elif int(width) in optimizer.lot_specifications:
313
- spec = optimizer.lot_specifications[int(width)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  else:
315
- closest = min(optimizer.lot_specifications.keys(),
316
- key=lambda x: abs(x - width))
317
- spec = optimizer.lot_specifications[closest]
318
- spec = {**spec, 'type': 'Custom', 'squares': 'Custom'}
319
-
320
- width_counts[key] = {
321
- 'count': 1,
322
- 'type': spec.get('type', 'Custom'),
323
- 'squares': spec.get('squares', 'N/A'),
324
- 'area': width * stage_depth
325
- }
326
-
327
- results_data = []
328
- for width_label, info in sorted(width_counts.items()):
329
- results_data.append({
330
- 'Lot Width': width_label,
331
- 'Count': info['count'],
332
- 'Type': info['type'],
333
- 'Area Each': f"{info['area']:.0f}m²",
334
- 'Total Width': f"{float(width_label[:-1]) * info['count']:.1f}m",
335
- 'Total Area': f"{info['area'] * info['count']:.0f}m²"
336
- })
337
-
338
- results_df = pd.DataFrame(results_data)
339
- report = optimizer.generate_report(optimized_solution, stage_width, stage_depth, None)
340
-
341
- total_lots = len(optimized_solution)
342
- unique_widths = len(set(w for w, _ in optimized_solution))
343
- slhc_pairs = sum(
344
- 1 for i in range(len(optimized_solution) - 1)
345
- if optimized_solution[i][0] <= 10.5 and optimized_solution[i + 1][0] <= 10.5
346
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
- corner_info = "N/A"
349
- if len(optimized_solution) >= 2:
350
- first = optimized_solution[0][0]
351
- last = optimized_solution[-1][0]
352
- diff = abs(first - last)
353
- if diff < 0.1:
354
- corner_info = f"✨ PERFECT ({first:.1f}m × 2)"
355
- elif diff <= 1.0:
356
- corner_info = f"✅ Excellent ({first:.1f}m + {last:.1f}m)"
357
- elif diff <= 2.0:
358
- corner_info = f"👍 Good ({first:.1f}m + {last:.1f}m)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  else:
360
- corner_info = f"⚠️ Unbalanced ({first:.1f}m + {last:.1f}m)"
 
 
 
 
361
 
362
- summary = f"""
363
- **Stage**: {stage_width}m × {stage_depth}m = {stage_width * stage_depth}m²
364
- **Total Lots**: {total_lots}
365
- **Unique Lot Types**: {unique_widths}
366
- **Grid Variance**: {variance:+.2f}m {"✅" if abs(variance) < 0.001 else "⚠️"}
367
- """
368
 
369
- manual_edit_string = optimizer.solution_to_string(optimized_solution)
370
- return fig_2d, results_df, summary, report, manual_edit_string
371
 
372
- def update_manual_adjustment(manual_widths_text, stage_width, stage_depth, color_scheme):
373
- """Update visualization based on manually entered widths"""
374
- optimizer.current_scheme = color_scheme
375
- widths = optimizer.parse_manual_adjustments(manual_widths_text)
376
- if not widths:
377
- return None, "Please enter lot widths (e.g. '14.0, 8.5, 10.5, 8.5, 14.0')"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
 
379
- solution, feedback = optimizer.validate_manual_solution(widths, stage_width)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  if not solution:
381
- return None, feedback
382
 
383
- total_width = sum(widths)
384
- variance = total_width - stage_width
 
 
385
 
386
- fig = optimizer.create_enhanced_visualization(
387
- solution, stage_width, stage_depth,
388
- "Manually Adjusted Layout", show_variance=variance
389
- )
390
- return fig, feedback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
- def process_uploaded_plan(file_path, scale, auto_detect, confidence):
393
- if not file_path:
394
- return None, pd.DataFrame(), "Please upload a plan file"
395
- preview, lot_data, status = optimizer.process_plan_image(
396
- file_path, scale, auto_detect, confidence
397
- )
398
- if lot_data:
399
- df = optimizer.lot_data_to_dataframe(lot_data)
400
- return preview, df, status
401
- else:
402
- return preview, pd.DataFrame(), status
403
-
404
- def export_to_csv(df):
405
- if df is None or df.empty:
406
- return gr.update(visible=False), "No data to export"
407
- csv_content = optimizer.export_lot_data_to_csv(df)
408
- return gr.update(value=csv_content, visible=True), "✅ CSV data ready – copy and save as .csv file"
409
-
410
- def send_to_optimizer(df):
411
- if df is None or df.empty:
412
- return 0, 32, "No data to send"
413
- width, depth = optimizer.convert_lot_data_to_stage_format(df)
414
- if width is None:
415
- return 0, 32, "No data to send"
416
- return width, depth, f"✅ Stage dimensions set to {width:.1f}m × {depth:.1f}m\nSwitch to 'AI Optimization' tab to continue"
417
-
418
- def validate_lot_data(df):
419
- if df is None or df.empty:
420
- return "No data to validate"
421
- issues = []
422
- if df.isnull().any().any():
423
- issues.append("⚠️ Missing values detected")
424
- if (df['Frontage (m)'] < 6).any():
425
- issues.append("⚠️ Some lots have frontage < 6m")
426
- if (df['Frontage (m)'] > 30).any():
427
- issues.append("⚠️ Some lots have frontage > 30m")
428
- if len(df) < 5:
429
- issues.append("ℹ️ Few lots detected – check if all were found")
430
- return "✅ Data looks good! {} lots ready for optimization".format(len(df)) \
431
- if not issues else "\n".join(issues)
432
-
433
- def add_lot_row(df):
434
- if df is None or df.empty:
435
- new_row = pd.DataFrame({
436
- "Lot #": ["L1"],
437
- "Frontage (m)": [12.5],
438
- "Depth (m)": [32.0],
439
- "Area (m²)": [12.5 * 32],
440
- "Type": ["Standard"]
441
- })
442
- return new_row
443
- else:
444
- last_lot_num = len(df) + 1
445
- new_row = pd.DataFrame({
446
- "Lot #": [f"L{last_lot_num}"],
447
- "Frontage (m)": [12.5],
448
- "Depth (m)": [32.0],
449
- "Area (m²)": [12.5 * 32],
450
- "Type": ["Standard"]
451
- })
452
- return pd.concat([df, new_row], ignore_index=True)
453
-
454
- def remove_selected_rows(df, rows_to_remove):
455
- if df is None or df.empty:
456
- return df
457
- if not rows_to_remove:
458
- return df
459
- return df.drop(rows_to_remove, axis=0, errors='ignore').reset_index(drop=True)
460
-
461
- with gr.Blocks(
462
- title="Advanced AI Grid Optimizer",
463
- theme=gr.themes.Base(),
464
- css="""
465
- .gradio-container {
466
- font-family: 'Segoe UI', sans-serif;
467
- background: #1a1a1a;
468
- color: white;
469
- }
470
- .gr-button-primary {
471
- background: linear-gradient(45deg, #FF073A 30%, #0AEFFF 90%);
472
- border: none;
473
- box-shadow: 0 3px 5px 2px rgba(255, 7, 58, .3);
474
- }
475
- h1 {
476
- background: linear-gradient(45deg, #FF073A, #0AEFFF);
477
- -webkit-background-clip: text;
478
- -webkit-text-fill-color: transparent;
479
- text-align: center;
480
- font-size: 2.5em;
481
- }
482
- .gr-form {
483
- background: rgba(42, 42, 42, 0.9);
484
- border-radius: 10px;
485
- padding: 20px;
486
- border: 1px solid #444;
487
- }
488
- .gr-input {
489
- background-color: #2a2a2a;
490
- color: white;
491
- border: 1px solid #444;
492
- }
493
- .gr-check-radio {
494
- background-color: #2a2a2a;
495
- }
496
- """
497
- ) as demo:
498
- gr.Markdown("""
499
- # 🏗️ Advanced AI Grid Cut Optimizer Pro
500
- ### AI-Powered Subdivision Planning with Manual Fine-Tuning
501
- """)
502
-
503
- with gr.Tabs():
504
- with gr.TabItem("🤖 AI Optimization"):
505
- with gr.Row():
506
- with gr.Column(scale=1):
507
- with gr.Group():
508
- gr.Markdown("### 📐 Stage Dimensions")
509
- stage_width = gr.Number(
510
- label="Stage Width (m)",
511
- value=105.0,
512
- info="Width along the street"
513
- )
514
- stage_depth = gr.Number(
515
- label="Stage Depth (m)",
516
- value=32.0,
517
- info="Depth of lots (perpendicular to street)"
518
- )
519
-
520
- gr.Markdown("### 📏 Lot Width Options")
521
- with gr.Group():
522
- gr.Markdown("**Standard Widths**")
523
- with gr.Row():
524
- enable_8_5 = gr.Checkbox(label="8.5m SLHC", value=True)
525
- enable_10_5 = gr.Checkbox(label="10.5m SLHC", value=True)
526
- enable_12_5 = gr.Checkbox(label="12.5m", value=True)
527
- with gr.Row():
528
- enable_14 = gr.Checkbox(label="14.0m", value=True)
529
- enable_16 = gr.Checkbox(label="16.0m", value=True)
530
- enable_18 = gr.Checkbox(label="18.0m", value=False)
531
-
532
- with gr.Group():
533
- enable_corners = gr.Checkbox(
534
- label="Enable Corner-Specific Widths",
535
- value=True,
536
- info="Adds variety and helps achieve 100%"
537
- )
538
- with gr.Row():
539
- enable_11 = gr.Checkbox(label="11.0m", value=True)
540
- enable_13_3 = gr.Checkbox(label="13.3m", value=True)
541
- with gr.Row():
542
- enable_14_8 = gr.Checkbox(label="14.8m", value=True)
543
- enable_16_8 = gr.Checkbox(label="16.8m", value=True)
544
-
545
- with gr.Column(scale=1):
546
- gr.Markdown("### ⚙️ Advanced Settings")
547
- allow_custom_corners = gr.Checkbox(
548
- label="🎯 Allow Flexible Corner Widths",
549
- value=True,
550
- info="Enables 13.8m, 13.9m, etc., for perfect fits"
551
- )
552
- optimization_strategy = gr.Radio(
553
- ["diversity_focus", "balanced"],
554
- label="Optimization Strategy",
555
- value="diversity_focus",
556
- info="Diversity creates more interesting layouts"
557
- )
558
- color_scheme = gr.Radio(
559
- ["modern", "professional", "neon"],
560
- label="🎨 Color Scheme",
561
- value="neon",
562
- info="Neon colors work best with dark background"
563
- )
564
- optimize_btn = gr.Button(
565
- "🚀 Optimize with AI",
566
- variant="primary",
567
- size="lg",
568
- elem_id="optimize-button"
569
- )
570
- gr.Markdown("""
571
- ### 💡 Quick Tips:
572
- - **Visual Fix**: All lots now align at rear boundary
573
- - **Corner Lots**: Always wider than internals
574
- - **Grid Variance**: Shows if layout is perfect (0.0m)
575
- - **Manual Adjust**: Edit the result below after optimization
576
- """)
577
-
578
- with gr.Row():
579
- plot_2d = gr.Plot(label="2D Layout with Corner Splays")
580
-
581
- gr.Markdown("### ✏️ Fine‐Tune AI Result")
582
- with gr.Row():
583
- with gr.Column(scale=2):
584
- manual_widths = gr.Textbox(
585
- label="Manually Adjust Lot Widths",
586
- placeholder="Widths will appear here after optimization",
587
- info="Edit the widths (comma‐separated) and click 'Update Layout'",
588
- lines=2
589
- )
590
- with gr.Column(scale=1):
591
- update_btn = gr.Button("🔄 Update Layout", variant="secondary")
592
- adjustment_feedback = gr.Markdown(
593
- value="",
594
- label="Adjustment Feedback"
595
- )
596
-
597
- with gr.Row():
598
- results_table = gr.DataFrame(label="Lot Distribution Analysis")
599
-
600
- with gr.Row():
601
- with gr.Column():
602
- summary_output = gr.Markdown(label="Optimization Summary")
603
- with gr.Column():
604
- report_output = gr.Markdown(label="Professional Report")
605
-
606
- optimize_btn.click(
607
- optimize_grid,
608
- inputs=[
609
- stage_width, stage_depth,
610
- enable_8_5, enable_10_5, enable_12_5,
611
- enable_14, enable_16, enable_18,
612
- enable_corners, enable_11, enable_13_3,
613
- enable_14_8, enable_16_8,
614
- allow_custom_corners, optimization_strategy, color_scheme
615
- ],
616
- outputs=[plot_2d, results_table, summary_output, report_output, manual_widths]
617
- )
618
 
619
- update_btn.click(
620
- update_manual_adjustment,
621
- inputs=[manual_widths, stage_width, stage_depth, color_scheme],
622
- outputs=[plot_2d, adjustment_feedback]
623
- )
624
 
625
- with gr.TabItem("📊 Plan Reader"):
626
- gr.Markdown("""
627
- ## 🏢 AI Plan Reader
628
- ### Upload your subdivision plan to automatically extract lot information
629
- """)
630
-
631
- with gr.Row():
632
- with gr.Column(scale=1):
633
- plan_upload = gr.File(
634
- label="Upload Subdivision Plan",
635
- file_types=["image", "pdf"],
636
- type="filepath"
637
- )
638
- gr.Markdown("""
639
- **Supported Formats:**
640
- - PDF plans
641
- - PNG/JPG images
642
- - CAD exports
643
-
644
- **Best Results:**
645
- - High resolution (300+ DPI)
646
- - Clear lot numbers
647
- - Visible frontage dimensions
648
- - North arrow included
649
- """)
650
- process_plan_btn = gr.Button(
651
- "🔍 Analyze Plan",
652
- variant="primary",
653
- size="lg"
654
- )
655
- with gr.Group():
656
- gr.Markdown("**Analysis Settings**")
657
- scale_input = gr.Number(
658
- label="Scale (1:X)",
659
- value=1000,
660
- info="Drawing scale ratio"
661
- )
662
- auto_detect_scale = gr.Checkbox(
663
- label="Auto-detect scale from plan",
664
- value=True
665
- )
666
- confidence_threshold = gr.Slider(
667
- label="Detection Confidence",
668
- minimum=0.5,
669
- maximum=0.95,
670
- value=0.75,
671
- step=0.05,
672
- info="Higher = more accurate but may miss some lots"
673
- )
674
-
675
- with gr.Column(scale=2):
676
- plan_preview = gr.Image(
677
- label="Analyzed Plan Preview",
678
- type="numpy"
679
- )
680
- analysis_status = gr.Markdown(
681
- value="Upload a plan to begin analysis",
682
- label="Analysis Status"
683
- )
684
-
685
- gr.Markdown("### 📊 Extracted Lot Data")
686
- with gr.Row():
687
- extracted_data = gr.DataFrame(
688
- headers=["Lot #", "Frontage (m)", "Depth (m)", "Area (m²)", "Type"],
689
- label="Detected Lots",
690
- interactive=True
691
- )
692
- with gr.Column():
693
- extraction_summary = gr.Markdown(label="Extraction Summary")
694
- export_btn = gr.Button("📥 Export to CSV", variant="secondary")
695
- send_to_optimizer_btn = gr.Button("➡️ Send to Optimizer", variant="primary")
696
-
697
- gr.Markdown("### ✏️ Manual Corrections")
698
- with gr.Row():
699
- with gr.Column():
700
- gr.Markdown("""
701
- **Quick Edit Tools:**
702
- - Double‐click cells to edit
703
- - Add missing lots manually
704
- - Correct misread numbers
705
- - Adjust frontages
706
- """)
707
- add_lot_btn = gr.Button("➕ Add Lot", size="sm")
708
- remove_selected_btn = gr.Button("➖ Remove Selected", size="sm")
709
- with gr.Column():
710
- validation_result = gr.Markdown(label="Data Validation")
711
-
712
- process_plan_btn.click(
713
- process_uploaded_plan,
714
- inputs=[plan_upload, scale_input, auto_detect_scale, confidence_threshold],
715
- outputs=[plan_preview, extracted_data, analysis_status]
716
- )
717
 
718
- export_btn.click(
719
- export_to_csv,
720
- inputs=[extracted_data],
721
- outputs=[gr.Textbox(label="CSV Export (copy & save)", lines=8, visible=False),
722
- extraction_summary]
723
- )
724
 
725
- send_to_optimizer_btn.click(
726
- send_to_optimizer,
727
- inputs=[extracted_data],
728
- outputs=[stage_width, stage_depth, extraction_summary]
729
- )
730
 
731
- extracted_data.change(
732
- validate_lot_data,
733
- inputs=[extracted_data],
734
- outputs=[validation_result]
735
- )
736
 
737
- add_lot_btn.click(
738
- add_lot_row,
739
- inputs=[extracted_data],
740
- outputs=[extracted_data]
741
- )
742
 
743
- remove_selected_btn.click(
744
- remove_selected_rows,
745
- inputs=[extracted_data, gr.State([])],
746
- outputs=[extracted_data]
747
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
 
749
- return demo
 
 
750
 
 
751
 
752
- if __name__ == "__main__":
753
- app = create_advanced_app()
754
- app.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import pandas as pd
3
+ import numpy as np
4
+ import matplotlib.pyplot as plt
5
+ import matplotlib.patches as patches
6
+ from matplotlib.patches import FancyBboxPatch, Path, PathPatch, Rectangle
7
+ from matplotlib.collections import PatchCollection
8
+ from datetime import datetime
9
+ import io
10
+ import json
11
+ import tempfile
12
+ import re
13
+
14
+ # Try to import plan reading libraries
15
+ try:
16
+ import cv2
17
+ import pytesseract
18
+ from PIL import Image
19
+ from pdf2image import convert_from_path
20
+ PLAN_READER_AVAILABLE = True
21
+ except ImportError:
22
+ PLAN_READER_AVAILABLE = False
23
+ print("Warning: Plan reader libraries not available. "
24
+ "Install opencv-python, pytesseract, pillow, and pdf2image for full functionality.")
25
+
26
+
27
+ class AdvancedGridOptimizer:
28
+ def __init__(self):
29
+ # Standard lot widths and their typical depths
30
+ self.lot_specifications = {
31
+ 8.5: {"depths": [21, 25, 28], "type": "SLHC", "squares": "11-16"},
32
+ 10.5: {"depths": [21, 25, 28, 32, 35], "type": "SLHC", "squares": "13-21.5"},
33
+ 12.5: {"depths": [21, 25, 28, 30, 32], "type": "Standard", "squares": "16-24"},
34
+ 14.0: {"depths": [21, 25, 28, 30, 32, 34], "type": "Standard", "squares": "17-28"},
35
+ 16.0: {"depths": [28, 30, 32, 34, 36, 40], "type": "Premium", "squares": "24-38"},
36
+ 18.0: {"depths": [32, 34, 36], "type": "Premium", "squares": "32-39"},
37
+ # Traditional corner lots
38
+ 11.0: {"depths": [21, 25], "type": "Corner-SLHC", "squares": "13-17"},
39
+ 13.3: {"depths": [25, 28], "type": "Corner-Standard", "squares": "18-22"},
40
+ 14.8: {"depths": [28, 30], "type": "Corner-Standard", "squares": "22-26"},
41
+ 16.8: {"depths": [30, 32], "type": "Corner-Premium", "squares": "26-32"}
42
+ }
43
 
44
+ self.slhc_widths = [8.5, 10.5]
45
+ self.standard_widths = [12.5, 14.0]
46
+ self.premium_widths = [16.0, 18.0]
47
+ self.corner_specific = [11.0, 13.3, 14.8, 16.8]
48
+
49
+ # Define corner_widths as all widths suitable for corners
50
+ self.corner_widths = self.corner_specific + [14.0, 16.0, 18.0]
51
+
52
+ # Enhanced color palette with gradients
53
+ self.color_schemes = {
54
+ 'modern': {
55
+ 8.5: '#FF6B6B', 10.5: '#4ECDC4', 12.5: '#45B7D1',
56
+ 14.0: '#96CEB4', 16.0: '#DDA0DD', 18.0: '#FFD93D',
57
+ 11.0: '#FFA07A', 13.3: '#98D8C8', 14.8: '#F7DC6F',
58
+ 16.8: '#BB8FCE'
59
+ },
60
+ 'professional': {
61
+ 8.5: '#E74C3C', 10.5: '#3498DB', 12.5: '#2ECC71',
62
+ 14.0: '#F39C12', 16.0: '#9B59B6', 18.0: '#1ABC9C',
63
+ 11.0: '#E67E22', 13.3: '#16A085', 14.8: '#F1C40F',
64
+ 16.8: '#8E44AD'
65
+ },
66
+ 'neon': {
67
+ 8.5: '#FF073A', 10.5: '#0AEFFF', 12.5: '#39FF14',
68
+ 14.0: '#FF6600', 16.0: '#BF00FF', 18.0: '#FFFF00',
69
+ 11.0: '#FF1493', 13.3: '#00FFFF', 14.8: '#FFF700',
70
+ 16.8: '#FF00FF'
71
+ }
72
+ }
73
 
74
+ self.current_scheme = 'neon'
75
+ self.current_solution = None # Store current AI solution
76
+
77
+ def create_enhanced_visualization(self, solution, stage_width, stage_depth=32,
78
+ title="Premium Grid Layout", show_variance=None):
79
+ """Create a clean 2D visualization with corner splays and proper alignment"""
80
+ fig, (ax1, ax2) = plt.subplots(
81
+ 2, 1, figsize=(18, 12),
82
+ gridspec_kw={'height_ratios': [3, 1]},
83
+ facecolor='#1a1a1a'
84
+ )
85
+
86
+ colors = self.color_schemes[self.current_scheme]
87
+ x_pos = 0
88
+ lot_num = 1
89
+ ax1.set_xlim(-5, stage_width + 5)
90
+ ax1.set_ylim(-10, 50)
91
+ ax1.set_facecolor('#1a1a1a')
92
+
93
+ # Title with variance if provided
94
+ if show_variance is not None:
95
+ title_text = f"{title}\nGrid Variance: {show_variance:+.1f}m"
96
+ ax1.set_title(title_text, fontsize=28, fontweight='bold', pad=25, color='white')
97
+ else:
98
+ ax1.set_title(title, fontsize=28, fontweight='bold', pad=25, color='white')
99
+
100
+ # Subtle dark gradient background
101
+ gradient = np.linspace(0.2, 0, 100).reshape(1, -1)
102
+ ax1.imshow(gradient, extent=[-5, stage_width + 5, -10, 50],
103
+ aspect='auto', cmap='Greys', alpha=0.3, zorder=0)
104
+
105
+ # Draw street rectangle
106
+ street = Rectangle((-5, -8), stage_width + 10, 12,
107
+ facecolor='#2c2c2c', alpha=0.9, zorder=1,
108
+ edgecolor='#444444', linewidth=2)
109
+ ax1.add_patch(street)
110
+ ax1.text(stage_width / 2, -2, 'STREET', ha='center', va='center',
111
+ fontsize=20, color='white', fontweight='bold')
112
+
113
+ # Corner splay size and uniform lot height
114
+ splay_size = 3
115
+ lot_height = 28
116
+
117
+ for i, (width, lot_type) in enumerate(solution):
118
+ # Pick base color, or nearest if missing
119
+ if width in colors:
120
+ base_color = colors[width]
121
  else:
122
+ closest_width = min(colors.keys(), key=lambda x: abs(x - width))
123
+ base_color = colors[closest_width]
124
+
125
+ is_corner = (i == 0 or i == len(solution) - 1)
126
+ face_color = base_color
127
+ edge_color = 'white'
128
+ linewidth = 4.0 if is_corner else 3.0
129
+
130
+ if is_corner:
131
+ # Build a 5‐vertex corner polygon (splay at front) &
132
+ # keep uniform lot height
133
+ if i == 0:
134
+ vertices = [
135
+ (x_pos + splay_size, 8), # start after splay
136
+ (x_pos + width, 8),
137
+ (x_pos + width, 8 + lot_height),
138
+ (x_pos, 8 + lot_height),
139
+ (x_pos, 8 + splay_size)
140
+ ]
141
+ else:
142
+ vertices = [
143
+ (x_pos, 8),
144
+ (x_pos + width - splay_size, 8),
145
+ (x_pos + width, 8 + splay_size),
146
+ (x_pos + width, 8 + lot_height),
147
+ (x_pos, 8 + lot_height)
148
+ ]
149
+
150
+ # Close polygon path
151
+ codes = [Path.MOVETO] + [Path.LINETO] * (len(vertices) - 1) + [Path.CLOSEPOLY]
152
+ vertices.append(vertices[0])
153
+ path = Path(vertices, codes)
154
+ lot_patch = PathPatch(path, facecolor=face_color,
155
+ edgecolor=edge_color, linewidth=linewidth, zorder=3)
156
+ ax1.add_patch(lot_patch)
157
+
158
+ # Draw splay line
159
+ if i == 0:
160
+ ax1.plot([x_pos, x_pos + splay_size], [8 + splay_size, 8],
161
+ 'white', linewidth=2, alpha=0.8)
162
+ else:
163
+ ax1.plot([x_pos + width - splay_size, x_pos + width],
164
+ [8, 8 + splay_size], 'white', linewidth=2, alpha=0.8)
165
  else:
166
+ # Regular rectangular (rounded) lot
167
+ lot_patch = FancyBboxPatch(
168
+ (x_pos, 8), width, lot_height,
169
+ boxstyle="round,pad=0.1",
170
+ facecolor=face_color,
171
+ edgecolor=edge_color,
172
+ linewidth=linewidth,
173
+ zorder=3
174
+ )
175
+ ax1.add_patch(lot_patch)
176
+
177
+ # Add glow underneath
178
+ glow = FancyBboxPatch(
179
+ (x_pos - 0.2, 7.8), width + 0.4, lot_height + 0.4,
180
+ boxstyle="round,pad=0.15",
181
+ facecolor='none',
182
+ edgecolor=face_color,
183
+ linewidth=1,
184
+ alpha=0.5,
185
+ zorder=2
186
+ )
187
+ ax1.add_patch(glow)
188
+
189
+ # Rear alignment dashed line
190
+ rear_y = 8 + lot_height
191
+ ax1.plot([x_pos, x_pos + width], [rear_y, rear_y],
192
+ color=edge_color, linewidth=1, alpha=0.3, linestyle='--')
193
+
194
+ # Lot labels (number & width)
195
+ ax1.text(x_pos + width / 2, 40, f"L{lot_num}",
196
+ ha='center', va='center', fontsize=16, fontweight='bold', color='white')
197
+ ax1.text(x_pos + width / 2, 35, f"{width:.1f}m",
198
+ ha='center', va='center', fontsize=14, fontweight='bold', color='white')
199
+
200
+ # Lot type text inside a dark box
201
+ if int(width) in self.lot_specifications:
202
+ spec = self.lot_specifications[int(width)]
203
+ elif width in self.lot_specifications:
204
+ spec = self.lot_specifications[width]
205
+ else:
206
+ closest_width = min(self.lot_specifications.keys(),
207
+ key=lambda x: abs(x - width))
208
+ spec = self.lot_specifications[closest_width]
209
+ spec = {**spec, 'type': 'Custom'}
210
+
211
+ lot_type_text = spec['type']
212
+ if is_corner:
213
+ lot_type_text = "CORNER"
214
+
215
+ ax1.text(
216
+ x_pos + width / 2, 23, lot_type_text,
217
+ ha='center', va='center', fontsize=11,
218
+ bbox=dict(
219
+ boxstyle="round,pad=0.3",
220
+ facecolor='#333333',
221
+ edgecolor='white',
222
+ alpha=0.9
223
+ ),
224
+ color='white'
225
+ )
226
 
227
+ # Dimension indicator lines at front of lot
228
+ ax1.plot([x_pos, x_pos + width], [12, 12], 'w-', linewidth=1, alpha=0.3)
229
+ ax1.plot([x_pos, x_pos], [10, 14], 'w-', linewidth=1, alpha=0.3)
230
+ ax1.plot([x_pos + width, x_pos + width], [10, 14], 'w-', linewidth=1, alpha=0.3)
231
+
232
+ x_pos += width
233
+ lot_num += 1
234
+
235
+ # Draw one final “rear” alignment line across everything
236
+ ax1.plot([0, stage_width], [8 + lot_height, 8 + lot_height],
237
+ 'cyan', linewidth=2, alpha=0.8, linestyle='-')
238
+ ax1.text(
239
+ stage_width / 2, 8 + lot_height + 1, 'REAR ALIGNMENT LINE',
240
+ ha='center', va='bottom', fontsize=12, color='cyan', alpha=0.8,
241
+ bbox=dict(
242
+ boxstyle="round,pad=0.3",
243
+ facecolor='#1a1a1a',
244
+ edgecolor='cyan',
245
+ alpha=0.8
246
+ )
247
+ )
248
 
249
+ # Draw stage dimension arrow & text
250
+ arrow_props = dict(arrowstyle='<->', color='white', lw=3)
251
+ ax1.annotate('', xy=(0, -6), xytext=(stage_width, -6), arrowprops=arrow_props)
252
+ ax1.text(stage_width / 2, -7, f'{stage_width}m × {stage_depth}m',
253
+ ha='center', va='top', fontsize=16, fontweight='bold', color='white')
254
 
255
+ # Hide all axes spines/ticks
256
+ ax1.set_xticks([]); ax1.set_yticks([])
257
+ for spine in ax1.spines.values():
258
+ spine.set_visible(False)
 
259
 
260
+ # ========== Metrics panel below ========== #
261
+ ax2.axis('off')
262
+ ax2.set_facecolor('#1a1a1a')
 
 
 
263
 
264
+ total_lots = len(solution)
265
+ unique_widths = len(set(w for w, _ in solution))
266
+ diversity_score = unique_widths / len(set(self.lot_specifications.keys()))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
+ slhc_count = sum(1 for w, _ in solution if w <= 10.5)
269
+ standard_count = sum(1 for w, _ in solution if 10.5 < w <= 14)
270
+ premium_count = sum(1 for w, _ in solution if w > 14)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
+ slhc_pairs = sum(
273
+ 1 for i in range(len(solution) - 1)
274
+ if solution[i][0] <= 10.5 and solution[i + 1][0] <= 10.5
275
+ )
276
 
277
+ total_width = sum(w for w, _ in solution)
278
+ variance = total_width - stage_width
279
+ efficiency = "100%" if abs(variance) < 0.001 else f"{(total_width / stage_width) * 100:.1f}%"
280
+
281
+ metrics_lines = [
282
+ f"📊 TOTAL LOTS: {total_lots}",
283
+ f"📐 LAND EFFICIENCY: {efficiency}",
284
+ f"🎯 DIVERSITY: {diversity_score:.0%} ({unique_widths} types)",
285
+ f"📏 GRID VARIANCE: {variance:+.2f}m",
286
+ "",
287
+ f"SLHC (≤10.5m): {slhc_count} lots",
288
+ f"Standard (11–14m): {standard_count} lots",
289
+ f"Premium (>14m): {premium_count} lots",
290
+ "",
291
+ f"🚗 SLHC Pairs: {slhc_pairs}",
292
+ f"💰 Revenue: ${total_lots * 0.5:.1f}M – ${total_lots * 1.2:.1f}M"
293
+ ]
294
+
295
+ col1_text = '\n'.join(metrics_lines[:5])
296
+ col2_text = '\n'.join(metrics_lines[5:])
297
+
298
+ ax2.text(
299
+ 0.05, 0.5, col1_text, transform=ax2.transAxes,
300
+ fontsize=14, verticalalignment='center', fontweight='bold',
301
+ color='white',
302
+ bbox=dict(
303
+ boxstyle="round,pad=0.5",
304
+ facecolor='#2a2a2a',
305
+ edgecolor='#444444',
306
+ alpha=0.8
307
+ )
308
+ )
309
+ ax2.text(
310
+ 0.55, 0.5, col2_text, transform=ax2.transAxes,
311
+ fontsize=14, verticalalignment='center', fontweight='bold',
312
+ color='white',
313
+ bbox=dict(
314
+ boxstyle="round,pad=0.5",
315
+ facecolor='#2a2a2a',
316
+ edgecolor='#444444',
317
+ alpha=0.8
318
+ )
319
+ )
320
 
321
+ plt.tight_layout()
322
+ return fig
 
 
 
 
323
 
324
+ def parse_manual_adjustments(self, adjustment_text):
325
+ """Parse manual adjustment input into a list of widths"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  try:
327
+ if not adjustment_text:
328
+ return []
329
+ adjustment_text = adjustment_text.strip()
330
+ parts = re.split(r'[,\s]+', adjustment_text)
331
+ widths = [float(w.strip()) for w in parts if w.strip()]
332
+ return widths
333
+ except Exception as e:
334
+ print(f"Error parsing manual adjustments: {e}")
335
+ return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
 
337
+ def validate_manual_solution(self, widths, stage_width):
338
+ """Validate and provide feedback on manual solution"""
339
+ if not widths:
340
+ return None, "No widths provided"
341
 
342
+ total_width = sum(widths)
343
+ variance = total_width - stage_width
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
 
345
+ solution = [
346
+ (w, 'corner' if i in [0, len(widths) - 1] else 'standard')
347
+ for i, w in enumerate(widths)
348
+ ]
349
+
350
+ if abs(variance) < 0.001:
351
+ feedback = "✅ Perfect fit! Grid is exactly aligned."
352
+ elif variance > 0:
353
+ feedback = f"⚠️ Grid is {variance:.2f}m too wide."
354
+ else:
355
+ feedback = f"⚠️ Grid is {-variance:.2f}m too narrow."
356
+
357
+ if abs(variance) > 0.001:
358
+ if variance > 0:
359
+ suggestions = []
360
+ for i, w in enumerate(widths):
361
+ if w - variance >= 8.5:
362
+ suggestions.append(f"L{i + 1}: reduce from {w:.1f}m to {w - variance:.1f}m")
363
+ if suggestions:
364
+ feedback += "\n\nSuggestions:\n" + "\n".join(suggestions[:3])
365
  else:
366
+ add_each = -variance / len(widths)
367
+ feedback += f"\n\nSuggestion: Add {add_each:.2f}m to each lot"
368
+
369
+ return solution, feedback
370
+
371
+ def solution_to_string(self, solution):
372
+ """Convert solution to comma‐separated string for manual editing"""
373
+ if not solution:
374
+ return ""
375
+ return ", ".join([f"{w:.1f}" for w, _ in solution])
376
+
377
+ def optimize_with_corners_diverse(self, stage_width, enabled_widths, manual_allocation=None):
378
+ """Find lot arrangement with emphasis on diversity & proper corners"""
379
+ all_widths = sorted(enabled_widths)
380
+ if not all_widths:
381
+ return None
382
+
383
+ min_internal = min(all_widths)
384
+ corner_options = [w for w in enabled_widths if w >= max(11.0, min_internal)]
385
+
386
+ best_solution = None
387
+ best_fitness = -float('inf')
388
+
389
+ for corner1 in corner_options:
390
+ for corner2 in corner_options:
391
+ if abs(corner1 - corner2) > 3.0:
392
+ continue
393
+ internal_space = stage_width - corner1 - corner2
394
+ if internal_space <= 0:
395
+ continue
396
+ internal_sols = self.find_diverse_combinations(internal_space, all_widths, max_solutions=20)
397
+ for internal_list in internal_sols:
398
+ if not internal_list:
399
+ continue
400
+ if max(internal_list) > min(corner1, corner2):
401
+ continue
402
+ solution = [(corner1, 'corner')]
403
+ solution.extend([(w, 'standard') for w in internal_list])
404
+ solution.append((corner2, 'corner'))
405
+ optimized = self.optimize_slhc_grouping(solution)
406
+ fitness = self.evaluate_solution_with_diversity(optimized, stage_width)
407
+ if fitness > best_fitness:
408
+ best_fitness = fitness
409
+ best_solution = optimized
410
+
411
+ if not best_solution:
412
+ all_combos = []
413
+ self.find_all_combinations_recursive(stage_width, sorted(enabled_widths), [], all_combos, 20)
414
+ for combo in all_combos[:50]:
415
+ sorted_combo = sorted(combo)
416
+ if len(sorted_combo) >= 2:
417
+ sol = [(sorted_combo[-1], 'corner')]
418
+ sol.extend((w, 'standard') for w in sorted_combo[:-2])
419
+ sol.append((sorted_combo[-2], 'corner'))
420
  else:
421
+ sol = [(w, 'standard') for w in sorted_combo]
422
+ optimized = self.optimize_slhc_grouping(sol)
423
+ fitness = self.evaluate_solution_with_diversity(optimized, stage_width)
424
+ if fitness > best_fitness:
425
+ best_fitness = fitness
426
+ best_solution = optimized
427
+
428
+ return best_solution
429
+
430
+ def optimize_with_flexible_corners(self, stage_width, enabled_widths, allow_custom_corners=True):
431
+ """Enhanced optimization allowing corner widths ±0.5m variation"""
432
+ standard_internal = [w for w in enabled_widths if w not in self.corner_specific]
433
+
434
+ best_solution = None
435
+ best_fitness = -float('inf')
436
+
437
+ # Try strict corner widths first
438
+ sol = self.optimize_with_corners_diverse(stage_width, enabled_widths, None)
439
+ if sol:
440
+ fitness = self.evaluate_solution_with_diversity(sol, stage_width)
441
+ if fitness > best_fitness:
442
+ best_fitness = fitness
443
+ best_solution = sol
444
+
445
+ # If custom corners allowed, vary around each base corner
446
+ if allow_custom_corners and standard_internal:
447
+ for base in [11.0, 13.3, 14.8, 16.8, 14.0, 16.0]:
448
+ if any(abs(w - base) < 2 for w in enabled_widths):
449
+ custom_sol = self.find_optimal_custom_corners(
450
+ stage_width, standard_internal, base, tolerance=0.5
451
+ )
452
+ if custom_sol:
453
+ fitness = self.evaluate_solution_with_diversity(custom_sol, stage_width)
454
+ if fitness > best_fitness:
455
+ best_fitness = fitness
456
+ best_solution = custom_sol
457
+
458
+ return best_solution
459
+
460
+ def find_optimal_custom_corners(self, stage_width, internal_widths, base_corner_width, tolerance=0.5):
461
+ """Search corner widths ±0.5m to maximize diversity & fit exactly"""
462
+ best_solution = None
463
+ best_fitness = -float('inf')
464
+
465
+ min_internal = min(internal_widths) if internal_widths else 8.5
466
+ min_corner = max(base_corner_width - tolerance, min_internal)
467
+ variations = np.arange(min_corner, base_corner_width + tolerance + 0.1, 0.1)
468
+
469
+ for c1 in variations:
470
+ for c2 in variations:
471
+ internal_space = stage_width - c1 - c2
472
+ if internal_space <= 0:
473
+ continue
474
+ internal_sol = self.find_exact_solution_with_diversity(internal_space, internal_widths)
475
+ if internal_sol:
476
+ if max(internal_sol) > min(c1, c2):
477
+ continue
478
+ solution = [(round(c1, 1), 'corner')]
479
+ solution.extend((w, 'standard') for w in internal_sol)
480
+ solution.append((round(c2, 1), 'corner'))
481
+ fitness = self.evaluate_solution_with_diversity(solution, stage_width)
482
+ if fitness > best_fitness:
483
+ best_fitness = fitness
484
+ best_solution = solution
485
+
486
+ return best_solution
487
+
488
+ def find_diverse_combinations(self, target_width, available_widths, max_solutions=20):
489
+ """Find all combos that sum to target_width, then pick the top‐20 by diversity"""
490
+ all_solutions = []
491
+ self.find_all_combinations_recursive(target_width, available_widths, [], all_solutions, 20)
492
+ if not all_solutions:
493
+ return []
494
 
495
+ scored = []
496
+ for sol in all_solutions:
497
+ scored.append((len(set(sol)), sol))
498
+ scored.sort(key=lambda x: (x[0], len(x[1])), reverse=True)
499
+ return [sol for _, sol in scored[:max_solutions]]
500
+
501
+ def find_exact_solution_with_diversity(self, target_width, enabled_widths, max_depth=20):
502
+ """Dynamic‐programming approach to find an exact‐sum solution that maximizes diversity"""
503
+ dp = {0: ([], set())} # {sum: (solution_list, unique_widths)}
504
+
505
+ for current in range(1, int(target_width) + 1):
506
+ best_div = -1
507
+ best_pair = None
508
+ for w in enabled_widths:
509
+ if w <= current and (current - w) in dp:
510
+ prev_list, prev_set = dp[current - w]
511
+ if len(prev_list) < max_depth:
512
+ new_list = prev_list + [w]
513
+ new_set = prev_set | {w}
514
+ diversity = len(new_set)
515
+ if diversity > best_div:
516
+ best_div = diversity
517
+ best_pair = (new_list, new_set)
518
+ if best_pair:
519
+ dp[current] = best_pair
520
+
521
+ if target_width in dp:
522
+ return dp[target_width][0]
523
+
524
+ # Fallback to simpler exact‐sum
525
+ return self.find_exact_solution(target_width, enabled_widths, max_depth)
526
+
527
+ def find_exact_solution(self, target_width, enabled_widths, max_depth=20):
528
+ """Classic DP to find ANY combination of enabled_widths summing to target_width"""
529
+ # Quick check for a single‐width repetition
530
+ for w in enabled_widths:
531
+ if abs(target_width % w) < 0.001:
532
+ count = int(target_width / w)
533
+ if count <= max_depth:
534
+ return [w] * count
535
+
536
+ dp = {0: []} # {sum: solution_list}
537
+ for s in range(1, int(target_width) + 1):
538
+ dp[s] = None
539
+ for w in enabled_widths:
540
+ if w <= s and dp[s - w] is not None and len(dp[s - w]) < max_depth:
541
+ dp[s] = dp[s - w] + [w]
542
+ break
543
+
544
+ if dp.get(target_width) is not None:
545
+ return dp[target_width]
546
+
547
+ # Last‐resort: brute‐force recursion
548
+ all_solutions = []
549
+ self.find_all_combinations_recursive(target_width, sorted(enabled_widths), [], all_solutions, max_depth)
550
+ return min(all_solutions, key=len) if all_solutions else None
551
+
552
+ def find_all_combinations_recursive(self, remaining, widths, current, all_solutions, max_depth):
553
+ """Recursively build up lists of widths to exactly match remaining"""
554
+ if abs(remaining) < 0.001:
555
+ all_solutions.append(current[:])
556
+ return
557
+ if remaining < 0 or len(current) >= max_depth or len(all_solutions) >= 100:
558
+ return
559
+ for i, w in enumerate(widths):
560
+ if w <= remaining + 0.001:
561
+ current.append(w)
562
+ self.find_all_combinations_recursive(remaining - w, widths[i:], current, all_solutions, max_depth)
563
+ current.pop()
564
+
565
+ def optimize_slhc_grouping(self, lots):
566
+ """Re‐order an existing (corner, slhc, standard, custom) list for best grouping"""
567
+ if not lots or len(lots) <= 1:
568
+ return lots
569
+
570
+ corner_specific = []
571
+ slhc_lots = []
572
+ standard_lots = []
573
+ custom_lots = []
574
+
575
+ for w, lot_type in lots:
576
+ if w in self.corner_specific:
577
+ corner_specific.append((w, lot_type))
578
+ elif w <= 10.5:
579
+ slhc_lots.append((w, lot_type))
580
+ elif w in self.standard_widths + self.premium_widths:
581
+ standard_lots.append((w, lot_type))
582
  else:
583
+ # Custom widths that are not exact matches
584
+ if 10.8 < w < 17:
585
+ custom_lots.append((w, lot_type))
586
+ else:
587
+ standard_lots.append((w, lot_type))
588
 
589
+ slhc_8_5 = [(w, t) for w, t in slhc_lots if abs(w - 8.5) < 0.1]
590
+ slhc_10_5 = [(w, t) for w, t in slhc_lots if abs(w - 10.5) < 0.1]
 
 
 
 
591
 
592
+ corner_solution = self._determine_best_corners(corner_specific + custom_lots, standard_lots)
 
593
 
594
+ optimized = []
595
+ # Place first corner
596
+ if corner_solution and corner_solution[0]:
597
+ optimized.append((corner_solution[0][0], 'corner'))
598
+ for lst in [corner_specific, custom_lots, standard_lots]:
599
+ if corner_solution[0] in lst:
600
+ lst.remove(corner_solution[0])
601
+ break
602
+
603
+ # Add SLHC in optimal pairs
604
+ optimized.extend(self._arrange_slhc_optimally(slhc_8_5, slhc_10_5))
605
+
606
+ # Add remaining standard & custom
607
+ optimized.extend(standard_lots)
608
+ optimized.extend(custom_lots)
609
+ optimized.extend(corner_specific)
610
+
611
+ # Place second corner
612
+ if corner_solution and len(corner_solution) > 1 and corner_solution[1]:
613
+ optimized.append((corner_solution[1][0], 'corner'))
614
 
615
+ return optimized
616
+
617
+ def _determine_best_corners(self, corner_suitable, standard_lots):
618
+ """Pick two lots (among corner‐suitable or large standard) that are closest in width"""
619
+ all_suitable = corner_suitable + [(w, t) for w, t in standard_lots if w >= 12.5]
620
+ if len(all_suitable) < 2:
621
+ return None
622
+
623
+ best_pair = None
624
+ min_diff = float('inf')
625
+ for i in range(len(all_suitable)):
626
+ for j in range(i + 1, len(all_suitable)):
627
+ diff = abs(all_suitable[i][0] - all_suitable[j][0])
628
+ if diff < min_diff:
629
+ min_diff = diff
630
+ best_pair = (all_suitable[i], all_suitable[j])
631
+ return best_pair
632
+
633
+ def _arrange_slhc_optimally(self, slhc_8_5, slhc_10_5):
634
+ """Group SLHC lots in same‐width pairs first, then cross‐mix if needed"""
635
+ arranged = []
636
+ while len(slhc_8_5) >= 2:
637
+ arranged.extend(slhc_8_5[:2])
638
+ slhc_8_5 = slhc_8_5[2:]
639
+ while len(slhc_10_5) >= 2:
640
+ arranged.extend(slhc_10_5[:2])
641
+ slhc_10_5 = slhc_10_5[2:]
642
+ while slhc_8_5 and slhc_10_5:
643
+ arranged.append(slhc_8_5[0]); slhc_8_5 = slhc_8_5[1:]
644
+ arranged.append(slhc_10_5[0]); slhc_10_5 = slhc_10_5[1:]
645
+ arranged.extend(slhc_8_5)
646
+ arranged.extend(slhc_10_5)
647
+ return arranged
648
+
649
+ def evaluate_solution_with_diversity(self, solution, stage_width):
650
+ """Fitness = #lots*1000 + (#unique widths)*2000 – (max repetition)*500 + ratio‐bonus + corner‐bonus"""
651
  if not solution:
652
+ return -float('inf')
653
 
654
+ total_width = sum(w for w, _ in solution)
655
+ waste = stage_width - total_width
656
+ if abs(waste) > 0.001:
657
+ return -float('inf')
658
 
659
+ lot_count = len(solution)
660
+ width_counts = {}
661
+ for w, _ in solution:
662
+ width_counts[w] = width_counts.get(w, 0) + 1
663
+
664
+ unique_widths = len(width_counts)
665
+ max_repetition = max(width_counts.values())
666
+ diversity_ratio = unique_widths / lot_count if lot_count > 0 else 0
667
+
668
+ fitness = lot_count * 1000
669
+ fitness += unique_widths * 2000
670
+ fitness -= max_repetition * 500
671
+ fitness += diversity_ratio * 3000
672
+
673
+ # Corner: penalize if SLHC on corners, reward if ≥11m
674
+ if len(solution) >= 2:
675
+ first_w = solution[0][0]
676
+ last_w = solution[-1][0]
677
+ if first_w <= 10.5: fitness -= 2000
678
+ if last_w <= 10.5: fitness -= 2000
679
+ if first_w >= 11.0: fitness += 1000
680
+ if last_w >= 11.0: fitness += 1000
681
+ diff = abs(first_w - last_w)
682
+ if diff < 0.1:
683
+ fitness += 1500
684
+ elif diff <= 1.0:
685
+ fitness += 1000
686
+ elif diff <= 2.0:
687
+ fitness += 500
688
+ else:
689
+ fitness -= 500
690
 
691
+ # Bonus for adjacent SLHC
692
+ for i in range(len(solution) - 1):
693
+ if solution[i][0] <= 10.5 and solution[i + 1][0] <= 10.5:
694
+ fitness += 300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
695
 
696
+ # Penalty if a corner‐specific width used internally
697
+ for i in range(1, len(solution) - 1):
698
+ if solution[i][0] in self.corner_specific:
699
+ fitness -= 200
 
700
 
701
+ return fitness
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
 
703
+ def generate_report(self, solution, stage_width, stage_depth, manual_allocation=None):
704
+ """Generate a Markdown‐style report summarizing the layout"""
705
+ if not solution:
706
+ return None
 
 
707
 
708
+ custom_widths = [f"{w:.1f}m" for w, _ in solution if w not in self.lot_specifications]
 
 
 
 
709
 
710
+ unique_widths = len(set(w for w, _ in solution))
711
+ width_counts = {}
712
+ for w, _ in solution:
713
+ width_counts[w] = width_counts.get(w, 0) + 1
 
714
 
715
+ total_width = sum(w for w, _ in solution)
716
+ variance = total_width - stage_width
 
 
 
717
 
718
+ report = f"""
719
+ # SUBDIVISION OPTIMIZATION REPORT
720
+ ## Project Analysis for {stage_width}m × {stage_depth}m Stage
721
+
722
+ ### EXECUTIVE SUMMARY
723
+ - **Total Lots**: {len(solution)}
724
+ - **Unique Lot Types**: {unique_widths}
725
+ - **Land Efficiency**: {"100%" if abs(variance) < 0.001 else f"{(total_width/stage_width)*100:.1f}%"}
726
+ - **Grid Variance**: {variance:+.2f}m
727
+ - **Stage Dimensions**: {stage_width}m × {stage_depth}m
728
+ - **Total Area**: {stage_width * stage_depth}m²
729
+ {f"- **Custom Widths Used**: {', '.join(custom_widths)}" if custom_widths else ""}
730
+ """
731
+
732
+ report += "\n### LOT DIVERSITY ANALYSIS\n"
733
+ sorted_counts = sorted(width_counts.items(), key=lambda x: x[1], reverse=True)
734
+ for width, count in sorted_counts:
735
+ percentage = (count / len(solution)) * 100
736
+ if width in self.lot_specifications:
737
+ spec = self.lot_specifications[width]
738
+ report += f"- **{width:.1f}m** × {count} ({percentage:.1f}%): {spec['type']}\n"
739
+ else:
740
+ report += f"- **{width:.1f}m** × {count} ({percentage:.1f}%): Custom Width\n"
741
+
742
+ if len(solution) >= 2:
743
+ report += f"""
744
+ ### CORNER ANALYSIS
745
+ - **Front Corner**: {solution[0][0]:.1f}m with 3m × 3m splay
746
+ - **Rear Corner**: {solution[-1][0]:.1f}m with 3m × 3m splay
747
+ - **Balance**: {abs(solution[0][0] - solution[-1][0]):.1f}m difference
748
+ """
749
+
750
+ report += """
751
+ ### DESIGN FEATURES
752
+ - Corner splays provide safe sight lines at intersections
753
+ - All lots have identical rear alignment for visual consistency
754
+ - Diverse lot mix ensures varied streetscape
755
+ - SLHC lots grouped for efficient garbage collection
756
 
757
+ ---
758
+ *Report generated: {timestamp}*
759
+ """.format(timestamp=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
760
 
761
+ return report
762
 
763
+ def process_plan_image(self, image_path, scale=1000, auto_detect_scale=True, confidence=0.75):
764
+ """Use OpenCV + Tesseract to identify lots & OCR text; return a preview plus lot data"""
765
+ if not PLAN_READER_AVAILABLE:
766
+ # Demo mode: return a mock array and mock lot list
767
+ mock_lots = []
768
+ for i in range(8):
769
+ frontage = np.random.choice([8.5, 10.5, 12.5, 14.0, 16.0])
770
+ mock_lots.append({
771
+ 'lot_number': f'L{i + 1}',
772
+ 'frontage': frontage,
773
+ 'depth': 32,
774
+ 'area': frontage * 32,
775
+ 'type': 'SLHC' if frontage <= 10.5 else 'Standard' if frontage <= 14 else 'Premium'
776
+ })
777
+
778
+ # Create a simple “demo” image
779
+ fig, ax = plt.subplots(figsize=(10, 8))
780
+ ax.text(0.5, 0.5,
781
+ 'Plan Reader Demo Mode\n(Install required libraries for actual functionality)',
782
+ ha='center', va='center', fontsize=16, transform=ax.transAxes)
783
+ ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off')
784
+ fig.canvas.draw()
785
+ preview_img = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8)
786
+ preview_img = preview_img.reshape(fig.canvas.get_width_height()[::-1] + (3,))
787
+ plt.close()
788
+ summary = """
789
+ ### Demo Mode Active
790
+ Plan reader libraries not installed. Showing sample data.
791
+
792
+ **To enable full functionality, install:**