Spaces:
Paused
Paused
Download satquery_engine/services/registration.py from SM737/satquery-api: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/spaces/SM737/satquery-api/resolve/main/satquery_engine/services/registration.py
- Command line
-
hf download hf://spaces/SM737/satquery-api/satquery_engine/services/registration.py
-
curl -L -o registration.py https://huggingface.co/spaces/SM737/satquery-api/resolve/main/satquery_engine/services/registration.py
10.6 kB
| from __future__ import annotations | |
| import json | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import rasterio | |
| from affine import Affine | |
| from PIL import Image | |
| from rasterio.enums import Resampling | |
| from rasterio.warp import calculate_default_transform, reproject | |
| class RegistrationReport: | |
| attempted: bool | |
| method: str | |
| alignment_score: float | |
| status: str | |
| shift_x: float | |
| shift_y: float | |
| original_dims_a: tuple[int, int] | |
| original_dims_b: tuple[int, int] | |
| normalized_dims: tuple[int, int] | |
| message: str | |
| def _normalize_luminance(arr: np.ndarray) -> np.ndarray: | |
| """Normalize 2D array to [0.0, 1.0] ignoring extreme outliers.""" | |
| arr = arr.astype("float32") | |
| finite = arr[np.isfinite(arr)] | |
| if finite.size == 0: | |
| return np.zeros_like(arr, dtype="float32") | |
| p2, p98 = np.percentile(finite, [2, 98]) | |
| if p98 <= p2: | |
| return np.zeros_like(arr, dtype="float32") | |
| return np.clip((arr - p2) / (p98 - p2), 0.0, 1.0) | |
| def _compute_phase_correlation_shift( | |
| im1: np.ndarray, im2: np.ndarray, max_shift_ratio: float = 0.2 | |
| ) -> tuple[float, float, float]: | |
| """Compute translation shift (dy, dx) between two 2D grayscale arrays using FFT phase correlation. | |
| Returns: | |
| (shift_y, shift_x, peak_correlation) | |
| """ | |
| h, w = im1.shape | |
| if (h, w) != im2.shape or h < 8 or w < 8: | |
| return 0.0, 0.0, 0.0 | |
| # Apply Hann window to avoid edge artifacts in FFT | |
| window_y = np.hanning(h)[:, None] | |
| window_x = np.hanning(w)[None, :] | |
| window = (window_y * window_x).astype("float32") | |
| w1 = (im1 - im1.mean()) * window | |
| w2 = (im2 - im2.mean()) * window | |
| f1 = np.fft.fft2(w1) | |
| f2 = np.fft.fft2(w2) | |
| cross_power = f1 * np.conj(f2) | |
| norm = np.abs(cross_power) | |
| norm[norm < 1e-9] = 1e-9 | |
| cross_power /= norm | |
| corr = np.fft.ifft2(cross_power) | |
| corr = np.abs(np.fft.fftshift(corr)) | |
| cy, cx = h // 2, w // 2 | |
| max_idx = np.unravel_index(np.argmax(corr), corr.shape) | |
| shift_y = float(max_idx[0] - cy) | |
| shift_x = float(max_idx[1] - cx) | |
| peak_val = float(corr[max_idx]) | |
| # Reject unreasonable large jumps (beyond max_shift_ratio) | |
| if abs(shift_y) > h * max_shift_ratio or abs(shift_x) > w * max_shift_ratio: | |
| return 0.0, 0.0, 0.0 | |
| return shift_y, shift_x, peak_val | |
| def _apply_shift(image_arr: np.ndarray, shift_y: float, shift_x: float) -> np.ndarray: | |
| """Shift array (2D or 3D) by integer or rounded displacement and edge pad.""" | |
| sy = int(round(shift_y)) | |
| sx = int(round(shift_x)) | |
| if sy == 0 and sx == 0: | |
| return image_arr | |
| shifted = np.zeros_like(image_arr) | |
| h, w = image_arr.shape[:2] | |
| # Source slices | |
| src_y1 = max(0, -sy) | |
| src_y2 = min(h, h - sy) | |
| src_x1 = max(0, -sx) | |
| src_x2 = min(w, w - sx) | |
| # Destination slices | |
| dst_y1 = max(0, sy) | |
| dst_y2 = min(h, h + sy) | |
| dst_x1 = max(0, sx) | |
| dst_x2 = min(w, w + sx) | |
| if dst_y2 > dst_y1 and dst_x2 > dst_x1 and src_y2 > src_y1 and src_x2 > src_x1: | |
| shifted[dst_y1:dst_y2, dst_x1:dst_x2] = image_arr[src_y1:src_y2, src_x1:src_x2] | |
| return shifted | |
| def compute_alignment_quality(im1: np.ndarray, im2: np.ndarray) -> float: | |
| """Compute alignment score in [0.0, 1.0] using structural and correlation metrics.""" | |
| if im1.shape != im2.shape or im1.size == 0: | |
| return 0.0 | |
| # Ensure grayscale 2D float [0, 1] | |
| g1 = _normalize_luminance(im1) | |
| g2 = _normalize_luminance(im2) | |
| # 1. Pearson Correlation Coefficient | |
| mean1, mean2 = float(g1.mean()), float(g2.mean()) | |
| std1, std2 = float(g1.std()), float(g2.std()) | |
| if std1 < 1e-6 or std2 < 1e-6: | |
| # Flat image | |
| diff = np.abs(g1 - g2).mean() | |
| return float(np.clip(1.0 - diff, 0.0, 1.0)) | |
| cov = float(((g1 - mean1) * (g2 - mean2)).mean()) | |
| pearson = cov / (std1 * std2) | |
| # 2. Mean Absolute Error similarity | |
| mae = float(np.abs(g1 - g2).mean()) | |
| mae_sim = max(0.0, 1.0 - (mae * 2.0)) | |
| # 3. Structural Gradient overlap | |
| gy1, gx1 = np.gradient(g1) | |
| gy2, gx2 = np.gradient(g2) | |
| grad1 = np.sqrt(gy1**2 + gx1**2) | |
| grad2 = np.sqrt(gy2**2 + gx2**2) | |
| g_std1, g_std2 = float(grad1.std()), float(grad2.std()) | |
| if g_std1 > 1e-6 and g_std2 > 1e-6: | |
| g_cov = float(((grad1 - grad1.mean()) * (grad2 - grad2.mean())).mean()) | |
| grad_sim = max(0.0, g_cov / (g_std1 * g_std2)) | |
| else: | |
| grad_sim = mae_sim | |
| # Composite alignment score | |
| score = 0.45 * max(0.0, pearson) + 0.35 * mae_sim + 0.20 * grad_sim | |
| return float(np.clip(round(score, 4), 0.0, 1.0)) | |
| def normalize_and_register_pair( | |
| path_a: Path, | |
| path_b: Path, | |
| output_dir: Path, | |
| max_size: int = 1024, | |
| ) -> tuple[Path, Path, RegistrationReport]: | |
| """Inspects, normalizes, and registers a before/after image pair. | |
| Never modifies original uploaded files. Creates working copies in output_dir. | |
| Returns: | |
| (path_working_a, path_working_b, report) | |
| """ | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| working_a_path = output_dir / "prepared_1.png" | |
| working_b_path = output_dir / "prepared_2.png" | |
| with rasterio.open(path_a) as src_a, rasterio.open(path_b) as src_b: | |
| dims_a = (src_a.width, src_a.height) | |
| dims_b = (src_b.width, src_b.height) | |
| has_crs_a = src_a.crs is not None | |
| has_crs_b = src_b.crs is not None | |
| # Determine target normalized dimension based on Primary Image A | |
| ratio_a = min(1.0, max_size / max(src_a.width, src_a.height)) | |
| target_w = max(16, round(src_a.width * ratio_a)) | |
| target_h = max(16, round(src_a.height * ratio_a)) | |
| normalized_dims = (target_w, target_h) | |
| # Mode 2: Both have valid CRS -> Use GIS geospatial warping / reproject | |
| if has_crs_a and has_crs_b and src_a.crs == src_b.crs: | |
| data_a = src_a.read( | |
| out_shape=(src_a.count, target_h, target_w), | |
| resampling=Resampling.bilinear, | |
| ) | |
| # Reproject B directly into A's coordinate window | |
| data_b = np.zeros((src_b.count, target_h, target_w), dtype="float32") | |
| reproject( | |
| source=rasterio.band(src_b, list(range(1, src_b.count + 1))), | |
| destination=data_b, | |
| src_transform=src_b.transform, | |
| src_crs=src_b.crs, | |
| dst_transform=src_a.transform * Affine.scale(src_a.width / target_w, src_a.height / target_h), | |
| dst_crs=src_a.crs, | |
| resampling=Resampling.bilinear, | |
| ) | |
| method = "geospatial_crs_reproject" | |
| shift_x, shift_y = 0.0, 0.0 | |
| gray_a = _normalize_luminance(data_a[0]) | |
| gray_b = _normalize_luminance(data_b[0]) | |
| else: | |
| # Mode 1: Standard Pixel-Space (PNG/JPG or mixed CRS) | |
| # Read and resize Image A | |
| indexes_a = [1, 2, 3] if src_a.count >= 3 else [1] | |
| data_a = src_a.read( | |
| indexes_a, | |
| out_shape=(len(indexes_a), target_h, target_w), | |
| resampling=Resampling.bilinear, | |
| ) | |
| # Read and resize Image B to identical target grid | |
| indexes_b = [1, 2, 3] if src_b.count >= 3 else [1] | |
| data_b = src_b.read( | |
| indexes_b, | |
| out_shape=(len(indexes_b), target_h, target_w), | |
| resampling=Resampling.bilinear, | |
| ) | |
| # Perform Phase-Correlation Registration on normalized grayscale | |
| gray_a = _normalize_luminance(data_a[0]) | |
| gray_b = _normalize_luminance(data_b[0]) | |
| shift_y, shift_x, peak_corr = _compute_phase_correlation_shift(gray_a, gray_b) | |
| if abs(shift_x) > 0.5 or abs(shift_y) > 0.5: | |
| # Apply registration shift across all channels of Image B | |
| for ch in range(data_b.shape[0]): | |
| data_b[ch] = _apply_shift(data_b[ch], shift_y, shift_x) | |
| method = f"phase_correlation_shift (dx={shift_x:.1f}, dy={shift_y:.1f})" | |
| gray_b = _normalize_luminance(data_b[0]) | |
| else: | |
| method = "direct_pixel_alignment" | |
| # Compute genuine alignment quality using already normalized arrays | |
| alignment_score = compute_alignment_quality(gray_a, gray_b) | |
| # Determine status | |
| if alignment_score >= 0.70: | |
| status = "optimal" | |
| msg = f"Images successfully normalized and registered ({method}). Alignment score: {alignment_score:.2f}." | |
| elif alignment_score >= 0.35: | |
| status = "acceptable" | |
| msg = f"Images normalized with minor spatial difference ({method}). Alignment score: {alignment_score:.2f}." | |
| else: | |
| status = "low_confidence" | |
| msg = f"Significant scene/perspective divergence detected. Alignment score: {alignment_score:.2f}." | |
| # Save normalized working image files (RGB PNG) | |
| def _to_rgb_image(arr: np.ndarray) -> Image.Image: | |
| if arr.shape[0] >= 3: | |
| r = (_normalize_luminance(arr[0]) * 255).astype("uint8") | |
| g = (_normalize_luminance(arr[1]) * 255).astype("uint8") | |
| b = (_normalize_luminance(arr[2]) * 255).astype("uint8") | |
| return Image.fromarray(np.stack([r, g, b], axis=-1), mode="RGB") | |
| gray = (_normalize_luminance(arr[0]) * 255).astype("uint8") | |
| return Image.fromarray(np.stack([gray, gray, gray], axis=-1), mode="RGB") | |
| _to_rgb_image(data_a).save(working_a_path, format="PNG") | |
| _to_rgb_image(data_b).save(working_b_path, format="PNG") | |
| report = RegistrationReport( | |
| attempted=True, | |
| method=method, | |
| alignment_score=alignment_score, | |
| status=status, | |
| shift_x=shift_x, | |
| shift_y=shift_y, | |
| original_dims_a=dims_a, | |
| original_dims_b=dims_b, | |
| normalized_dims=normalized_dims, | |
| message=msg, | |
| ) | |
| # Write registration manifest for auditability | |
| report_dict = { | |
| "attempted": report.attempted, | |
| "method": report.method, | |
| "alignment_score": report.alignment_score, | |
| "status": report.status, | |
| "shift_x": report.shift_x, | |
| "shift_y": report.shift_y, | |
| "original_dims_a": list(report.original_dims_a), | |
| "original_dims_b": list(report.original_dims_b), | |
| "normalized_dims": list(report.normalized_dims), | |
| "message": report.message, | |
| } | |
| (output_dir / "registration_report.json").write_text(json.dumps(report_dict, indent=2), encoding="utf-8") | |
| return working_a_path, working_b_path, report | |