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/__init__.py +1 -1
- geoai/extract.py +1006 -67
- geoai/geoai.py +1 -1
- geoai/preprocess.py +245 -2
- geoai/{common.py → utils.py} +463 -4
- {geoai_py-0.2.3.dist-info → geoai_py-0.3.1.dist-info}/METADATA +1 -1
- geoai_py-0.3.1.dist-info/RECORD +13 -0
- geoai_py-0.2.3.dist-info/RECORD +0 -13
- {geoai_py-0.2.3.dist-info → geoai_py-0.3.1.dist-info}/LICENSE +0 -0
- {geoai_py-0.2.3.dist-info → geoai_py-0.3.1.dist-info}/WHEEL +0 -0
- {geoai_py-0.2.3.dist-info → geoai_py-0.3.1.dist-info}/entry_points.txt +0 -0
- {geoai_py-0.2.3.dist-info → geoai_py-0.3.1.dist-info}/top_level.txt +0 -0
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__(
|
|
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
|
-
|
|
58
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
686
|
-
|
|
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
|
-
|
|
689
|
-
|
|
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
|
-
|
|
692
|
-
|
|
1534
|
+
# Plot each building footprint
|
|
1535
|
+
for idx, row in gdf.iterrows():
|
|
1536
|
+
try:
|
|
693
1537
|
# Convert polygon to pixel coordinates
|
|
694
|
-
|
|
695
|
-
if geom.is_empty:
|
|
696
|
-
continue
|
|
1538
|
+
coords = geo_to_pixel(row.geometry, scaled_transform)
|
|
697
1539
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
x, y = geom.exterior.xy
|
|
1540
|
+
if coords:
|
|
1541
|
+
pixel_x, pixel_y = coords
|
|
701
1542
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
1607
|
+
if len(gdf) > 0:
|
|
1608
|
+
plt.figure(figsize=figsize)
|
|
1609
|
+
ax = plt.gca()
|
|
740
1610
|
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
791
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
808
|
-
|
|
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
|