"""Run an explainable CPU image-region anomaly baseline.
|
|
The anomaly scores come from lightweight image statistics and Isolation Forest.
|
GeoAI is used for anomaly-mask vectorization, so this remains a B capability:
|
GeoAI supplies the geospatial output workflow while ecosystem code supplies the
|
anomaly model. A high score is only a review candidate, not a business event.
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
import json
|
import math
|
import time
|
from dataclasses import dataclass
|
from datetime import UTC, datetime
|
from importlib import metadata as importlib_metadata
|
from pathlib import Path
|
from typing import Any, Iterable
|
|
import cv2
|
import geopandas as gpd
|
import numpy as np
|
import pandas as pd
|
import rasterio
|
from PIL import Image
|
from rasterio.features import geometry_mask, shapes
|
from rasterio.transform import Affine
|
from shapely.geometry import Polygon, mapping, shape
|
from sklearn.ensemble import IsolationForest
|
from sklearn.preprocessing import StandardScaler
|
|
|
SUPPORTED_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
|
MASK_LABELS = {1: "rule_only", 2: "isolation_only", 3: "agreement"}
|
MASK_COLORS = {
|
0: np.array([0, 0, 0], dtype=np.uint8),
|
1: np.array([255, 158, 44], dtype=np.uint8),
|
2: np.array([47, 128, 237], dtype=np.uint8),
|
3: np.array([224, 49, 49], dtype=np.uint8),
|
}
|
FEATURE_NAMES = [
|
"rgb_mean_r",
|
"rgb_mean_g",
|
"rgb_mean_b",
|
"rgb_std_r",
|
"rgb_std_g",
|
"rgb_std_b",
|
"hsv_mean_h",
|
"hsv_mean_s",
|
"hsv_mean_v",
|
"hsv_std_h",
|
"hsv_std_s",
|
"hsv_std_v",
|
"gray_entropy",
|
"edge_density",
|
"laplacian_variance",
|
"dark_ratio",
|
"bright_ratio",
|
]
|
|
|
@dataclass(frozen=True)
|
class RasterInfo:
|
transform: Affine
|
crs: str | None
|
georeferenced: bool
|
source_bands: list[int]
|
normalization: str
|
|
|
def axis_positions(length: int, tile_size: int, stride: int) -> list[int]:
|
"""Return deterministic starts and always anchor the final tile to the edge."""
|
if length < tile_size:
|
raise ValueError(f"Image dimension {length} is smaller than tile size {tile_size}.")
|
positions = list(range(0, length - tile_size + 1, stride))
|
last = length - tile_size
|
if positions[-1] != last:
|
positions.append(last)
|
return positions
|
|
|
def tile_windows(width: int, height: int, tile_size: int, stride: int) -> list[tuple[int, int, int, int]]:
|
if tile_size <= 0 or stride <= 0 or stride > tile_size:
|
raise ValueError("tile_size must be positive and stride must be in 1..tile_size.")
|
return [
|
(x, y, x + tile_size, y + tile_size)
|
for y in axis_positions(height, tile_size, stride)
|
for x in axis_positions(width, tile_size, stride)
|
]
|
|
|
def _normalize_band(band: np.ndarray) -> np.ndarray:
|
values = band.astype(np.float32)
|
finite = values[np.isfinite(values)]
|
if finite.size == 0:
|
return np.zeros(band.shape, dtype=np.uint8)
|
low, high = np.percentile(finite, [2.0, 98.0])
|
if high <= low:
|
high = low + 1.0
|
return np.clip((values - low) * 255.0 / (high - low), 0, 255).astype(np.uint8)
|
|
|
def read_rgb(path: Path) -> tuple[np.ndarray, RasterInfo]:
|
"""Read common imagery and retain spatial metadata only when it is real."""
|
if path.suffix.lower() in {".tif", ".tiff"}:
|
with rasterio.open(path) as src:
|
if src.count < 3:
|
raise ValueError(f"GeoTIFF requires at least three bands: {path.name}")
|
raw = src.read([1, 2, 3])
|
if raw.dtype == np.uint8:
|
rgb = np.moveaxis(raw, 0, 2)
|
normalization = "uint8_identity"
|
else:
|
rgb = np.stack([_normalize_band(raw[index]) for index in range(3)], axis=2)
|
normalization = "per_band_percentile_2_98"
|
transform = src.transform
|
crs = src.crs.to_string() if src.crs else None
|
georeferenced = bool(src.crs and src.transform != Affine.identity())
|
return rgb, RasterInfo(transform, crs, georeferenced, [1, 2, 3], normalization)
|
with Image.open(path) as image:
|
rgb = np.asarray(image.convert("RGB"))
|
return rgb, RasterInfo(Affine.identity(), None, False, [1, 2, 3], "pillow_rgb")
|
|
|
def extract_features(tile: np.ndarray) -> np.ndarray:
|
"""Extract explainable colour, texture, edge, focus, and exposure features."""
|
if tile.ndim != 3 or tile.shape[2] != 3:
|
raise ValueError("Expected an RGB tile with shape (height, width, 3).")
|
rgb = tile.astype(np.float32)
|
hsv = cv2.cvtColor(tile, cv2.COLOR_RGB2HSV).astype(np.float32)
|
gray = cv2.cvtColor(tile, cv2.COLOR_RGB2GRAY)
|
histogram = cv2.calcHist([gray], [0], None, [32], [0, 256]).ravel().astype(np.float64)
|
probabilities = histogram / max(float(histogram.sum()), 1.0)
|
probabilities = probabilities[probabilities > 0]
|
entropy = float(-(probabilities * np.log2(probabilities)).sum())
|
edges = cv2.Canny(gray, 80, 160)
|
laplacian_variance = float(cv2.Laplacian(gray, cv2.CV_64F).var())
|
values = [
|
*rgb.mean(axis=(0, 1)).tolist(),
|
*rgb.std(axis=(0, 1)).tolist(),
|
*hsv.mean(axis=(0, 1)).tolist(),
|
*hsv.std(axis=(0, 1)).tolist(),
|
entropy,
|
float(np.mean(edges > 0)),
|
laplacian_variance,
|
float(np.mean(gray < 32)),
|
float(np.mean(gray > 223)),
|
]
|
return np.asarray(values, dtype=np.float64)
|
|
|
def image_features(
|
rgb: np.ndarray, tile_size: int, stride: int
|
) -> tuple[np.ndarray, list[tuple[int, int, int, int]]]:
|
height, width = rgb.shape[:2]
|
windows = tile_windows(width, height, tile_size, stride)
|
features = np.vstack([extract_features(rgb[y0:y1, x0:x1]) for x0, y0, x1, y1 in windows])
|
return features, windows
|
|
|
def robust_rule_scores(
|
features: np.ndarray,
|
center: np.ndarray,
|
scale: np.ndarray,
|
feature_cutoffs: np.ndarray | None = None,
|
) -> tuple[np.ndarray, np.ndarray]:
|
z_scores = np.abs((features - center) / scale)
|
normalized = z_scores if feature_cutoffs is None else z_scores / feature_cutoffs
|
return normalized.max(axis=1), normalized.argmax(axis=1)
|
|
|
def _spatial_profile(features: np.ndarray) -> np.ndarray:
|
"""Remove image-wide colour/lighting shifts while retaining local spatial patterns."""
|
center = np.median(features, axis=0)
|
mad = 1.4826 * np.median(np.abs(features - center), axis=0)
|
fallback = features.std(axis=0)
|
scale = np.where(mad > 1e-9, mad, np.where(fallback > 1e-9, fallback, 1.0))
|
return (features - center) / scale
|
|
|
def aligned_rule_scores(
|
features: np.ndarray, center: np.ndarray, scale: np.ndarray
|
) -> tuple[np.ndarray, np.ndarray]:
|
"""Compare every target tile only with the same tile position in the references."""
|
profile = _spatial_profile(features)
|
z_scores = np.abs((profile - center) / scale)
|
top_count = min(3, z_scores.shape[1])
|
scores = np.sort(z_scores, axis=1)[:, -top_count:].mean(axis=1)
|
return scores, z_scores.argmax(axis=1)
|
|
|
def fit_aligned_rule_model(
|
reference_feature_sets: list[np.ndarray], threshold_quantile: float
|
) -> dict[str, Any]:
|
"""Fit a fixed-camera rule model and calibrate it by leave-one-reference-out scores."""
|
if len(reference_feature_sets) < 3:
|
raise ValueError("Aligned spatial mode requires at least three reference images.")
|
if len({values.shape for values in reference_feature_sets}) != 1:
|
raise ValueError("Aligned spatial mode requires identical reference image dimensions.")
|
profiles = np.stack([_spatial_profile(values) for values in reference_feature_sets])
|
feature_floor = np.maximum(
|
profiles.reshape(-1, profiles.shape[-1]).std(axis=0) * 0.15, 0.1
|
)
|
|
def spatial_stats(values: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
center = np.median(values, axis=0)
|
mad = 1.4826 * np.median(np.abs(values - center), axis=0)
|
return center, np.maximum(mad, feature_floor)
|
|
calibration_scores: list[np.ndarray] = []
|
for index in range(profiles.shape[0]):
|
center, scale = spatial_stats(np.delete(profiles, index, axis=0))
|
z_scores = np.abs((profiles[index] - center) / scale)
|
top_count = min(3, z_scores.shape[1])
|
calibration_scores.append(np.sort(z_scores, axis=1)[:, -top_count:].mean(axis=1))
|
center, scale = spatial_stats(profiles)
|
reference_scores = np.concatenate(calibration_scores)
|
return {
|
"rule_mode": "aligned",
|
"aligned_center": center,
|
"aligned_scale": scale,
|
"rule_threshold": float(np.quantile(reference_scores, threshold_quantile)),
|
"reference_rule_scores": reference_scores,
|
}
|
|
|
def _robust_image_channel(values: np.ndarray) -> np.ndarray:
|
low, high = np.percentile(values, [10.0, 90.0])
|
scale = max(float(high - low), 1e-3)
|
return (values - np.median(values)) / scale
|
|
|
def _local_appearance_profile(rgb: np.ndarray) -> np.ndarray:
|
"""Represent generic local colour and luminance without any class/colour rules."""
|
lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB).astype(np.float32)
|
normalized = np.stack(
|
[_robust_image_channel(lab[:, :, index]) for index in range(3)], axis=2
|
).astype(np.float32)
|
return cv2.GaussianBlur(normalized, (0, 0), 2.0)
|
|
|
def _local_structure_profile(rgb: np.ndarray) -> np.ndarray:
|
"""Represent local intensity, edges, and contrast for class-agnostic changes."""
|
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
normalized = _robust_image_channel(gray).astype(np.float32)
|
smooth = cv2.GaussianBlur(normalized, (0, 0), 2.0)
|
gradient_x = cv2.Sobel(smooth, cv2.CV_32F, 1, 0, ksize=3)
|
gradient_y = cv2.Sobel(smooth, cv2.CV_32F, 0, 1, ksize=3)
|
gradient = np.sqrt(gradient_x * gradient_x + gradient_y * gradient_y)
|
local_mean = cv2.GaussianBlur(normalized, (0, 0), 4.0)
|
local_square_mean = cv2.GaussianBlur(normalized * normalized, (0, 0), 4.0)
|
local_contrast = np.sqrt(np.maximum(local_square_mean - local_mean * local_mean, 0.0))
|
return np.stack([smooth, gradient, local_contrast], axis=2).astype(np.float32)
|
|
|
def _nearest_profile_distance(profile: np.ndarray, references: list[np.ndarray]) -> np.ndarray:
|
distances = [np.sqrt(np.sum((profile - reference) ** 2, axis=2)) for reference in references]
|
return np.min(np.stack(distances), axis=0).astype(np.float32)
|
|
|
def _local_processing_shape(shape_: tuple[int, int], max_dimension: int = 2048) -> tuple[int, int]:
|
height, width = shape_
|
scale = min(1.0, max_dimension / max(height, width))
|
return max(1, round(height * scale)), max(1, round(width * scale))
|
|
|
def _resize_rgb(rgb: np.ndarray, shape_: tuple[int, int]) -> np.ndarray:
|
if rgb.shape[:2] == shape_:
|
return rgb
|
return cv2.resize(rgb, (shape_[1], shape_[0]), interpolation=cv2.INTER_AREA)
|
|
|
def fit_local_change_model(
|
reference_images: list[np.ndarray], threshold_quantile: float
|
) -> dict[str, Any]:
|
"""Calibrate generic pixel-local appearance/structure change from normal references."""
|
if len(reference_images) < 3 or len({image.shape for image in reference_images}) != 1:
|
raise ValueError("Local change scoring requires at least three equal-sized RGB references.")
|
original_shape = reference_images[0].shape[:2]
|
processing_shape = _local_processing_shape(original_shape)
|
resized = [_resize_rgb(image, processing_shape) for image in reference_images]
|
appearance_profiles = [_local_appearance_profile(image) for image in resized]
|
structure_profiles = [_local_structure_profile(image) for image in resized]
|
|
def calibrate(profiles: list[np.ndarray]) -> tuple[float, list[float]]:
|
scores: list[np.ndarray] = []
|
for index, profile in enumerate(profiles):
|
others = [value for other_index, value in enumerate(profiles) if other_index != index]
|
scores.append(_nearest_profile_distance(profile, others))
|
merged = np.concatenate([values.ravel() for values in scores])
|
threshold = float(np.quantile(merged, threshold_quantile))
|
coverages = [float(np.mean(values > threshold)) for values in scores]
|
return max(threshold, 1e-6), coverages
|
|
appearance_threshold, appearance_coverages = calibrate(appearance_profiles)
|
structure_threshold, structure_coverages = calibrate(structure_profiles)
|
return {
|
"local_change_enabled": True,
|
"local_original_shape": original_shape,
|
"local_processing_shape": processing_shape,
|
"local_appearance_profiles": appearance_profiles,
|
"local_structure_profiles": structure_profiles,
|
"local_appearance_threshold": appearance_threshold,
|
"local_structure_threshold": structure_threshold,
|
"local_reference_appearance_coverages": appearance_coverages,
|
"local_reference_structure_coverages": structure_coverages,
|
}
|
|
|
def local_change_scores(rgb: np.ndarray, models: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]:
|
"""Return threshold-normalized generic local scores and dominant-channel codes."""
|
if rgb.shape[:2] != tuple(models["local_original_shape"]):
|
raise ValueError("Local change scoring requires the target to match reference dimensions.")
|
resized = _resize_rgb(rgb, tuple(models["local_processing_shape"]))
|
appearance = _nearest_profile_distance(
|
_local_appearance_profile(resized), models["local_appearance_profiles"]
|
) / models["local_appearance_threshold"]
|
structure = _nearest_profile_distance(
|
_local_structure_profile(resized), models["local_structure_profiles"]
|
) / models["local_structure_threshold"]
|
if resized.shape[:2] != rgb.shape[:2]:
|
size = (rgb.shape[1], rgb.shape[0])
|
appearance = cv2.resize(appearance, size, interpolation=cv2.INTER_LINEAR)
|
structure = cv2.resize(structure, size, interpolation=cv2.INTER_LINEAR)
|
return np.maximum(appearance, structure), (structure > appearance).astype(np.uint8)
|
|
|
def clean_local_change_flag(score_map: np.ndarray, minimum_area: int = 512) -> np.ndarray:
|
"""Discard isolated/thin registration noise while retaining coherent anomalies."""
|
count, labels, stats, _ = cv2.connectedComponentsWithStats(
|
(score_map > 1.0).astype(np.uint8), connectivity=8
|
)
|
cleaned = np.zeros(score_map.shape, dtype=bool)
|
for label in range(1, count):
|
area = int(stats[label, cv2.CC_STAT_AREA])
|
width = int(stats[label, cv2.CC_STAT_WIDTH])
|
height = int(stats[label, cv2.CC_STAT_HEIGHT])
|
fill_ratio = area / max(width * height, 1)
|
coherent_small_region = fill_ratio >= 0.1 and min(width, height) >= 16
|
if area >= minimum_area and (area >= 4096 or coherent_small_region):
|
cleaned[labels == label] = True
|
return cleaned
|
|
|
def fit_models(
|
reference_features: np.ndarray,
|
threshold_quantile: float,
|
random_state: int,
|
aligned_reference_features: list[np.ndarray] | None = None,
|
aligned_reference_images: list[np.ndarray] | None = None,
|
) -> dict[str, Any]:
|
if reference_features.shape[0] < 50:
|
raise ValueError(
|
f"At least 50 reference tiles are required; found {reference_features.shape[0]}."
|
)
|
if not 0.9 <= threshold_quantile < 1.0:
|
raise ValueError("threshold_quantile must be in [0.9, 1.0).")
|
center = np.median(reference_features, axis=0)
|
mad = np.median(np.abs(reference_features - center), axis=0)
|
fallback = reference_features.std(axis=0)
|
scale = np.where(mad > 1e-9, 1.4826 * mad, np.where(fallback > 1e-9, fallback, 1.0))
|
reference_z = np.abs((reference_features - center) / scale)
|
feature_cutoffs = np.quantile(reference_z, threshold_quantile, axis=0)
|
feature_cutoffs = np.maximum(feature_cutoffs, 1.0)
|
rule_scores, _ = robust_rule_scores(reference_features, center, scale, feature_cutoffs)
|
scaler = StandardScaler().fit(reference_features)
|
standardized = scaler.transform(reference_features)
|
isolation = IsolationForest(
|
n_estimators=200,
|
contamination="auto",
|
random_state=random_state,
|
n_jobs=-1,
|
).fit(standardized)
|
isolation_scores = -isolation.score_samples(standardized)
|
result = {
|
"rule_mode": "global",
|
"center": center,
|
"scale": scale,
|
"feature_cutoffs": feature_cutoffs,
|
"scaler": scaler,
|
"isolation": isolation,
|
"rule_threshold": float(np.quantile(rule_scores, threshold_quantile)),
|
"isolation_threshold": float(np.quantile(isolation_scores, threshold_quantile)),
|
"reference_rule_scores": rule_scores,
|
"reference_isolation_scores": isolation_scores,
|
}
|
if aligned_reference_features is not None:
|
result.update(fit_aligned_rule_model(aligned_reference_features, threshold_quantile))
|
if aligned_reference_images is not None:
|
result.update(fit_local_change_model(aligned_reference_images, threshold_quantile))
|
return result
|
|
|
def score_features(features: np.ndarray, models: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
if models.get("rule_mode") == "aligned":
|
if features.shape[0] != models["aligned_center"].shape[0]:
|
raise ValueError(
|
"Aligned spatial mode requires target images with the same dimensions as references."
|
)
|
rule_scores, reason_indices = aligned_rule_scores(
|
features, models["aligned_center"], models["aligned_scale"]
|
)
|
else:
|
rule_scores, reason_indices = robust_rule_scores(
|
features, models["center"], models["scale"], models["feature_cutoffs"]
|
)
|
isolation_scores = -models["isolation"].score_samples(models["scaler"].transform(features))
|
return rule_scores, isolation_scores, reason_indices
|
|
|
def scores_to_raster(
|
scores: np.ndarray,
|
windows: list[tuple[int, int, int, int]],
|
shape_: tuple[int, int],
|
) -> np.ndarray:
|
sums = np.zeros(shape_, dtype=np.float32)
|
counts = np.zeros(shape_, dtype=np.uint16)
|
for score, (x0, y0, x1, y1) in zip(scores, windows, strict=True):
|
sums[y0:y1, x0:x1] += float(score)
|
counts[y0:y1, x0:x1] += 1
|
return sums / np.maximum(counts, 1)
|
|
|
def _heatmap(score_map: np.ndarray, threshold: float) -> np.ndarray:
|
scaled = np.clip(score_map / max(threshold, 1e-9), 0.0, 2.0) * 127.5
|
colored_bgr = cv2.applyColorMap(scaled.astype(np.uint8), cv2.COLORMAP_TURBO)
|
return cv2.cvtColor(colored_bgr, cv2.COLOR_BGR2RGB)
|
|
|
def _write_mask_raster(path: Path, mask: np.ndarray, info: RasterInfo) -> None:
|
with rasterio.open(
|
path,
|
"w",
|
driver="GTiff",
|
height=mask.shape[0],
|
width=mask.shape[1],
|
count=1,
|
dtype="uint8",
|
transform=info.transform,
|
crs=info.crs,
|
nodata=0,
|
compress="deflate",
|
) as dst:
|
dst.write(mask, 1)
|
|
|
def _fallback_geometries(binary: np.ndarray, transform: Affine, crs: str | None) -> gpd.GeoDataFrame:
|
records: list[dict[str, Any]] = []
|
for geometry, value in shapes(binary.astype(np.uint8), mask=binary, transform=transform):
|
if value != 1:
|
continue
|
polygon = shape(geometry)
|
if polygon.is_empty:
|
continue
|
records.append({"geometry": polygon})
|
return gpd.GeoDataFrame(records, geometry="geometry", crs=crs)
|
|
|
def _polygon_score_stats(
|
geometry: Any,
|
transform: Affine,
|
shape_: tuple[int, int],
|
rule_map: np.ndarray,
|
isolation_map: np.ndarray,
|
) -> tuple[float, float, int]:
|
inverse = ~transform
|
corners = [
|
inverse * (geometry.bounds[0], geometry.bounds[1]),
|
inverse * (geometry.bounds[0], geometry.bounds[3]),
|
inverse * (geometry.bounds[2], geometry.bounds[1]),
|
inverse * (geometry.bounds[2], geometry.bounds[3]),
|
]
|
xs = [point[0] for point in corners]
|
ys = [point[1] for point in corners]
|
x0 = max(0, int(math.floor(min(xs))))
|
x1 = min(shape_[1], int(math.ceil(max(xs))) + 1)
|
y0 = max(0, int(math.floor(min(ys))))
|
y1 = min(shape_[0], int(math.ceil(max(ys))) + 1)
|
if x1 <= x0 or y1 <= y0:
|
return 0.0, 0.0, 0
|
local_transform = transform * Affine.translation(x0, y0)
|
inside = geometry_mask(
|
[mapping(geometry)],
|
out_shape=(y1 - y0, x1 - x0),
|
transform=local_transform,
|
invert=True,
|
)
|
if not inside.any():
|
return 0.0, 0.0, 0
|
return (
|
float(rule_map[y0:y1, x0:x1][inside].max()),
|
float(isolation_map[y0:y1, x0:x1][inside].max()),
|
int(inside.sum()),
|
)
|
|
|
def vectorize_candidates(
|
mask: np.ndarray,
|
rule_map: np.ndarray,
|
isolation_map: np.ndarray,
|
transform: Affine,
|
crs: str | None,
|
tile_rows: list[dict[str, Any]],
|
output_path: Path,
|
minimum_area: int,
|
source_file: str,
|
) -> tuple[int, str]:
|
frames: list[gpd.GeoDataFrame] = []
|
vectorizers: list[str] = []
|
for code in (1, 2, 3):
|
binary = mask == code
|
if not binary.any():
|
continue
|
reference = _fallback_geometries(binary, transform, crs)
|
temporary = output_path.with_name(f".{output_path.stem}-{code}.tif")
|
_write_mask_raster(
|
temporary,
|
np.where(binary, 255, 0).astype(np.uint8),
|
RasterInfo(transform, crs, bool(crs and transform != Affine.identity()), [1], "binary"),
|
)
|
selected = reference
|
source = "rasterio.features.shapes fallback"
|
try:
|
from geoai import masks_to_vector
|
|
candidate = masks_to_vector(
|
str(temporary), min_object_area=minimum_area, simplify_tolerance=2.0
|
)
|
pixel_area = abs(transform.a * transform.e - transform.b * transform.d) or 1.0
|
reference = reference[
|
reference.geometry.area / pixel_area >= minimum_area
|
].copy()
|
reference_area = float(reference.geometry.area.sum())
|
candidate_area = float(candidate.geometry.area.sum()) if not candidate.empty else 0.0
|
ratio = candidate_area / reference_area if reference_area else 0.0
|
if len(candidate) == len(reference) and (not reference_area or 0.9 <= ratio <= 1.1):
|
selected = candidate[["geometry"]].copy()
|
source = "geoai.masks_to_vector"
|
else:
|
selected = reference
|
source = "geoai.masks_to_vector + rasterio completeness repair"
|
except Exception:
|
pixel_area = abs(transform.a * transform.e - transform.b * transform.d) or 1.0
|
selected = reference[reference.geometry.area / pixel_area >= minimum_area].copy()
|
finally:
|
temporary.unlink(missing_ok=True)
|
vectorizers.append(source)
|
rows: list[dict[str, Any]] = []
|
for _, record in selected.iterrows():
|
geometry = record.geometry
|
max_rule, max_isolation, area_pixels = _polygon_score_stats(
|
geometry, transform, mask.shape, rule_map, isolation_map
|
)
|
reason = ""
|
best_rule = -math.inf
|
for tile in tile_rows:
|
corners = [
|
transform * (tile["x0"], tile["y0"]),
|
transform * (tile["x1"], tile["y0"]),
|
transform * (tile["x1"], tile["y1"]),
|
transform * (tile["x0"], tile["y1"]),
|
]
|
tile_geometry = Polygon(corners)
|
if geometry.intersects(tile_geometry) and tile["rule_score"] > best_rule:
|
best_rule = float(tile["rule_score"])
|
reason = str(tile["reason_feature"])
|
rows.append(
|
{
|
"geometry": geometry,
|
"source_file": source_file,
|
"mask_code": code,
|
"method": MASK_LABELS[code],
|
"rule_flag": code in (1, 3),
|
"isolation_flag": code in (2, 3),
|
"agreement": code == 3,
|
"max_rule_score": round(max_rule, 6),
|
"max_isolation_score": round(max_isolation, 6),
|
"reason_feature": reason,
|
"area_pixels": area_pixels,
|
}
|
)
|
if rows:
|
frames.append(gpd.GeoDataFrame(rows, geometry="geometry", crs=crs))
|
if frames:
|
merged = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), geometry="geometry", crs=crs)
|
merged.insert(0, "feature_id", np.arange(1, len(merged) + 1))
|
else:
|
merged = gpd.GeoDataFrame(
|
{
|
"feature_id": pd.Series(dtype="int64"),
|
"mask_code": pd.Series(dtype="int64"),
|
"method": pd.Series(dtype="str"),
|
"geometry": gpd.GeoSeries([], crs=crs),
|
},
|
geometry="geometry",
|
crs=crs,
|
)
|
merged.to_file(output_path, driver="GeoJSON")
|
unique_vectorizers = set(vectorizers)
|
if "geoai.masks_to_vector + rasterio completeness repair" in unique_vectorizers:
|
vectorizer = "geoai.masks_to_vector + rasterio completeness repair"
|
elif "geoai.masks_to_vector" in unique_vectorizers:
|
vectorizer = "geoai.masks_to_vector"
|
elif unique_vectorizers:
|
vectorizer = "rasterio.features.shapes fallback"
|
else:
|
vectorizer = "no candidates"
|
return len(merged), vectorizer
|
|
|
def _truth_entry(manifest: dict[str, Any] | None, filename: str) -> dict[str, Any] | None:
|
if not manifest:
|
return None
|
images = manifest.get("images", {})
|
entry = images.get(filename)
|
return entry if isinstance(entry, dict) else None
|
|
|
def _binary_metrics(predicted: np.ndarray, truth: np.ndarray) -> dict[str, float | int]:
|
predicted = predicted.astype(bool)
|
truth = truth.astype(bool)
|
true_positive = int(np.count_nonzero(predicted & truth))
|
false_positive = int(np.count_nonzero(predicted & ~truth))
|
false_negative = int(np.count_nonzero(~predicted & truth))
|
precision = true_positive / (true_positive + false_positive) if true_positive + false_positive else 0.0
|
recall = true_positive / (true_positive + false_negative) if true_positive + false_negative else 0.0
|
union = true_positive + false_positive + false_negative
|
return {
|
"true_positive_pixels": true_positive,
|
"false_positive_pixels": false_positive,
|
"false_negative_pixels": false_negative,
|
"precision": round(precision, 6),
|
"recall": round(recall, 6),
|
"iou": round(true_positive / union if union else 0.0, 6),
|
}
|
|
|
def evaluate_truth(
|
mask: np.ndarray,
|
entry: dict[str, Any],
|
manifest_dir: Path,
|
expected_shape: tuple[int, int],
|
) -> dict[str, Any]:
|
truth_path = (manifest_dir / str(entry["mask"])).resolve()
|
with Image.open(truth_path) as image:
|
truth = np.asarray(image.convert("L")) > 0
|
if truth.shape != expected_shape:
|
raise ValueError(f"Truth mask shape {truth.shape} does not match image {expected_shape}.")
|
methods = {
|
"rule": np.isin(mask, [1, 3]),
|
"isolation": np.isin(mask, [2, 3]),
|
"agreement": mask == 3,
|
}
|
result: dict[str, Any] = {name: _binary_metrics(values, truth) for name, values in methods.items()}
|
region_results: dict[str, list[dict[str, Any]]] = {}
|
for name, values in methods.items():
|
regions: list[dict[str, Any]] = []
|
for region in entry.get("regions", []):
|
x0, y0, x1, y1 = map(int, region["bbox"])
|
region_truth = truth[y0:y1, x0:x1]
|
detected = values[y0:y1, x0:x1]
|
overlap = int(np.count_nonzero(region_truth & detected))
|
truth_pixels = int(np.count_nonzero(region_truth))
|
regions.append(
|
{
|
"name": region["name"],
|
"overlap_pixels": overlap,
|
"truth_pixels": truth_pixels,
|
"overlap_ratio": round(overlap / truth_pixels if truth_pixels else 0.0, 6),
|
"hit": overlap > 0,
|
}
|
)
|
region_results[name] = regions
|
result["regions"] = region_results
|
return result
|
|
|
def process_image(
|
input_path: Path,
|
output_dir: Path,
|
models: dict[str, Any],
|
tile_size: int,
|
stride: int,
|
truth_manifest: dict[str, Any] | None,
|
truth_manifest_dir: Path | None,
|
) -> dict[str, Any]:
|
started = time.perf_counter()
|
rgb, raster_info = read_rgb(input_path)
|
features, windows = image_features(rgb, tile_size, stride)
|
rule_scores, isolation_scores, reason_indices = score_features(features, models)
|
coarse_rule_map = scores_to_raster(rule_scores, windows, rgb.shape[:2])
|
isolation_map = scores_to_raster(isolation_scores, windows, rgb.shape[:2])
|
rule_map = coarse_rule_map
|
local_score_map: np.ndarray | None = None
|
local_reason_map: np.ndarray | None = None
|
local_flag = np.zeros(rgb.shape[:2], dtype=bool)
|
if models.get("local_change_enabled"):
|
local_score_map, local_reason_map = local_change_scores(rgb, models)
|
local_flag = clean_local_change_flag(local_score_map)
|
local_equivalent = local_score_map * models["rule_threshold"]
|
rule_map = np.maximum(coarse_rule_map, local_equivalent)
|
rule_flag = (coarse_rule_map > models["rule_threshold"]) | local_flag
|
isolation_flag = isolation_map > models["isolation_threshold"]
|
encoded = rule_flag.astype(np.uint8) + isolation_flag.astype(np.uint8) * 2
|
|
stem = input_path.stem
|
rule_heatmap_path = output_dir / f"{stem}.rule.heatmap.png"
|
isolation_heatmap_path = output_dir / f"{stem}.isolation.heatmap.png"
|
overlay_path = output_dir / f"{stem}.comparison.overlay.png"
|
mask_path = output_dir / f"{stem}.anomaly-mask.tif"
|
vector_path = output_dir / f"{stem}.anomalies.geojson"
|
csv_path = output_dir / f"{stem}.tiles.csv"
|
|
Image.fromarray(_heatmap(rule_map, models["rule_threshold"])).save(rule_heatmap_path)
|
Image.fromarray(_heatmap(isolation_map, models["isolation_threshold"])).save(
|
isolation_heatmap_path
|
)
|
color_mask = np.zeros_like(rgb)
|
for code, color in MASK_COLORS.items():
|
color_mask[encoded == code] = color
|
overlay = rgb.copy()
|
candidates = encoded > 0
|
overlay[candidates] = (
|
rgb[candidates].astype(np.float32) * 0.42
|
+ color_mask[candidates].astype(np.float32) * 0.58
|
).clip(0, 255).astype(np.uint8)
|
Image.fromarray(overlay).save(overlay_path, quality=92)
|
_write_mask_raster(mask_path, encoded, raster_info)
|
|
tile_rows: list[dict[str, Any]] = []
|
for index, ((x0, y0, x1, y1), feature_values) in enumerate(
|
zip(windows, features, strict=True)
|
):
|
output_rule_score = float(rule_scores[index])
|
output_reason = FEATURE_NAMES[int(reason_indices[index])]
|
local_tile_score = 0.0
|
if local_score_map is not None and local_reason_map is not None:
|
local_values = local_score_map[y0:y1, x0:x1]
|
local_tile_score = float(local_values.max())
|
local_equivalent = local_tile_score * models["rule_threshold"]
|
if local_equivalent > output_rule_score:
|
output_rule_score = local_equivalent
|
maximum_position = np.unravel_index(int(local_values.argmax()), local_values.shape)
|
output_reason = (
|
"local_structure_change"
|
if local_reason_map[y0:y1, x0:x1][maximum_position] == 1
|
else "local_appearance_change"
|
)
|
row: dict[str, Any] = {
|
"tile_id": index + 1,
|
"x0": x0,
|
"y0": y0,
|
"x1": x1,
|
"y1": y1,
|
"rule_score": output_rule_score,
|
"local_change_score": local_tile_score,
|
"isolation_score": float(isolation_scores[index]),
|
"rule_anomaly": bool(rule_flag[y0:y1, x0:x1].any()),
|
"isolation_anomaly": bool(isolation_scores[index] > models["isolation_threshold"]),
|
"reason_feature": output_reason,
|
}
|
row.update({name: float(value) for name, value in zip(FEATURE_NAMES, feature_values, strict=True)})
|
tile_rows.append(row)
|
pd.DataFrame(tile_rows).to_csv(csv_path, index=False, encoding="utf-8-sig")
|
candidate_count, vectorizer = vectorize_candidates(
|
encoded,
|
rule_map,
|
isolation_map,
|
raster_info.transform,
|
raster_info.crs,
|
tile_rows,
|
vector_path,
|
minimum_area=512 if models.get("local_change_enabled") else max(64, tile_size * tile_size // 16),
|
source_file=input_path.name,
|
)
|
|
truth_metrics = None
|
entry = _truth_entry(truth_manifest, input_path.name)
|
if entry and truth_manifest_dir:
|
truth_metrics = evaluate_truth(encoded, entry, truth_manifest_dir, rgb.shape[:2])
|
pixel_count = int(encoded.size)
|
return {
|
"file": input_path.name,
|
"width": int(rgb.shape[1]),
|
"height": int(rgb.shape[0]),
|
"tile_count": len(windows),
|
"rule_heatmap_file": rule_heatmap_path.name,
|
"isolation_heatmap_file": isolation_heatmap_path.name,
|
"overlay_file": overlay_path.name,
|
"mask_file": mask_path.name,
|
"vector_file": vector_path.name,
|
"tiles_file": csv_path.name,
|
"candidate_count": candidate_count,
|
"rule_anomaly_pixels": int(np.count_nonzero(rule_flag)),
|
"local_change_pixels": int(np.count_nonzero(local_flag)),
|
"isolation_anomaly_pixels": int(np.count_nonzero(isolation_flag)),
|
"agreement_pixels": int(np.count_nonzero(encoded == 3)),
|
"rule_anomaly_coverage": round(float(np.count_nonzero(rule_flag) / pixel_count), 6),
|
"local_change_coverage": round(float(np.count_nonzero(local_flag) / pixel_count), 6),
|
"isolation_anomaly_coverage": round(
|
float(np.count_nonzero(isolation_flag) / pixel_count), 6
|
),
|
"agreement_coverage": round(float(np.count_nonzero(encoded == 3) / pixel_count), 6),
|
"georeferenced": raster_info.georeferenced,
|
"crs": raster_info.crs,
|
"source_bands": raster_info.source_bands,
|
"normalization": raster_info.normalization,
|
"coordinate_basis": "map_coordinates" if raster_info.georeferenced else "pixel_coordinates",
|
"vectorizer": vectorizer,
|
"truth_metrics": truth_metrics,
|
"elapsed_seconds": round(time.perf_counter() - started, 3),
|
}
|
|
|
def collect_inputs(path: Path) -> list[Path]:
|
if path.is_file():
|
candidates: Iterable[Path] = [path]
|
elif path.is_dir():
|
candidates = sorted(item for item in path.iterdir() if item.is_file())
|
else:
|
raise ValueError(f"Input path does not exist: {path}")
|
inputs = [item for item in candidates if item.suffix.lower() in SUPPORTED_SUFFIXES]
|
if not inputs:
|
raise ValueError(f"No JPG, PNG, or GeoTIFF inputs were found in: {path}")
|
return inputs
|
|
|
def load_truth_manifest(path: Path | None) -> tuple[dict[str, Any] | None, Path | None]:
|
if path is None:
|
return None, None
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
if not isinstance(payload.get("images"), dict):
|
raise ValueError("Truth manifest must contain an 'images' object.")
|
return payload, path.parent
|
|
|
def run(args: argparse.Namespace) -> dict[str, Any]:
|
if args.output.exists() and any(args.output.iterdir()):
|
raise ValueError(f"Output directory is not empty: {args.output}. Use a new run directory.")
|
args.output.mkdir(parents=True, exist_ok=True)
|
reference_paths = collect_inputs(args.reference)
|
input_paths = collect_inputs(args.input)
|
started = time.perf_counter()
|
reference_feature_sets: list[np.ndarray] = []
|
reference_rgbs: list[np.ndarray] = []
|
reference_images: list[dict[str, Any]] = []
|
reference_shapes: list[tuple[int, int]] = []
|
for path in reference_paths:
|
rgb, _ = read_rgb(path)
|
features, windows = image_features(rgb, args.tile_size, args.stride)
|
reference_feature_sets.append(features)
|
reference_rgbs.append(rgb)
|
reference_shapes.append(rgb.shape[:2])
|
reference_images.append(
|
{
|
"file": path.name,
|
"tile_count": len(windows),
|
"width": rgb.shape[1],
|
"height": rgb.shape[0],
|
}
|
)
|
reference_features = np.vstack(reference_feature_sets)
|
requested_spatial_mode = getattr(args, "spatial_mode", "auto")
|
if requested_spatial_mode not in {"auto", "global", "aligned"}:
|
raise ValueError("spatial_mode must be auto, global, or aligned.")
|
input_shapes = [read_rgb(path)[0].shape[:2] for path in input_paths]
|
aligned_compatible = (
|
len(reference_paths) >= 3
|
and len(set(reference_shapes)) == 1
|
and all(shape_ == reference_shapes[0] for shape_ in input_shapes)
|
)
|
if requested_spatial_mode == "aligned" and not aligned_compatible:
|
raise ValueError(
|
"Aligned spatial mode requires at least three references and all reference/target images "
|
"to have identical dimensions."
|
)
|
selected_rule_mode = (
|
"aligned"
|
if requested_spatial_mode == "aligned"
|
or (requested_spatial_mode == "auto" and aligned_compatible)
|
else "global"
|
)
|
models = fit_models(
|
reference_features,
|
args.threshold_quantile,
|
args.random_state,
|
reference_feature_sets if selected_rule_mode == "aligned" else None,
|
reference_rgbs if selected_rule_mode == "aligned" else None,
|
)
|
truth_manifest, truth_manifest_dir = load_truth_manifest(args.truth_manifest)
|
images = [
|
process_image(
|
path,
|
args.output,
|
models,
|
args.tile_size,
|
args.stride,
|
truth_manifest,
|
truth_manifest_dir,
|
)
|
for path in input_paths
|
]
|
metadata = {
|
"capability": "09-anomaly-detection",
|
"classification": "B",
|
"created_at": datetime.now(UTC).isoformat(),
|
"versions": {
|
"python": __import__("platform").python_version(),
|
"geoai-py": importlib_metadata.version("geoai-py"),
|
"scikit-learn": importlib_metadata.version("scikit-learn"),
|
"rasterio": importlib_metadata.version("rasterio"),
|
"opencv-python-headless": importlib_metadata.version("opencv-python-headless"),
|
},
|
"method": (
|
"position-aligned robust feature and generic local-change rules compared with global Isolation Forest"
|
if models["rule_mode"] == "aligned"
|
else "global robust feature rules compared with Isolation Forest"
|
),
|
"model": "IsolationForest(n_estimators=200, contamination='auto')",
|
"device": "CPU",
|
"reference_path": args.reference.as_posix(),
|
"input_path": args.input.as_posix(),
|
"reference_count": len(reference_paths),
|
"reference_tile_count": int(reference_features.shape[0]),
|
"input_count": len(input_paths),
|
"feature_names": FEATURE_NAMES,
|
"parameters": {
|
"tile_size": args.tile_size,
|
"stride": args.stride,
|
"threshold_quantile": args.threshold_quantile,
|
"random_state": args.random_state,
|
"minimum_reference_tiles": 50,
|
"spatial_mode_requested": requested_spatial_mode,
|
"rule_mode_selected": models["rule_mode"],
|
},
|
"thresholds": {
|
"rule_score": round(float(models["rule_threshold"]), 8),
|
"isolation_score": round(float(models["isolation_threshold"]), 8),
|
"local_appearance_normalized": 1.0 if models.get("local_change_enabled") else None,
|
"local_structure_normalized": 1.0 if models.get("local_change_enabled") else None,
|
},
|
"local_change": {
|
"enabled": bool(models.get("local_change_enabled")),
|
"method": "nearest normal-reference distance in robust Lab appearance and grayscale structure profiles",
|
"processing_shape": list(models["local_processing_shape"])
|
if models.get("local_change_enabled")
|
else None,
|
"appearance_threshold": round(float(models["local_appearance_threshold"]), 8)
|
if models.get("local_change_enabled")
|
else None,
|
"structure_threshold": round(float(models["local_structure_threshold"]), 8)
|
if models.get("local_change_enabled")
|
else None,
|
"minimum_component_pixels": 512,
|
"small_component_minimum_fill_ratio": 0.1,
|
"class_or_colour_rules": False,
|
},
|
"reference_images": reference_images,
|
"images": images,
|
"truth_manifest": args.truth_manifest.as_posix() if args.truth_manifest else None,
|
"elapsed_seconds": round(time.perf_counter() - started, 3),
|
"limitations": [
|
"异常表示相对正常参考影像的统计离群,只是人工复核候选,不是堵塞、损坏、渗漏或告警结论。",
|
"受控注入异常只能验证检测链路,不能代表真实矿山场景精度。",
|
"自然材质稀有区域、视角、光照、季节和清晰度变化都可能产生误报。",
|
"同位置规则模式要求固定机位和相同像素尺寸;auto 条件不满足时自动回退到全局规则。",
|
"局部变化通道不使用颜色或物品类别规则;显著机位偏移、移动阴影和生成式影像细节漂移仍可能造成误报。",
|
"普通 JPG/PNG 使用像素坐标;只有含有效 CRS 和仿射变换的 GeoTIFF 才保留地图坐标。",
|
],
|
}
|
(args.output / "run_metadata.json").write_text(
|
json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
|
)
|
return metadata
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
parser = argparse.ArgumentParser(description="Run the CPU image-region anomaly baseline.")
|
parser.add_argument("--reference", type=Path, required=True, help="Normal reference image or directory.")
|
parser.add_argument("--input", type=Path, required=True, help="Target image or directory.")
|
parser.add_argument("--output", type=Path, required=True, help="A new, empty output directory.")
|
parser.add_argument("--tile-size", type=int, default=256)
|
parser.add_argument("--stride", type=int, default=128)
|
parser.add_argument("--threshold-quantile", type=float, default=0.995)
|
parser.add_argument("--random-state", type=int, default=42)
|
parser.add_argument(
|
"--spatial-mode",
|
choices=("auto", "global", "aligned"),
|
default="auto",
|
help="auto uses same-position rules for 3+ aligned references, otherwise global rules.",
|
)
|
parser.add_argument("--truth-manifest", type=Path)
|
return parser
|
|
|
def main() -> int:
|
args = build_parser().parse_args()
|
try:
|
metadata = run(args)
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
print(f"ERROR: {error}", file=__import__("sys").stderr)
|
return 2
|
print(json.dumps(metadata, ensure_ascii=False, indent=2))
|
return 0
|
|
|
if __name__ == "__main__":
|
raise SystemExit(main())
|