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