diff --git a/api_server.py b/api_server.py index 7fb1b91..756c6e1 100644 --- a/api_server.py +++ b/api_server.py @@ -224,6 +224,9 @@ class PredictionConfig(BaseModel): # Cloud removal strategy cloud_removal_method: str = "classic" cloud_removal_model: Optional[str] = None # Optional: .pth model filename for deep learning cloud removal + + # Shapefile overlay for visualization + shapefile_overlay: Optional[str] = None # Path to shapefile for overlaying boundaries class TrainingStatus(BaseModel): @@ -286,6 +289,7 @@ class PredictionWithNDVIConfig(BaseModel): export_classification: bool = True # Export classification raster cloud_removal_method: str = "classic" cloud_removal_model: Optional[str] = None # Optional: .pth model filename for deep learning cloud removal + shapefile_overlay: Optional[str] = None # Path to shapefile for overlaying boundaries class CloudRemovalTrainingConfig(BaseModel): @@ -1228,6 +1232,87 @@ async def list_training_files(): } +@app.get("/api/overlay/shapefiles") +async def list_overlay_shapefiles(): + """Liệt kê các shapefile có sẵn cho overlay trên prediction""" + overlay_dirs = ["region", "ChauThanh", "ThuanHoa"] + shapefiles = [] + + for overlay_dir in overlay_dirs: + dir_path = Path(overlay_dir) + if not dir_path.exists(): + continue + + # Find all .shp files in this directory and subdirectories + for shp_file in dir_path.rglob("*.shp"): + try: + file_size = shp_file.stat().st_size + file_modified = datetime.fromtimestamp(shp_file.stat().st_mtime).isoformat() + + # Try to read shapefile to get feature count and bbox + import geopandas as gpd + gdf = gpd.read_file(str(shp_file)) + feature_count = len(gdf) + + # Calculate bbox (always in EPSG:4326 for consistency) + bbox = None + if not gdf.empty and gdf.crs: + try: + # Reproject to EPSG:4326 if needed + if gdf.crs != "EPSG:4326": + gdf_4326 = gdf.to_crs("EPSG:4326") + else: + gdf_4326 = gdf + + # Get total bounds [minx, miny, maxx, maxy] + bounds = gdf_4326.total_bounds + if len(bounds) == 4: + bbox = [ + float(bounds[0]), # min_lon + float(bounds[1]), # min_lat + float(bounds[2]), # max_lon + float(bounds[3]) # max_lat + ] + except Exception as bbox_error: + print(f"[WARNING] Cannot calculate bbox for {shp_file}: {bbox_error}") + + # Get relative path from workspace root + relative_path = str(shp_file) + + shapefiles.append({ + "filename": shp_file.name, + "path": relative_path, + "directory": overlay_dir, + "size_bytes": file_size, + "size_mb": round(file_size / 1024 / 1024, 2), + "modified": file_modified, + "feature_count": feature_count, + "crs": str(gdf.crs) if gdf.crs else "Unknown", + "bbox": bbox # [min_lon, min_lat, max_lon, max_lat] in EPSG:4326 + }) + except Exception as e: + # If cannot read shapefile, just add basic info + file_size = shp_file.stat().st_size + file_modified = datetime.fromtimestamp(shp_file.stat().st_mtime).isoformat() + relative_path = str(shp_file) + + shapefiles.append({ + "filename": shp_file.name, + "path": relative_path, + "directory": overlay_dir, + "size_bytes": file_size, + "size_mb": round(file_size / 1024 / 1024, 2), + "modified": file_modified, + "error": f"Cannot read shapefile: {str(e)}" + }) + + return { + "shapefiles": shapefiles, + "count": len(shapefiles), + "directories": overlay_dirs + } + + @app.get("/api/training/shapefile/{filename}/labels") async def get_shapefile_labels(filename: str): """Lấy các label từ một shapefile cụ thể""" @@ -1729,6 +1814,85 @@ def update_progress(message: str): print(f"[PROGRESS] {message}") +def rasterize_shapefile_overlay(shapefile_path, reference_raster, boundary_value=255): + """ + Rasterize shapefile boundaries to overlay on prediction result. + + Args: + shapefile_path: Path to shapefile + reference_raster: xarray DataArray to match dimensions and CRS + boundary_value: Value to use for boundaries (default 255 for white) + + Returns: + numpy array with boundaries, same shape as reference_raster + """ + try: + import geopandas as gpd + from rasterio.features import rasterize + import numpy as np + + # Read shapefile + gdf = gpd.read_file(shapefile_path) + print(f"[OVERLAY] Loaded shapefile with {len(gdf)} features, CRS: {gdf.crs}") + + # Ensure CRS matches + target_crs = reference_raster.rio.crs + if gdf.crs != target_crs: + print(f"[OVERLAY] Reprojecting from {gdf.crs} to {target_crs}") + gdf = gdf.to_crs(target_crs) + + # Get raster dimensions and transform + height, width = reference_raster.shape + transform = reference_raster.rio.transform() + print(f"[OVERLAY] Raster dimensions: {height}x{width}") + print(f"[OVERLAY] Transform: {transform}") + + # Calculate appropriate buffer size based on pixel resolution + # Get pixel size from transform (transform[0] is x resolution) + pixel_size = abs(transform[0]) # in CRS units + # Very thin boundary - only 0.2 pixels wide for 1px line + buffer_distance = pixel_size * 0.2 + print(f"[OVERLAY] Pixel size: {pixel_size}, Buffer distance: {buffer_distance} (thin 1px line)") + + # Create boundary geometries with minimal buffering + boundary_geoms = [] + for idx, geom in enumerate(gdf.geometry): + if geom is not None and geom.is_valid: + # Get boundary of each polygon + boundary = geom.boundary + if boundary is not None: + # Minimal buffer for 1-pixel thin line + buffered = boundary.buffer(buffer_distance) + boundary_geoms.append((buffered, boundary_value)) + + if not boundary_geoms: + print(f"[WARNING] No valid boundary geometries found in {shapefile_path}") + return np.zeros((height, width), dtype=np.uint8) + + print(f"[OVERLAY] Rasterizing {len(boundary_geoms)} boundaries...") + + # Rasterize boundaries + boundary_mask = rasterize( + shapes=boundary_geoms, + out_shape=(height, width), + transform=transform, + fill=0, # Background + dtype=np.uint8 + ) + + boundary_count = np.count_nonzero(boundary_mask) + print(f"[OVERLAY] Boundary pixels: {boundary_count} / {height*width} ({boundary_count/(height*width)*100:.2f}%)") + + if boundary_count == 0: + print(f"[OVERLAY WARNING] No boundary pixels were rasterized! Check CRS and geometry overlap.") + + return boundary_mask + + except Exception as e: + print(f"[ERROR] Failed to rasterize shapefile {shapefile_path}: {e}") + return None + + def update_prediction_progress(message: str): """Cập nhật prediction progress message""" global prediction_status @@ -2083,6 +2247,33 @@ async def run_prediction(config: PredictionConfig): prediction_da.rio.to_raster(str(output_file), driver="GTiff") + # ============ SHAPEFILE OVERLAY ============ + overlay_mask = None + if config.shapefile_overlay: + prediction_status["progress"] = f"Đang overlay shapefile: {config.shapefile_overlay}..." + print(f"[OVERLAY] Shapefile overlay requested: {config.shapefile_overlay}") + + # Validate shapefile path exists + shapefile_path = Path(config.shapefile_overlay) + if not shapefile_path.exists(): + print(f"[OVERLAY ERROR] Shapefile not found: {shapefile_path}") + print(f"[OVERLAY ERROR] Absolute path: {shapefile_path.absolute()}") + print(f"[OVERLAY ERROR] Current working directory: {Path.cwd()}") + else: + print(f"[OVERLAY] Shapefile exists: {shapefile_path.absolute()}") + try: + overlay_mask = rasterize_shapefile_overlay(str(shapefile_path), prediction_da) + if overlay_mask is not None and np.count_nonzero(overlay_mask) > 0: + print(f"[OVERLAY] Successfully rasterized shapefile boundaries ({np.count_nonzero(overlay_mask)} pixels)") + else: + print(f"[OVERLAY WARNING] Shapefile rasterized but no boundary pixels found") + except Exception as overlay_error: + print(f"[OVERLAY ERROR] Exception: {overlay_error}") + import traceback + traceback.print_exc() + else: + print(f"[OVERLAY] No shapefile overlay requested") + # Generate PNG preview for web display prediction_status["progress"] = "Đang tạo PNG preview..." png_file = output_dir / f"prediction_{timestamp}.png" @@ -2097,6 +2288,42 @@ async def run_prediction(config: PredictionConfig): # Plot prediction with colormap im = ax.imshow(predictions_2d, cmap='tab20', interpolation='nearest') + + # Overlay shapefile boundaries if available + if overlay_mask is not None and np.count_nonzero(overlay_mask) > 0: + print(f"[PNG OVERLAY] Overlaying {np.count_nonzero(overlay_mask)} boundary pixels") + + # Create a mask for boundaries (where overlay_mask > 0) + boundary_mask = overlay_mask > 0 + + # Method: Direct overlay with high-contrast colors + # Create RGBA overlay image + overlay_rgba = np.zeros((*predictions_2d.shape, 4)) + overlay_rgba[boundary_mask, 0] = 1.0 # Red = 1.0 (white) + overlay_rgba[boundary_mask, 1] = 1.0 # Green = 1.0 (white) + overlay_rgba[boundary_mask, 2] = 1.0 # Blue = 1.0 (white) + overlay_rgba[boundary_mask, 3] = 1.0 # Alpha = 1.0 (fully opaque) + + # Overlay on top of prediction + ax.imshow(overlay_rgba, interpolation='nearest') + + # Also add a black outline for better contrast + from scipy import ndimage + boundary_dilated = ndimage.binary_dilation(boundary_mask, iterations=1) + boundary_outline = boundary_dilated & ~boundary_mask + + outline_rgba = np.zeros((*predictions_2d.shape, 4)) + outline_rgba[boundary_outline, 0] = 0.0 # Black outline + outline_rgba[boundary_outline, 1] = 0.0 + outline_rgba[boundary_outline, 2] = 0.0 + outline_rgba[boundary_outline, 3] = 0.8 + + ax.imshow(outline_rgba, interpolation='nearest') + + print(f"[PNG OVERLAY] Added shapefile boundaries to visualization (direct overlay method)") + else: + print(f"[PNG OVERLAY] No overlay mask or empty mask (pixels: {np.count_nonzero(overlay_mask) if overlay_mask is not None else 0})") + ax.set_title(f'Land Classification - {timestamp}', fontsize=16, fontweight='bold', pad=20) ax.set_xlabel('X (pixels)', fontsize=11) ax.set_ylabel('Y (pixels)', fontsize=11) @@ -2176,7 +2403,9 @@ async def run_prediction(config: PredictionConfig): "n_features": features.shape[1], "feature_mode": feature_mode, "used_radar": use_radar, - "model_used": config.model_filename + "model_used": config.model_filename, + "shapefile_overlay": config.shapefile_overlay, + "overlay_applied": overlay_mask is not None } # Auto generate prediction report @@ -4355,8 +4584,69 @@ async def predict_with_ndvi(config: PredictionWithNDVIConfig, background_tasks: import matplotlib.pyplot as plt from matplotlib.patches import Patch + # ============ SHAPEFILE OVERLAY ============ + overlay_mask = None + if config.shapefile_overlay: + print(f"[OVERLAY] Shapefile overlay requested: {config.shapefile_overlay}") + + # Validate shapefile path exists + shapefile_path = Path(config.shapefile_overlay) + if not shapefile_path.exists(): + print(f"[OVERLAY ERROR] Shapefile not found: {shapefile_path}") + print(f"[OVERLAY ERROR] Absolute path: {shapefile_path.absolute()}") + else: + print(f"[OVERLAY] Shapefile exists: {shapefile_path.absolute()}") + try: + # Import rioxarray for rio accessor + import rioxarray + + # Create temporary DataArray for rasterization + temp_da = xr.DataArray( + prediction_raster, + coords={ + "y": np.linspace(bbox[3], bbox[1], height), + "x": np.linspace(bbox[0], bbox[2], width) + }, + dims=["y", "x"] + ) + temp_da.rio.write_crs("EPSG:4326", inplace=True) + temp_da.rio.write_transform(transform, inplace=True) + + overlay_mask = rasterize_shapefile_overlay(str(shapefile_path), temp_da) + if overlay_mask is not None and np.count_nonzero(overlay_mask) > 0: + print(f"[OVERLAY] Successfully rasterized shapefile boundaries ({np.count_nonzero(overlay_mask)} pixels)") + else: + print(f"[OVERLAY WARNING] Shapefile rasterized but no boundary pixels found") + except Exception as overlay_error: + print(f"[OVERLAY ERROR] Exception: {overlay_error}") + import traceback + traceback.print_exc() + else: + print(f"[OVERLAY] No shapefile overlay requested") + fig, ax = plt.subplots(figsize=(14, 10), dpi=150) im = ax.imshow(prediction_raster, cmap='tab20', interpolation='nearest') + + # Overlay shapefile boundaries if available + if overlay_mask is not None and np.count_nonzero(overlay_mask) > 0: + print(f"[PNG OVERLAY] Overlaying {np.count_nonzero(overlay_mask)} boundary pixels (1px thin line)") + + # Create a mask for boundaries + boundary_mask = overlay_mask > 0 + + # Create RGBA overlay image - thin 1px white line only + overlay_rgba = np.zeros((*prediction_raster.shape, 4)) + overlay_rgba[boundary_mask, 0] = 1.0 # White (R=1) + overlay_rgba[boundary_mask, 1] = 1.0 # White (G=1) + overlay_rgba[boundary_mask, 2] = 1.0 # White (B=1) + overlay_rgba[boundary_mask, 3] = 1.0 # Fully opaque + + ax.imshow(overlay_rgba, interpolation='nearest') + + print(f"[PNG OVERLAY] Added thin 1px shapefile boundaries to visualization") + else: + print(f"[PNG OVERLAY] No overlay mask or empty mask") + ax.set_title(f'Land Classification - {timestamp}', fontsize=16, fontweight='bold', pad=20) ax.set_xlabel('X (pixels)', fontsize=11) ax.set_ylabel('Y (pixels)', fontsize=11) diff --git a/prediction_interface.html b/prediction_interface.html index 3274b8b..8ec5467 100644 --- a/prediction_interface.html +++ b/prediction_interface.html @@ -774,6 +774,23 @@ + + +
+ + +
+ 🎯 Tự động cập nhật vùng prediction:
+ ✅ Khi chọn shapefile → Bbox trên bản đồ tự động thay đổi theo vùng shapefile
+ ✅ CRS sẽ tự động chuyển đổi - không cần lo về EPSG:4326/9209/32648
+ 💡 Không chọn shapefile → Dùng bbox tùy chỉnh do bạn vẽ trên bản đồ +
+