geoai-py 0.2.3__py2.py3-none-any.whl → 0.3.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
geoai/extract.py CHANGED
@@ -7,13 +7,20 @@ import geopandas as gpd
7
7
  from tqdm import tqdm
8
8
 
9
9
  import cv2
10
- from torchgeo.datasets import NonGeoDataset
11
10
  from torchvision.models.detection import maskrcnn_resnet50_fpn
12
11
  import torchvision.transforms as T
13
12
  import rasterio
14
13
  from rasterio.windows import Window
15
14
  from rasterio.features import shapes
16
15
  from huggingface_hub import hf_hub_download
16
+ from .preprocess import get_raster_stats
17
+
18
+ try:
19
+ from torchgeo.datasets import NonGeoDataset
20
+ except ImportError as e:
21
+ raise ImportError(
22
+ "Your torchgeo version is too old. Please upgrade to the latest version using 'pip install -U torchgeo'."
23
+ )
17
24
 
18
25
 
19
26
  class BuildingFootprintDataset(NonGeoDataset):
@@ -22,7 +29,9 @@ class BuildingFootprintDataset(NonGeoDataset):
22
29
  Using NonGeoDataset to avoid spatial indexing issues.
23
30
  """
24
31
 
25
- def __init__(self, raster_path, chip_size=(512, 512), transforms=None):
32
+ def __init__(
33
+ self, raster_path, chip_size=(512, 512), transforms=None, verbose=False
34
+ ):
26
35
  """
27
36
  Initialize the dataset.
28
37
 
@@ -30,6 +39,7 @@ class BuildingFootprintDataset(NonGeoDataset):
30
39
  raster_path: Path to the input raster file
31
40
  chip_size: Size of image chips to extract (height, width)
32
41
  transforms: Transforms to apply to the image
42
+ verbose: Whether to print detailed processing information
33
43
  """
34
44
  super().__init__()
35
45
 
@@ -37,6 +47,10 @@ class BuildingFootprintDataset(NonGeoDataset):
37
47
  self.raster_path = raster_path
38
48
  self.chip_size = chip_size
39
49
  self.transforms = transforms
50
+ self.verbose = verbose
51
+
52
+ # For tracking warnings about multi-band images
53
+ self.warned_about_bands = False
40
54
 
41
55
  # Open raster and get metadata
42
56
  with rasterio.open(self.raster_path) as src:
@@ -54,15 +68,21 @@ class BuildingFootprintDataset(NonGeoDataset):
54
68
  self.roi = box(*self.bounds)
55
69
 
56
70
  # Calculate number of chips in each dimension
57
- self.rows = self.height // self.chip_size[0]
58
- self.cols = self.width // self.chip_size[1]
71
+ # Use ceil division to ensure we cover the entire image
72
+ self.rows = (self.height + self.chip_size[0] - 1) // self.chip_size[0]
73
+ self.cols = (self.width + self.chip_size[1] - 1) // self.chip_size[1]
59
74
 
60
75
  print(
61
76
  f"Dataset initialized with {self.rows} rows and {self.cols} columns of chips"
62
77
  )
78
+ print(f"Image dimensions: {self.width} x {self.height} pixels")
79
+ print(f"Chip size: {self.chip_size[1]} x {self.chip_size[0]} pixels")
63
80
  if src.crs:
64
81
  print(f"CRS: {src.crs}")
65
82
 
83
+ # get raster stats
84
+ self.raster_stats = get_raster_stats(raster_path, divide_by=255)
85
+
66
86
  def __getitem__(self, idx):
67
87
  """
68
88
  Get an image chip from the dataset by index.
@@ -92,11 +112,17 @@ class BuildingFootprintDataset(NonGeoDataset):
92
112
 
93
113
  # Handle RGBA or multispectral images - keep only first 3 bands
94
114
  if image.shape[0] > 3:
95
- print(f"Image has {image.shape[0]} bands, using first 3 bands only")
115
+ if not self.warned_about_bands and self.verbose:
116
+ print(f"Image has {image.shape[0]} bands, using first 3 bands only")
117
+ self.warned_about_bands = True
96
118
  image = image[:3]
97
119
  elif image.shape[0] < 3:
98
120
  # If image has fewer than 3 bands, duplicate the last band to make 3
99
- print(f"Image has {image.shape[0]} bands, duplicating bands to make 3")
121
+ if not self.warned_about_bands and self.verbose:
122
+ print(
123
+ f"Image has {image.shape[0]} bands, duplicating bands to make 3"
124
+ )
125
+ self.warned_about_bands = True
100
126
  temp = np.zeros((3, image.shape[1], image.shape[2]), dtype=image.dtype)
101
127
  for c in range(3):
102
128
  temp[c] = image[min(c, image.shape[0] - 1)]
@@ -387,8 +413,279 @@ class BuildingFootprintExtractor:
387
413
 
388
414
  return gdf.iloc[keep_indices]
389
415
 
416
+ def filter_edge_buildings(self, gdf, raster_path, edge_buffer=10):
417
+ """
418
+ Filter out building detections that fall in padding/edge areas of the image.
419
+
420
+ Args:
421
+ gdf: GeoDataFrame with building footprint detections
422
+ raster_path: Path to the original raster file
423
+ edge_buffer: Buffer in pixels to consider as edge region
424
+
425
+ Returns:
426
+ GeoDataFrame with filtered building footprints
427
+ """
428
+ import rasterio
429
+ from shapely.geometry import box
430
+
431
+ # If no buildings detected, return empty GeoDataFrame
432
+ if gdf is None or len(gdf) == 0:
433
+ return gdf
434
+
435
+ print(f"Buildings before filtering: {len(gdf)}")
436
+
437
+ with rasterio.open(raster_path) as src:
438
+ # Get raster bounds
439
+ raster_bounds = src.bounds
440
+ raster_width = src.width
441
+ raster_height = src.height
442
+
443
+ # Convert edge buffer from pixels to geographic units
444
+ # We need the smallest dimension of a pixel in geographic units
445
+ pixel_width = (raster_bounds[2] - raster_bounds[0]) / raster_width
446
+ pixel_height = (raster_bounds[3] - raster_bounds[1]) / raster_height
447
+ buffer_size = min(pixel_width, pixel_height) * edge_buffer
448
+
449
+ # Create a slightly smaller bounding box to exclude edge regions
450
+ inner_bounds = (
451
+ raster_bounds[0] + buffer_size, # min x (west)
452
+ raster_bounds[1] + buffer_size, # min y (south)
453
+ raster_bounds[2] - buffer_size, # max x (east)
454
+ raster_bounds[3] - buffer_size, # max y (north)
455
+ )
456
+
457
+ # Check that inner bounds are valid
458
+ if inner_bounds[0] >= inner_bounds[2] or inner_bounds[1] >= inner_bounds[3]:
459
+ print("Warning: Edge buffer too large, using original bounds")
460
+ inner_box = box(*raster_bounds)
461
+ else:
462
+ inner_box = box(*inner_bounds)
463
+
464
+ # Filter out buildings that intersect with the edge of the image
465
+ filtered_gdf = gdf[gdf.intersects(inner_box)]
466
+
467
+ # Additional check for buildings that have >50% of their area outside the valid region
468
+ valid_buildings = []
469
+ for idx, row in filtered_gdf.iterrows():
470
+ if row.geometry.intersection(inner_box).area >= 0.5 * row.geometry.area:
471
+ valid_buildings.append(idx)
472
+
473
+ filtered_gdf = filtered_gdf.loc[valid_buildings]
474
+
475
+ print(f"Buildings after filtering: {len(filtered_gdf)}")
476
+
477
+ return filtered_gdf
478
+
479
+ def masks_to_vector(
480
+ self,
481
+ mask_path,
482
+ output_path=None,
483
+ simplify_tolerance=None,
484
+ mask_threshold=None,
485
+ small_building_area=None,
486
+ nms_iou_threshold=None,
487
+ regularize=True,
488
+ angle_threshold=15,
489
+ rectangularity_threshold=0.7,
490
+ ):
491
+ """
492
+ Convert a building mask GeoTIFF to vector polygons and save as GeoJSON.
493
+
494
+ Args:
495
+ mask_path: Path to the building masks GeoTIFF
496
+ output_path: Path to save the output GeoJSON (default: mask_path with .geojson extension)
497
+ simplify_tolerance: Tolerance for polygon simplification (default: self.simplify_tolerance)
498
+ mask_threshold: Threshold for mask binarization (default: self.mask_threshold)
499
+ small_building_area: Minimum area in pixels to keep a building (default: self.small_building_area)
500
+ nms_iou_threshold: IoU threshold for non-maximum suppression (default: self.nms_iou_threshold)
501
+ regularize: Whether to regularize buildings to right angles (default: True)
502
+ angle_threshold: Maximum deviation from 90 degrees for regularization (default: 15)
503
+ rectangularity_threshold: Threshold for rectangle simplification (default: 0.7)
504
+
505
+ Returns:
506
+ GeoDataFrame with building footprints
507
+ """
508
+ # Use class defaults if parameters not provided
509
+ simplify_tolerance = (
510
+ simplify_tolerance
511
+ if simplify_tolerance is not None
512
+ else self.simplify_tolerance
513
+ )
514
+ mask_threshold = (
515
+ mask_threshold if mask_threshold is not None else self.mask_threshold
516
+ )
517
+ small_building_area = (
518
+ small_building_area
519
+ if small_building_area is not None
520
+ else self.small_building_area
521
+ )
522
+ nms_iou_threshold = (
523
+ nms_iou_threshold
524
+ if nms_iou_threshold is not None
525
+ else self.nms_iou_threshold
526
+ )
527
+
528
+ # Set default output path if not provided
529
+ # if output_path is None:
530
+ # output_path = os.path.splitext(mask_path)[0] + ".geojson"
531
+
532
+ print(f"Converting mask to GeoJSON with parameters:")
533
+ print(f"- Mask threshold: {mask_threshold}")
534
+ print(f"- Min building area: {small_building_area}")
535
+ print(f"- Simplify tolerance: {simplify_tolerance}")
536
+ print(f"- NMS IoU threshold: {nms_iou_threshold}")
537
+ print(f"- Regularize buildings: {regularize}")
538
+ if regularize:
539
+ print(f"- Angle threshold: {angle_threshold}° from 90°")
540
+ print(f"- Rectangularity threshold: {rectangularity_threshold*100}%")
541
+
542
+ # Open the mask raster
543
+ with rasterio.open(mask_path) as src:
544
+ # Read the mask data
545
+ mask_data = src.read(1)
546
+ transform = src.transform
547
+ crs = src.crs
548
+
549
+ # Print mask statistics
550
+ print(f"Mask dimensions: {mask_data.shape}")
551
+ print(f"Mask value range: {mask_data.min()} to {mask_data.max()}")
552
+
553
+ # Prepare for connected component analysis
554
+ # Binarize the mask based on threshold
555
+ binary_mask = (mask_data > (mask_threshold * 255)).astype(np.uint8)
556
+
557
+ # Apply morphological operations for better results (optional)
558
+ kernel = np.ones((3, 3), np.uint8)
559
+ binary_mask = cv2.morphologyEx(binary_mask, cv2.MORPH_CLOSE, kernel)
560
+
561
+ # Find connected components
562
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
563
+ binary_mask, connectivity=8
564
+ )
565
+
566
+ print(
567
+ f"Found {num_labels-1} potential buildings"
568
+ ) # Subtract 1 for background
569
+
570
+ # Create list to store polygons and confidence values
571
+ all_polygons = []
572
+ all_confidences = []
573
+
574
+ # Process each component (skip the first one which is background)
575
+ for i in tqdm(range(1, num_labels)):
576
+ # Extract this building
577
+ area = stats[i, cv2.CC_STAT_AREA]
578
+
579
+ # Skip if too small
580
+ if area < small_building_area:
581
+ continue
582
+
583
+ # Create a mask for this building
584
+ building_mask = (labels == i).astype(np.uint8)
585
+
586
+ # Find contours
587
+ contours, _ = cv2.findContours(
588
+ building_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
589
+ )
590
+
591
+ # Process each contour
592
+ for contour in contours:
593
+ # Skip if too few points
594
+ if contour.shape[0] < 3:
595
+ continue
596
+
597
+ # Simplify contour if it has many points
598
+ if contour.shape[0] > 50 and simplify_tolerance > 0:
599
+ epsilon = simplify_tolerance * cv2.arcLength(contour, True)
600
+ contour = cv2.approxPolyDP(contour, epsilon, True)
601
+
602
+ # Convert to list of (x, y) coordinates
603
+ polygon_points = contour.reshape(-1, 2)
604
+
605
+ # Convert pixel coordinates to geographic coordinates
606
+ geo_points = []
607
+ for x, y in polygon_points:
608
+ gx, gy = transform * (x, y)
609
+ geo_points.append((gx, gy))
610
+
611
+ # Create Shapely polygon
612
+ if len(geo_points) >= 3:
613
+ try:
614
+ shapely_poly = Polygon(geo_points)
615
+ if shapely_poly.is_valid and shapely_poly.area > 0:
616
+ all_polygons.append(shapely_poly)
617
+
618
+ # Calculate "confidence" as normalized size
619
+ # This is a proxy since we don't have model confidence scores
620
+ normalized_size = min(1.0, area / 1000) # Cap at 1.0
621
+ all_confidences.append(normalized_size)
622
+ except Exception as e:
623
+ print(f"Error creating polygon: {e}")
624
+
625
+ print(f"Created {len(all_polygons)} valid polygons")
626
+
627
+ # Create GeoDataFrame
628
+ if not all_polygons:
629
+ print("No valid polygons found")
630
+ return None
631
+
632
+ gdf = gpd.GeoDataFrame(
633
+ {
634
+ "geometry": all_polygons,
635
+ "confidence": all_confidences,
636
+ "class": 1, # Building class
637
+ },
638
+ crs=crs,
639
+ )
640
+
641
+ # Apply non-maximum suppression to remove overlapping polygons
642
+ gdf = self._filter_overlapping_polygons(
643
+ gdf, nms_iou_threshold=nms_iou_threshold
644
+ )
645
+
646
+ print(f"Building count after NMS filtering: {len(gdf)}")
647
+
648
+ # Apply regularization if requested
649
+ if regularize and len(gdf) > 0:
650
+ # Convert pixel area to geographic units for min_area parameter
651
+ # Estimate pixel size in geographic units
652
+ with rasterio.open(mask_path) as src:
653
+ pixel_size_x = src.transform[
654
+ 0
655
+ ] # width of a pixel in geographic units
656
+ pixel_size_y = abs(
657
+ src.transform[4]
658
+ ) # height of a pixel in geographic units
659
+ avg_pixel_area = pixel_size_x * pixel_size_y
660
+
661
+ # Use 10 pixels as minimum area in geographic units
662
+ min_geo_area = 10 * avg_pixel_area
663
+
664
+ # Regularize buildings
665
+ gdf = self.regularize_buildings(
666
+ gdf,
667
+ min_area=min_geo_area,
668
+ angle_threshold=angle_threshold,
669
+ rectangularity_threshold=rectangularity_threshold,
670
+ )
671
+
672
+ # Save to file
673
+ if output_path:
674
+ gdf.to_file(output_path)
675
+ print(f"Saved {len(gdf)} building footprints to {output_path}")
676
+
677
+ return gdf
678
+
390
679
  @torch.no_grad()
391
- def process_raster(self, raster_path, output_path=None, batch_size=4, **kwargs):
680
+ def process_raster(
681
+ self,
682
+ raster_path,
683
+ output_path=None,
684
+ batch_size=4,
685
+ filter_edges=True,
686
+ edge_buffer=20,
687
+ **kwargs,
688
+ ):
392
689
  """
393
690
  Process a raster file to extract building footprints with customizable parameters.
394
691
 
@@ -396,6 +693,8 @@ class BuildingFootprintExtractor:
396
693
  raster_path: Path to input raster file
397
694
  output_path: Path to output GeoJSON file (optional)
398
695
  batch_size: Batch size for processing
696
+ filter_edges: Whether to filter out buildings at the edges of the image
697
+ edge_buffer: Size of edge buffer in pixels to filter out buildings (if filter_edges=True)
399
698
  **kwargs: Additional parameters:
400
699
  confidence_threshold: Minimum confidence score to keep a detection (0.0-1.0)
401
700
  overlap: Overlap between adjacent tiles (0.0-1.0)
@@ -430,9 +729,13 @@ class BuildingFootprintExtractor:
430
729
  print(f"- Mask threshold: {mask_threshold}")
431
730
  print(f"- Min building area: {small_building_area}")
432
731
  print(f"- Simplify tolerance: {simplify_tolerance}")
732
+ print(f"- Filter edge buildings: {filter_edges}")
733
+ if filter_edges:
734
+ print(f"- Edge buffer size: {edge_buffer} pixels")
433
735
 
434
736
  # Create dataset
435
737
  dataset = BuildingFootprintDataset(raster_path=raster_path, chip_size=chip_size)
738
+ self.raster_stats = dataset.raster_stats
436
739
 
437
740
  # Custom collate function to handle Shapely objects
438
741
  def custom_collate(batch):
@@ -603,6 +906,10 @@ class BuildingFootprintExtractor:
603
906
  gdf, nms_iou_threshold=nms_iou_threshold
604
907
  )
605
908
 
909
+ # Filter edge buildings if requested
910
+ if filter_edges:
911
+ gdf = self.filter_edge_buildings(gdf, raster_path, edge_buffer=edge_buffer)
912
+
606
913
  # Save to file if requested
607
914
  if output_path:
608
915
  gdf.to_file(output_path, driver="GeoJSON")
@@ -610,22 +917,473 @@ class BuildingFootprintExtractor:
610
917
 
611
918
  return gdf
612
919
 
920
+ def save_masks_as_geotiff(
921
+ self, raster_path, output_path=None, batch_size=4, verbose=False, **kwargs
922
+ ):
923
+ """
924
+ Process a raster file to extract building footprint masks and save as GeoTIFF.
925
+
926
+ Args:
927
+ raster_path: Path to input raster file
928
+ output_path: Path to output GeoTIFF file (optional, default: input_masks.tif)
929
+ batch_size: Batch size for processing
930
+ verbose: Whether to print detailed processing information
931
+ **kwargs: Additional parameters:
932
+ confidence_threshold: Minimum confidence score to keep a detection (0.0-1.0)
933
+ chip_size: Size of image chips for processing (height, width)
934
+ mask_threshold: Threshold for mask binarization (0.0-1.0)
935
+
936
+ Returns:
937
+ Path to the saved GeoTIFF file
938
+ """
939
+
940
+ # Get parameters from kwargs or use instance defaults
941
+ confidence_threshold = kwargs.get(
942
+ "confidence_threshold", self.confidence_threshold
943
+ )
944
+ chip_size = kwargs.get("chip_size", self.chip_size)
945
+ mask_threshold = kwargs.get("mask_threshold", self.mask_threshold)
946
+
947
+ # Set default output path if not provided
948
+ if output_path is None:
949
+ output_path = os.path.splitext(raster_path)[0] + "_masks.tif"
950
+
951
+ # Print parameters being used
952
+ print(f"Processing masks with parameters:")
953
+ print(f"- Confidence threshold: {confidence_threshold}")
954
+ print(f"- Chip size: {chip_size}")
955
+ print(f"- Mask threshold: {mask_threshold}")
956
+
957
+ # Create dataset
958
+ dataset = BuildingFootprintDataset(
959
+ raster_path=raster_path, chip_size=chip_size, verbose=verbose
960
+ )
961
+
962
+ # Store a flag to avoid repetitive messages
963
+ self.raster_stats = dataset.raster_stats
964
+ seen_warnings = {
965
+ "bands": False,
966
+ "resize": {}, # Dictionary to track resize warnings by shape
967
+ }
968
+
969
+ # Open original raster to get metadata
970
+ with rasterio.open(raster_path) as src:
971
+ # Create output binary mask raster with same dimensions as input
972
+ output_profile = src.profile.copy()
973
+ output_profile.update(
974
+ dtype=rasterio.uint8,
975
+ count=1, # Single band for building mask
976
+ compress="lzw",
977
+ nodata=0,
978
+ )
979
+
980
+ # Create output mask raster
981
+ with rasterio.open(output_path, "w", **output_profile) as dst:
982
+ # Initialize mask with zeros
983
+ mask_array = np.zeros((src.height, src.width), dtype=np.uint8)
984
+
985
+ # Custom collate function to handle Shapely objects
986
+ def custom_collate(batch):
987
+ """Custom collate function for DataLoader"""
988
+ elem = batch[0]
989
+ if isinstance(elem, dict):
990
+ result = {}
991
+ for key in elem:
992
+ if key == "bbox":
993
+ # Don't collate shapely objects, keep as list
994
+ result[key] = [d[key] for d in batch]
995
+ else:
996
+ # For tensors and other collatable types
997
+ try:
998
+ result[key] = (
999
+ torch.utils.data._utils.collate.default_collate(
1000
+ [d[key] for d in batch]
1001
+ )
1002
+ )
1003
+ except TypeError:
1004
+ # Fall back to list for non-collatable types
1005
+ result[key] = [d[key] for d in batch]
1006
+ return result
1007
+ else:
1008
+ # Default collate for non-dict types
1009
+ return torch.utils.data._utils.collate.default_collate(batch)
1010
+
1011
+ # Create dataloader
1012
+ dataloader = torch.utils.data.DataLoader(
1013
+ dataset,
1014
+ batch_size=batch_size,
1015
+ shuffle=False,
1016
+ num_workers=0,
1017
+ collate_fn=custom_collate,
1018
+ )
1019
+
1020
+ # Process batches
1021
+ print(f"Processing raster with {len(dataloader)} batches")
1022
+ for batch in tqdm(dataloader):
1023
+ # Move images to device
1024
+ images = batch["image"].to(self.device)
1025
+ coords = batch["coords"] # (i, j) coordinates in pixels
1026
+
1027
+ # Run inference
1028
+ with torch.no_grad():
1029
+ predictions = self.model(images)
1030
+
1031
+ # Process predictions
1032
+ for idx, prediction in enumerate(predictions):
1033
+ masks = prediction["masks"].cpu().numpy()
1034
+ scores = prediction["scores"].cpu().numpy()
1035
+
1036
+ # Skip if no predictions
1037
+ if len(scores) == 0:
1038
+ continue
1039
+
1040
+ # Filter by confidence threshold
1041
+ valid_indices = scores >= confidence_threshold
1042
+ masks = masks[valid_indices]
1043
+ scores = scores[valid_indices]
1044
+
1045
+ # Skip if no valid predictions
1046
+ if len(scores) == 0:
1047
+ continue
1048
+
1049
+ # Get window coordinates
1050
+ if isinstance(coords, list):
1051
+ coord_item = coords[idx]
1052
+ if isinstance(coord_item, tuple) and len(coord_item) == 2:
1053
+ i, j = coord_item
1054
+ elif isinstance(coord_item, torch.Tensor):
1055
+ i, j = coord_item.cpu().numpy().tolist()
1056
+ else:
1057
+ print(f"Unexpected coords format: {type(coord_item)}")
1058
+ continue
1059
+ elif isinstance(coords, torch.Tensor):
1060
+ i, j = coords[idx].cpu().numpy().tolist()
1061
+ else:
1062
+ print(f"Unexpected coords type: {type(coords)}")
1063
+ continue
1064
+
1065
+ # Get window size
1066
+ if isinstance(batch["window_size"], list):
1067
+ window_item = batch["window_size"][idx]
1068
+ if isinstance(window_item, tuple) and len(window_item) == 2:
1069
+ window_width, window_height = window_item
1070
+ elif isinstance(window_item, torch.Tensor):
1071
+ window_width, window_height = (
1072
+ window_item.cpu().numpy().tolist()
1073
+ )
1074
+ else:
1075
+ print(
1076
+ f"Unexpected window_size format: {type(window_item)}"
1077
+ )
1078
+ continue
1079
+ elif isinstance(batch["window_size"], torch.Tensor):
1080
+ window_width, window_height = (
1081
+ batch["window_size"][idx].cpu().numpy().tolist()
1082
+ )
1083
+ else:
1084
+ print(
1085
+ f"Unexpected window_size type: {type(batch['window_size'])}"
1086
+ )
1087
+ continue
1088
+
1089
+ # Combine all masks for this window
1090
+ combined_mask = np.zeros(
1091
+ (window_height, window_width), dtype=np.uint8
1092
+ )
1093
+
1094
+ for mask in masks:
1095
+ # Get the binary mask
1096
+ binary_mask = (mask[0] > mask_threshold).astype(
1097
+ np.uint8
1098
+ ) * 255
1099
+
1100
+ # Handle size mismatch - resize binary_mask if needed
1101
+ mask_h, mask_w = binary_mask.shape
1102
+ if mask_h != window_height or mask_w != window_width:
1103
+ resize_key = f"{(mask_h, mask_w)}->{(window_height, window_width)}"
1104
+ if resize_key not in seen_warnings["resize"]:
1105
+ if verbose:
1106
+ print(
1107
+ f"Resizing mask from {binary_mask.shape} to {(window_height, window_width)}"
1108
+ )
1109
+ else:
1110
+ if not seen_warnings[
1111
+ "resize"
1112
+ ]: # If this is the first resize warning
1113
+ print(
1114
+ f"Resizing masks at image edges (set verbose=True for details)"
1115
+ )
1116
+ seen_warnings["resize"][resize_key] = True
1117
+
1118
+ # Crop or pad the binary mask to match window size
1119
+ resized_mask = np.zeros(
1120
+ (window_height, window_width), dtype=np.uint8
1121
+ )
1122
+ copy_h = min(mask_h, window_height)
1123
+ copy_w = min(mask_w, window_width)
1124
+ resized_mask[:copy_h, :copy_w] = binary_mask[
1125
+ :copy_h, :copy_w
1126
+ ]
1127
+ binary_mask = resized_mask
1128
+
1129
+ # Update combined mask (taking maximum where masks overlap)
1130
+ combined_mask = np.maximum(combined_mask, binary_mask)
1131
+
1132
+ # Write combined mask to output array
1133
+ # Handle edge cases where window might be smaller than chip size
1134
+ h, w = combined_mask.shape
1135
+ valid_h = min(h, src.height - j)
1136
+ valid_w = min(w, src.width - i)
1137
+
1138
+ if valid_h > 0 and valid_w > 0:
1139
+ mask_array[j : j + valid_h, i : i + valid_w] = np.maximum(
1140
+ mask_array[j : j + valid_h, i : i + valid_w],
1141
+ combined_mask[:valid_h, :valid_w],
1142
+ )
1143
+
1144
+ # Write the final mask to the output file
1145
+ dst.write(mask_array, 1)
1146
+
1147
+ print(f"Building masks saved to {output_path}")
1148
+ return output_path
1149
+
1150
+ def regularize_buildings(
1151
+ self,
1152
+ gdf,
1153
+ min_area=10,
1154
+ angle_threshold=15,
1155
+ orthogonality_threshold=0.3,
1156
+ rectangularity_threshold=0.7,
1157
+ ):
1158
+ """
1159
+ Regularize building footprints to enforce right angles and rectangular shapes.
1160
+
1161
+ Args:
1162
+ gdf: GeoDataFrame with building footprints
1163
+ min_area: Minimum area in square units to keep a building
1164
+ angle_threshold: Maximum deviation from 90 degrees to consider an angle as orthogonal (degrees)
1165
+ orthogonality_threshold: Percentage of angles that must be orthogonal for a building to be regularized
1166
+ rectangularity_threshold: Minimum area ratio to building's oriented bounding box for rectangular simplification
1167
+
1168
+ Returns:
1169
+ GeoDataFrame with regularized building footprints
1170
+ """
1171
+ import numpy as np
1172
+ from shapely.geometry import Polygon, MultiPolygon, box
1173
+ from shapely.affinity import rotate, translate
1174
+ import geopandas as gpd
1175
+ import math
1176
+ from tqdm import tqdm
1177
+ import cv2
1178
+
1179
+ def get_angle(p1, p2, p3):
1180
+ """Calculate angle between three points in degrees (0-180)"""
1181
+ a = np.array(p1)
1182
+ b = np.array(p2)
1183
+ c = np.array(p3)
1184
+
1185
+ ba = a - b
1186
+ bc = c - b
1187
+
1188
+ cosine_angle = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc))
1189
+ # Handle numerical errors that could push cosine outside [-1, 1]
1190
+ cosine_angle = np.clip(cosine_angle, -1.0, 1.0)
1191
+ angle = np.degrees(np.arccos(cosine_angle))
1192
+
1193
+ return angle
1194
+
1195
+ def is_orthogonal(angle, threshold=angle_threshold):
1196
+ """Check if angle is close to 90 degrees"""
1197
+ return abs(angle - 90) <= threshold
1198
+
1199
+ def calculate_dominant_direction(polygon):
1200
+ """Find the dominant direction of a polygon using PCA"""
1201
+ # Extract coordinates
1202
+ coords = np.array(polygon.exterior.coords)
1203
+
1204
+ # Mean center the coordinates
1205
+ mean = np.mean(coords, axis=0)
1206
+ centered_coords = coords - mean
1207
+
1208
+ # Calculate covariance matrix and its eigenvalues/eigenvectors
1209
+ cov_matrix = np.cov(centered_coords.T)
1210
+ eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
1211
+
1212
+ # Get the index of the largest eigenvalue
1213
+ largest_idx = np.argmax(eigenvalues)
1214
+
1215
+ # Get the corresponding eigenvector (principal axis)
1216
+ principal_axis = eigenvectors[:, largest_idx]
1217
+
1218
+ # Calculate the angle in degrees
1219
+ angle_rad = np.arctan2(principal_axis[1], principal_axis[0])
1220
+ angle_deg = np.degrees(angle_rad)
1221
+
1222
+ # Normalize to range 0-180
1223
+ if angle_deg < 0:
1224
+ angle_deg += 180
1225
+
1226
+ return angle_deg
1227
+
1228
+ def create_oriented_envelope(polygon, angle_deg):
1229
+ """Create an oriented minimum area rectangle for the polygon"""
1230
+ # Create a rotated rectangle using OpenCV method (more robust than Shapely methods)
1231
+ coords = np.array(polygon.exterior.coords)[:-1].astype(
1232
+ np.float32
1233
+ ) # Skip the last point (same as first)
1234
+
1235
+ # Use OpenCV's minAreaRect
1236
+ rect = cv2.minAreaRect(coords)
1237
+ box_points = cv2.boxPoints(rect)
1238
+
1239
+ # Convert to shapely polygon
1240
+ oriented_box = Polygon(box_points)
1241
+
1242
+ return oriented_box
1243
+
1244
+ def get_rectangularity(polygon, oriented_box):
1245
+ """Calculate the rectangularity (area ratio to its oriented bounding box)"""
1246
+ if oriented_box.area == 0:
1247
+ return 0
1248
+ return polygon.area / oriented_box.area
1249
+
1250
+ def check_orthogonality(polygon):
1251
+ """Check what percentage of angles in the polygon are orthogonal"""
1252
+ coords = list(polygon.exterior.coords)
1253
+ if len(coords) <= 4: # Triangle or point
1254
+ return 0
1255
+
1256
+ # Remove last point (same as first)
1257
+ coords = coords[:-1]
1258
+
1259
+ orthogonal_count = 0
1260
+ total_angles = len(coords)
1261
+
1262
+ for i in range(total_angles):
1263
+ p1 = coords[i]
1264
+ p2 = coords[(i + 1) % total_angles]
1265
+ p3 = coords[(i + 2) % total_angles]
1266
+
1267
+ angle = get_angle(p1, p2, p3)
1268
+ if is_orthogonal(angle):
1269
+ orthogonal_count += 1
1270
+
1271
+ return orthogonal_count / total_angles
1272
+
1273
+ def simplify_to_rectangle(polygon):
1274
+ """Simplify a polygon to a rectangle using its oriented bounding box"""
1275
+ # Get dominant direction
1276
+ angle = calculate_dominant_direction(polygon)
1277
+
1278
+ # Create oriented envelope
1279
+ rect = create_oriented_envelope(polygon, angle)
1280
+
1281
+ return rect
1282
+
1283
+ if gdf is None or len(gdf) == 0:
1284
+ print("No buildings to regularize")
1285
+ return gdf
1286
+
1287
+ print(f"Regularizing {len(gdf)} building footprints...")
1288
+ print(f"- Angle threshold: {angle_threshold}° from 90°")
1289
+ print(f"- Min orthogonality: {orthogonality_threshold*100}% of angles")
1290
+ print(
1291
+ f"- Min rectangularity: {rectangularity_threshold*100}% of bounding box area"
1292
+ )
1293
+
1294
+ # Create a copy to avoid modifying the original
1295
+ result_gdf = gdf.copy()
1296
+
1297
+ # Track statistics
1298
+ total_buildings = len(gdf)
1299
+ regularized_count = 0
1300
+ rectangularized_count = 0
1301
+
1302
+ # Process each building
1303
+ for idx, row in tqdm(gdf.iterrows(), total=len(gdf)):
1304
+ geom = row.geometry
1305
+
1306
+ # Skip invalid or empty geometries
1307
+ if geom is None or geom.is_empty:
1308
+ continue
1309
+
1310
+ # Handle MultiPolygons by processing the largest part
1311
+ if isinstance(geom, MultiPolygon):
1312
+ areas = [p.area for p in geom.geoms]
1313
+ if not areas:
1314
+ continue
1315
+ geom = list(geom.geoms)[np.argmax(areas)]
1316
+
1317
+ # Filter out tiny buildings
1318
+ if geom.area < min_area:
1319
+ continue
1320
+
1321
+ # Check orthogonality
1322
+ orthogonality = check_orthogonality(geom)
1323
+
1324
+ # Create oriented envelope
1325
+ oriented_box = create_oriented_envelope(
1326
+ geom, calculate_dominant_direction(geom)
1327
+ )
1328
+
1329
+ # Check rectangularity
1330
+ rectangularity = get_rectangularity(geom, oriented_box)
1331
+
1332
+ # Decide how to regularize
1333
+ if rectangularity >= rectangularity_threshold:
1334
+ # Building is already quite rectangular, simplify to a rectangle
1335
+ result_gdf.at[idx, "geometry"] = oriented_box
1336
+ result_gdf.at[idx, "regularized"] = "rectangle"
1337
+ rectangularized_count += 1
1338
+ elif orthogonality >= orthogonality_threshold:
1339
+ # Building has many orthogonal angles but isn't rectangular
1340
+ # Could implement more sophisticated regularization here
1341
+ # For now, we'll still use the oriented rectangle
1342
+ result_gdf.at[idx, "geometry"] = oriented_box
1343
+ result_gdf.at[idx, "regularized"] = "orthogonal"
1344
+ regularized_count += 1
1345
+ else:
1346
+ # Building doesn't have clear orthogonal structure
1347
+ # Keep original but flag as unmodified
1348
+ result_gdf.at[idx, "regularized"] = "original"
1349
+
1350
+ # Report statistics
1351
+ print(f"Regularization completed:")
1352
+ print(f"- Total buildings: {total_buildings}")
1353
+ print(
1354
+ f"- Rectangular buildings: {rectangularized_count} ({rectangularized_count/total_buildings*100:.1f}%)"
1355
+ )
1356
+ print(
1357
+ f"- Other regularized buildings: {regularized_count} ({regularized_count/total_buildings*100:.1f}%)"
1358
+ )
1359
+ print(
1360
+ f"- Unmodified buildings: {total_buildings-rectangularized_count-regularized_count} ({(total_buildings-rectangularized_count-regularized_count)/total_buildings*100:.1f}%)"
1361
+ )
1362
+
1363
+ return result_gdf
1364
+
613
1365
  def visualize_results(
614
1366
  self, raster_path, gdf=None, output_path=None, figsize=(12, 12)
615
1367
  ):
616
1368
  """
617
- Visualize building detection results.
1369
+ Visualize building detection results with proper coordinate transformation.
1370
+
1371
+ This function displays building footprints on top of the raster image,
1372
+ ensuring proper alignment between the GeoDataFrame polygons and the image.
618
1373
 
619
1374
  Args:
620
1375
  raster_path: Path to input raster
621
1376
  gdf: GeoDataFrame with building polygons (optional)
622
1377
  output_path: Path to save visualization (optional)
623
1378
  figsize: Figure size (width, height) in inches
1379
+
1380
+ Returns:
1381
+ bool: True if visualization was successful
624
1382
  """
625
1383
  # Check if raster file exists
626
1384
  if not os.path.exists(raster_path):
627
1385
  print(f"Error: Raster file '{raster_path}' not found.")
628
- return
1386
+ return False
629
1387
 
630
1388
  # Process raster if GeoDataFrame not provided
631
1389
  if gdf is None:
@@ -633,7 +1391,26 @@ class BuildingFootprintExtractor:
633
1391
 
634
1392
  if gdf is None or len(gdf) == 0:
635
1393
  print("No buildings to visualize")
636
- return
1394
+ return False
1395
+
1396
+ # Check if confidence column exists in the GeoDataFrame
1397
+ has_confidence = False
1398
+ if hasattr(gdf, "columns") and "confidence" in gdf.columns:
1399
+ # Try to access a confidence value to confirm it works
1400
+ try:
1401
+ if len(gdf) > 0:
1402
+ # Try getitem access
1403
+ conf_val = gdf["confidence"].iloc[0]
1404
+ has_confidence = True
1405
+ print(
1406
+ f"Using confidence values (range: {gdf['confidence'].min():.2f} - {gdf['confidence'].max():.2f})"
1407
+ )
1408
+ except Exception as e:
1409
+ print(f"Confidence column exists but couldn't access values: {e}")
1410
+ has_confidence = False
1411
+ else:
1412
+ print("No confidence column found in GeoDataFrame")
1413
+ has_confidence = False
637
1414
 
638
1415
  # Read raster for visualization
639
1416
  with rasterio.open(raster_path) as src:
@@ -651,8 +1428,27 @@ class BuildingFootprintExtractor:
651
1428
  image = src.read(
652
1429
  out_shape=out_shape, resampling=rasterio.enums.Resampling.bilinear
653
1430
  )
1431
+
1432
+ # Create a scaled transform for the resampled image
1433
+ # Calculate scaling factors
1434
+ x_scale = src.width / out_shape[2]
1435
+ y_scale = src.height / out_shape[1]
1436
+
1437
+ # Get the original transform
1438
+ orig_transform = src.transform
1439
+
1440
+ # Create a scaled transform
1441
+ scaled_transform = rasterio.transform.Affine(
1442
+ orig_transform.a * x_scale,
1443
+ orig_transform.b,
1444
+ orig_transform.c,
1445
+ orig_transform.d,
1446
+ orig_transform.e * y_scale,
1447
+ orig_transform.f,
1448
+ )
654
1449
  else:
655
1450
  image = src.read()
1451
+ scaled_transform = src.transform
656
1452
 
657
1453
  # Convert to RGB for display
658
1454
  if image.shape[0] > 3:
@@ -671,60 +1467,134 @@ class BuildingFootprintExtractor:
671
1467
 
672
1468
  # Get image bounds
673
1469
  bounds = src.bounds
1470
+ crs = src.crs
674
1471
 
675
1472
  # Create figure with appropriate aspect ratio
676
1473
  aspect_ratio = image.shape[1] / image.shape[0] # width / height
677
1474
  plt.figure(figsize=(figsize[0], figsize[0] / aspect_ratio))
678
-
679
- # Create axis with the right projection if CRS is available
680
1475
  ax = plt.gca()
681
1476
 
682
1477
  # Display image
683
1478
  ax.imshow(image)
684
1479
 
685
- # Convert GeoDataFrame to pixel coordinates for plotting
686
- with rasterio.open(raster_path) as src:
1480
+ # Make sure the GeoDataFrame has the same CRS as the raster
1481
+ if gdf.crs != crs:
1482
+ print(f"Reprojecting GeoDataFrame from {gdf.crs} to {crs}")
1483
+ gdf = gdf.to_crs(crs)
1484
+
1485
+ # Set up colors for confidence visualization
1486
+ if has_confidence:
1487
+ try:
1488
+ import matplotlib.cm as cm
1489
+ from matplotlib.colors import Normalize
1490
+
1491
+ # Get min/max confidence values
1492
+ min_conf = gdf["confidence"].min()
1493
+ max_conf = gdf["confidence"].max()
687
1494
 
688
- def geo_to_pixel(x, y):
689
- return ~src.transform * (x, y)
1495
+ # Set up normalization and colormap
1496
+ norm = Normalize(vmin=min_conf, vmax=max_conf)
1497
+ cmap = cm.viridis
1498
+
1499
+ # Create scalar mappable for colorbar
1500
+ sm = cm.ScalarMappable(cmap=cmap, norm=norm)
1501
+ sm.set_array([])
1502
+
1503
+ # Add colorbar
1504
+ cbar = plt.colorbar(
1505
+ sm, ax=ax, orientation="vertical", shrink=0.7, pad=0.01
1506
+ )
1507
+ cbar.set_label("Confidence Score")
1508
+ except Exception as e:
1509
+ print(f"Error setting up confidence visualization: {e}")
1510
+ has_confidence = False
1511
+
1512
+ # Function to convert coordinates
1513
+ def geo_to_pixel(geometry, transform):
1514
+ """Convert geometry to pixel coordinates using the provided transform."""
1515
+ if geometry.is_empty:
1516
+ return None
1517
+
1518
+ if geometry.geom_type == "Polygon":
1519
+ # Get exterior coordinates
1520
+ exterior_coords = list(geometry.exterior.coords)
1521
+
1522
+ # Convert to pixel coordinates
1523
+ pixel_coords = [~transform * (x, y) for x, y in exterior_coords]
1524
+
1525
+ # Split into x and y lists
1526
+ pixel_x = [coord[0] for coord in pixel_coords]
1527
+ pixel_y = [coord[1] for coord in pixel_coords]
1528
+
1529
+ return pixel_x, pixel_y
1530
+ else:
1531
+ print(f"Unsupported geometry type: {geometry.geom_type}")
1532
+ return None
690
1533
 
691
- # Plot each building footprint
692
- for _, row in gdf.iterrows():
1534
+ # Plot each building footprint
1535
+ for idx, row in gdf.iterrows():
1536
+ try:
693
1537
  # Convert polygon to pixel coordinates
694
- geom = row.geometry
695
- if geom.is_empty:
696
- continue
1538
+ coords = geo_to_pixel(row.geometry, scaled_transform)
697
1539
 
698
- try:
699
- # Get polygon exterior coordinates
700
- x, y = geom.exterior.xy
1540
+ if coords:
1541
+ pixel_x, pixel_y = coords
701
1542
 
702
- # Convert to pixel coordinates
703
- pixel_coords = [geo_to_pixel(x[i], y[i]) for i in range(len(x))]
704
- pixel_x = [coord[0] for coord in pixel_coords]
705
- pixel_y = [coord[1] for coord in pixel_coords]
1543
+ if has_confidence:
1544
+ try:
1545
+ # Get confidence value using different methods
1546
+ # Method 1: Try direct attribute access
1547
+ confidence = None
1548
+ try:
1549
+ confidence = row.confidence
1550
+ except:
1551
+ pass
1552
+
1553
+ # Method 2: Try dictionary-style access
1554
+ if confidence is None:
1555
+ try:
1556
+ confidence = row["confidence"]
1557
+ except:
1558
+ pass
706
1559
 
707
- # Plot polygon
708
- ax.plot(pixel_x, pixel_y, color="red", linewidth=1)
709
- except Exception as e:
710
- print(f"Error plotting polygon: {e}")
1560
+ # Method 3: Try accessing by index from the GeoDataFrame
1561
+ if confidence is None:
1562
+ try:
1563
+ confidence = gdf.iloc[idx]["confidence"]
1564
+ except:
1565
+ pass
1566
+
1567
+ if confidence is not None:
1568
+ color = cmap(norm(confidence))
1569
+ # Fill polygon with semi-transparent color
1570
+ ax.fill(pixel_x, pixel_y, color=color, alpha=0.5)
1571
+ # Draw border
1572
+ ax.plot(
1573
+ pixel_x,
1574
+ pixel_y,
1575
+ color=color,
1576
+ linewidth=1,
1577
+ alpha=0.8,
1578
+ )
1579
+ else:
1580
+ # Fall back to red if confidence value couldn't be accessed
1581
+ ax.plot(pixel_x, pixel_y, color="red", linewidth=1)
1582
+ except Exception as e:
1583
+ print(
1584
+ f"Error using confidence value for polygon {idx}: {e}"
1585
+ )
1586
+ ax.plot(pixel_x, pixel_y, color="red", linewidth=1)
1587
+ else:
1588
+ # No confidence data, just plot outlines in red
1589
+ ax.plot(pixel_x, pixel_y, color="red", linewidth=1)
1590
+ except Exception as e:
1591
+ print(f"Error plotting polygon {idx}: {e}")
711
1592
 
712
1593
  # Remove axes
713
1594
  ax.set_xticks([])
714
1595
  ax.set_yticks([])
715
1596
  ax.set_title(f"Building Footprints (Found: {len(gdf)})")
716
1597
 
717
- # Add colorbar for confidence if available
718
- if "confidence" in gdf.columns:
719
- # Create a colorbar legend
720
- sm = plt.cm.ScalarMappable(
721
- cmap=plt.get_cmap("viridis"),
722
- norm=plt.Normalize(gdf.confidence.min(), gdf.confidence.max()),
723
- )
724
- sm.set_array([])
725
- cbar = plt.colorbar(sm, ax=ax, orientation="vertical", shrink=0.7)
726
- cbar.set_label("Confidence")
727
-
728
1598
  # Save if requested
729
1599
  if output_path:
730
1600
  plt.tight_layout()
@@ -734,14 +1604,12 @@ class BuildingFootprintExtractor:
734
1604
  plt.close()
735
1605
 
736
1606
  # Create a simpler visualization focused just on a subset of buildings
737
- # This helps when the raster is very large
738
- plt.figure(figsize=figsize)
739
- ax = plt.gca()
1607
+ if len(gdf) > 0:
1608
+ plt.figure(figsize=figsize)
1609
+ ax = plt.gca()
740
1610
 
741
- # Choose a subset of the image to show
742
- with rasterio.open(raster_path) as src:
743
- # Get a sample window based on the first few buildings
744
- if len(gdf) > 0:
1611
+ # Choose a subset of the image to show
1612
+ with rasterio.open(raster_path) as src:
745
1613
  # Get centroid of first building
746
1614
  sample_geom = gdf.iloc[0].geometry
747
1615
  centroid = sample_geom.centroid
@@ -776,36 +1644,109 @@ class BuildingFootprintExtractor:
776
1644
 
777
1645
  sample_image = np.clip(sample_image, 0, 1)
778
1646
 
779
- # Get transform for this window
780
- window_transform = src.window_transform(window)
781
-
782
1647
  # Display sample image
783
- ax.imshow(sample_image)
1648
+ ax.imshow(sample_image, extent=[0, window.width, window.height, 0])
784
1649
 
785
- # Filter buildings that intersect with this window
1650
+ # Get the correct transform for this window
1651
+ window_transform = src.window_transform(window)
1652
+
1653
+ # Calculate bounds of the window
786
1654
  window_bounds = rasterio.windows.bounds(window, src.transform)
787
1655
  window_box = box(*window_bounds)
1656
+
1657
+ # Filter buildings that intersect with this window
788
1658
  visible_gdf = gdf[gdf.intersects(window_box)]
789
1659
 
790
- # Plot building footprints in this view
791
- for _, row in visible_gdf.iterrows():
1660
+ # Set up colors for sample view if confidence data exists
1661
+ if has_confidence:
1662
+ try:
1663
+ # Reuse the same normalization and colormap from main view
1664
+ sample_sm = cm.ScalarMappable(cmap=cmap, norm=norm)
1665
+ sample_sm.set_array([])
1666
+
1667
+ # Add colorbar to sample view
1668
+ sample_cbar = plt.colorbar(
1669
+ sample_sm,
1670
+ ax=ax,
1671
+ orientation="vertical",
1672
+ shrink=0.7,
1673
+ pad=0.01,
1674
+ )
1675
+ sample_cbar.set_label("Confidence Score")
1676
+ except Exception as e:
1677
+ print(f"Error setting up sample confidence visualization: {e}")
1678
+
1679
+ # Plot building footprints in sample view
1680
+ for idx, row in visible_gdf.iterrows():
792
1681
  try:
793
- # Get polygon exterior coordinates
1682
+ # Get window-relative pixel coordinates
794
1683
  geom = row.geometry
1684
+
1685
+ # Skip empty geometries
795
1686
  if geom.is_empty:
796
1687
  continue
797
1688
 
798
- x, y = geom.exterior.xy
1689
+ # Get exterior coordinates
1690
+ exterior_coords = list(geom.exterior.coords)
1691
+
1692
+ # Convert to pixel coordinates relative to window origin
1693
+ pixel_coords = []
1694
+ for x, y in exterior_coords:
1695
+ px, py = ~src.transform * (x, y) # Convert to image pixels
1696
+ # Make coordinates relative to window
1697
+ px = px - window.col_off
1698
+ py = py - window.row_off
1699
+ pixel_coords.append((px, py))
799
1700
 
800
- # Convert to pixel coordinates relative to window
801
- pixel_coords = [
802
- ~window_transform * (x[i], y[i]) for i in range(len(x))
803
- ]
1701
+ # Extract x and y coordinates
804
1702
  pixel_x = [coord[0] for coord in pixel_coords]
805
1703
  pixel_y = [coord[1] for coord in pixel_coords]
806
1704
 
807
- # Plot polygon
808
- ax.plot(pixel_x, pixel_y, color="red", linewidth=1.5)
1705
+ # Use confidence colors if available
1706
+ if has_confidence:
1707
+ try:
1708
+ # Try different methods to access confidence
1709
+ confidence = None
1710
+ try:
1711
+ confidence = row.confidence
1712
+ except:
1713
+ pass
1714
+
1715
+ if confidence is None:
1716
+ try:
1717
+ confidence = row["confidence"]
1718
+ except:
1719
+ pass
1720
+
1721
+ if confidence is None:
1722
+ try:
1723
+ confidence = visible_gdf.iloc[idx]["confidence"]
1724
+ except:
1725
+ pass
1726
+
1727
+ if confidence is not None:
1728
+ color = cmap(norm(confidence))
1729
+ # Fill polygon with semi-transparent color
1730
+ ax.fill(pixel_x, pixel_y, color=color, alpha=0.5)
1731
+ # Draw border
1732
+ ax.plot(
1733
+ pixel_x,
1734
+ pixel_y,
1735
+ color=color,
1736
+ linewidth=1.5,
1737
+ alpha=0.8,
1738
+ )
1739
+ else:
1740
+ ax.plot(
1741
+ pixel_x, pixel_y, color="red", linewidth=1.5
1742
+ )
1743
+ except Exception as e:
1744
+ print(
1745
+ f"Error using confidence in sample view for polygon {idx}: {e}"
1746
+ )
1747
+ ax.plot(pixel_x, pixel_y, color="red", linewidth=1.5)
1748
+ else:
1749
+ ax.plot(pixel_x, pixel_y, color="red", linewidth=1.5)
809
1750
  except Exception as e:
810
1751
  print(f"Error plotting polygon in sample view: {e}")
811
1752
 
@@ -828,5 +1769,3 @@ class BuildingFootprintExtractor:
828
1769
  plt.tight_layout()
829
1770
  plt.savefig(sample_output, dpi=300, bbox_inches="tight")
830
1771
  print(f"Sample visualization saved to {sample_output}")
831
-
832
- return True