罗广辉
3 hours ago 1e692412abeba21aad19879daca4f39e8527fa34
feat: 异常检测优化1
7 files modified
245 ■■■■■ changed files
PROJECT_CONTEXT.md 3 ●●●● patch | view | raw | blame | history
apps/workbench-console/README.md 2 ●●● patch | view | raw | blame | history
apps/workbench-console/src/api/artifacts.ts 2 ●●● patch | view | raw | blame | history
apps/workbench-console/src/components/AnomalyDetectionPanel.vue 3 ●●●● patch | view | raw | blame | history
capabilities/09-anomaly-detection/README.md 13 ●●●●● patch | view | raw | blame | history
capabilities/09-anomaly-detection/run_anomaly_detection.py 198 ●●●●● patch | view | raw | blame | history
capabilities/09-anomaly-detection/tests/test_anomaly_detection.py 24 ●●●●● patch | view | raw | blame | history
PROJECT_CONTEXT.md
@@ -149,7 +149,7 @@
## Anomaly Detection Snapshot
- Script: `capabilities/09-anomaly-detection/run_anomaly_detection.py`; reproducible input generator: `generate_validation_inputs.py` in the same directory.
- Boundary: B. Seventeen interpretable RGB/HSV/texture features feed a robust MAD rule baseline and a 200-tree Isolation Forest; fixed-view inputs can use corresponding-location robust rules after image-wide appearance normalization. `geoai-py 0.42.0` only supplies `masks_to_vector`. There are no model weights.
- Boundary: B. Seventeen interpretable RGB/HSV/texture features feed a robust MAD rule baseline and a 200-tree Isolation Forest; fixed-view inputs additionally use class-agnostic corresponding-location Lab appearance and grayscale structure differences after image-wide normalization. `geoai-py 0.42.0` only supplies `masks_to_vector`. There are no model weights or colour/object-specific rules.
- Contract: image or directory input; minimum 50 reference windows; outputs are two heatmaps, comparison overlay, encoded mask GeoTIFF, candidate GeoJSON, tile CSV and run metadata. Ordinary images use pixel coordinates; valid GeoTIFF CRS/transform is preserved.
- Validation: controlled color, blur and dark anomalies were injected into a copy of `07-16.jpg`; `07-19.jpg` was used unchanged as a difficult real sample. Source-copy SHA256 values match the originals.
- Measured result: 588 reference windows and three inputs completed in 41.051 seconds CPU. The rule method hit 3/3 controlled regions with precision 0.583, recall 0.672 and IoU 0.454; Isolation Forest hit 2/3 with precision 0.831, recall 0.500 and IoU 0.454. Normal-image coverage was 0.33% and 0.00%, respectively. Every image completed within 15 seconds.
@@ -157,6 +157,7 @@
- Console: `GET/POST /api/anomaly-detection/runs`, binary `PUT /api/anomaly-detection/uploads/<upload-id>` and asynchronous `GET /api/anomaly-detection/jobs/<job-id>`. A real one-reference/one-input upload produced run `anomaly-20260818-102845-61f55b` with six review candidates. Desktop and 390 px layouts were visually checked; tables use internal horizontal scrolling and the collapsed menu trigger remains inside the viewport.
- Synthetic fixed-view validation: five ImageGen normal references cover overcast, sunny, after-rain, morning-haze and late-afternoon conditions; one test image adds a localized branch/leaf/mud/rock blockage. The original pooled-feature run returned zero candidates and is retained as `ImageGen 五参考图堵塞复核(漏检案例)`.
- Fixed-view optimization: `--spatial-mode auto` now selects same-position robust rules when 3+ references and all target images share dimensions, after normalizing each image's global feature profile; otherwise it falls back to the original global rule. At the unchanged 0.995 quantile, the synthetic blockage produced one correctly located rule candidate with 9.375% coverage. Five leave-one-normal-out checks produced 7.292%, 0%, 0%, 0%, and 0% coverage. The successful output is retained as `ImageGen 五参考图堵塞复核(同位置优化)`; Isolation Forest still missed this sample.
- Generic local-change enhancement: same-position mode now also calibrates nearest-normal-reference Lab appearance and grayscale intensity/edge/contrast distances. A previously missed small added pattern became one 9,467-pixel rule candidate with 100% coverage of its 6,710 validation pixels and 0.602% full-image coverage; it is retained as `通用局部异常增强(小目标漏检修复)`. The blockage regression remains one candidate at 9.594% rule coverage. Five leave-one-normal-out combined coverages are 7.292%, 0%, 0%, 0.460%, and 1.209%. No sample colour, object class, or fixed coordinate is encoded.
- Next decision: add image registration and/or a channel ROI or specialist blockage detector before accepting camera motion or claiming blockage semantics. Obtain real normal/labelled anomaly pairs; the synthetic result is only a pipeline check.
## Change Detection Snapshot
apps/workbench-console/README.md
@@ -69,6 +69,6 @@
- 语义分割上传最多 6 张 `JPG/JPEG/PNG/TIF/TIFF`,任务预设从能力目录动态读取;当前只有通用颜色流程可运行,排洪沟、边坡、竖井和尾矿库任务在模型与真值齐备前保持禁用。结果展示原图、栅格叠加、GeoJSON 矢量预览、类别比例和可下载工件。
- 空间测量上传最多 4 份 `PNG/TIF/TIFF` 单波段标签栅格,展示彩色栅格、GeoAI 矢量对象、计数、面积、周长和对象明细;只有有效投影 CRS 才使用地图单位。
- 变化检测上传一对 `JPG/JPEG/PNG/TIF/TIFF`,每次运行可选择自动识别、普通图片或 GeoTIFF 地理参考模式;阈值支持 `0.01~0.99`(默认 `0.5`),分辨率支持 GeoTIFF 原始分辨率/自动以及 `1024/1536/2048/3072` 档位。GeoTIFF 模式保留 CRS 和地图坐标,普通图片模式输出像素坐标。单文件最大 `1024 MB`,单次 Base64 JSON 请求最大 `3072 MB`。
- 异常检测分别上传 1~6 张正常参考影像和 1~6 张待检测影像,支持 `JPG/JPEG/PNG/TIF/TIFF`。3 张以上固定机位且全部尺寸一致时自动启用同位置规则,否则回退为全局规则;结果页显示实际选择的模式。大文件使用二进制流,后台任务完成后进入案例库;结果展示参考输入、待检测原图、规则/Isolation Forest 对比、热力图、候选区字段与下载工件。
- 异常检测分别上传 1~6 张正常参考影像和 1~6 张待检测影像,支持 `JPG/JPEG/PNG/TIF/TIFF`。3 张以上固定机位且全部尺寸一致时自动启用同位置规则和通用局部外观/结构变化通道,否则回退为全局规则;局部通道不使用颜色或物品类别规则。结果页显示实际选择的模式。大文件使用二进制流,后台任务完成后进入案例库;结果展示参考输入、待检测原图、规则/Isolation Forest 对比、热力图、候选区字段与下载工件。
- 本地 API 还包括 `GET/POST /api/change-detection/runs` 和 `PUT /api/change-detection/uploads/<upload-id>`;变化检测前端使用二进制流上传,服务端按原字节暂存并复制到独立 raw、processed 和 output 目录,不修改原始 TIFF,也不覆盖既有运行。
- 本地 API 为 `GET/POST /api/trajectory/runs`、`GET/POST /api/object-detection/runs`、`GET/POST /api/semantic-mapping/runs`、`GET/POST /api/spatial-measurement/runs`、`GET/POST /api/anomaly-detection/runs` 和只读的 `GET /api/semantic-mapping/tasks`;异常检测另有二进制上传和后台任务状态端点。服务仅监听回环地址,端口必须是 `6xxx`,只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。
apps/workbench-console/src/api/artifacts.ts
@@ -10,7 +10,7 @@
export interface MeasurementRun { capability: string; classification: string; created_at?: string; geoai_version: string; method: string; model: string; device: string; input_count: number; processed_images: number; elapsed_seconds: number; images: MeasurementImage[]; limitations: string[]; input_dir?: string; raw_input_dir?: string; }
export interface MeasurementCase { id: string; label: string; note: string; artifactRoot: string; createdAt: string; run: MeasurementRun; }
export interface AnomalyCandidate { type: "Feature"; properties: { feature_id?: number; mask_code?: number; method?: string; rule_flag?: boolean; isolation_flag?: boolean; agreement?: boolean; max_rule_score?: number; max_isolation_score?: number; reason_feature?: string; area_pixels?: number }; geometry: { type: string; coordinates: unknown }; }
export interface AnomalyImage { file: string; width: number; height: number; tile_count: number; rule_heatmap_file: string; isolation_heatmap_file: string; overlay_file: string; mask_file: string; vector_file: string; tiles_file: string; candidate_count: number; rule_anomaly_pixels: number; isolation_anomaly_pixels: number; agreement_pixels: number; rule_anomaly_coverage: number; isolation_anomaly_coverage: number; agreement_coverage: number; georeferenced: boolean; crs: string | null; coordinate_basis: string; vectorizer: string; elapsed_seconds: number; }
export interface AnomalyImage { file: string; width: number; height: number; tile_count: number; rule_heatmap_file: string; isolation_heatmap_file: string; overlay_file: string; mask_file: string; vector_file: string; tiles_file: string; candidate_count: number; rule_anomaly_pixels: number; local_change_pixels?: number; isolation_anomaly_pixels: number; agreement_pixels: number; rule_anomaly_coverage: number; local_change_coverage?: number; isolation_anomaly_coverage: number; agreement_coverage: number; georeferenced: boolean; crs: string | null; coordinate_basis: string; vectorizer: string; elapsed_seconds: number; }
export interface AnomalyRun { capability: string; classification: string; created_at?: string; versions: Record<string, string>; method: string; model: string; device: string; reference_count: number; reference_tile_count: number; input_count: number; reference_images: Array<{ file: string; tile_count: number }>; feature_names: string[]; parameters: { tile_size: number; stride: number; threshold_quantile: number; random_state: number; spatial_mode_requested?: "auto" | "global" | "aligned"; rule_mode_selected?: "global" | "aligned" }; thresholds: { rule_score: number; isolation_score: number }; images: AnomalyImage[]; elapsed_seconds: number; limitations: string[]; }
export interface AnomalyCase { id: string; label: string; note: string; artifactRoot: string; inputRoot: string; referenceRoot: string; createdAt: string; run: AnomalyRun; candidates: Record<string, AnomalyCandidate[]>; }
export interface ChangeFeature { type: "Feature"; properties: { feature_id?: number; area_pixels?: number; mean_probability?: number; max_probability?: number; bounds_pixel?: number[]; confidence?: number; class?: number }; geometry: { type: string; coordinates: unknown }; }
apps/workbench-console/src/components/AnomalyDetectionPanel.vue
@@ -33,7 +33,8 @@
  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: "亮像素比例"
  gray_entropy: "灰度熵", edge_density: "边缘密度", laplacian_variance: "清晰度", dark_ratio: "暗像素比例", bright_ratio: "亮像素比例",
  local_appearance_change: "局部外观变化", local_structure_change: "局部结构变化"
};
function addFile(target: "reference" | "input", file: File) {
capabilities/09-anomaly-detection/README.md
@@ -21,6 +21,7 @@
每个窗口提取 17 个特征:RGB/HSV 均值与标准差、灰度熵、边缘密度、Laplacian 清晰度以及暗/亮像素比例。
- 规则基线:`auto` 模式下,3 张以上同尺寸固定机位参考图会先消除每张图的整体亮度/色彩漂移,再将测试窗口只与参考图的相同位置比较;条件不满足时回退到全局中位数/MAD。两种模式都记录贡献最大的原因特征。
- 通用局部变化:同位置模式额外学习正常参考图的 Lab 局部外观,以及灰度、边缘和局部对比度结构分布,用参考图之间的差异自动校准阈值。该通道没有颜色、物品类别或固定坐标规则,用于补充被大窗口平均掉的小而连贯的外观/结构变化。
- Isolation Forest:先对参考特征拟合 `StandardScaler`,再训练 `IsolationForest(n_estimators=200, random_state=42)`。
- 两种方法分别用参考分数的默认 `99.5%` 分位数定阈值,不通过固定污染率强制标记正常窗口。
- 重叠窗口分数平均回像素网格,掩膜编码为 `0=正常、1=仅规则、2=仅 Isolation Forest、3=两者一致`。
@@ -146,3 +147,15 @@
同位置模式解决的是固定机位、相同构图中的局部画面变化,不具备“堵塞”语义。明显机位偏移仍会造成误报;真实部署还应增加图像配准、排水沟 ROI 或专门的检测/分割模型,并取得真实标注数据。
## 通用局部异常增强验证(2026-08-19)
针对运行 `anomaly-20260819-061029-583672` 中面积较小的显著新增图案漏检,新增通用局部外观/结构通道。红色图案只作为漏检验证样本,算法没有红色阈值、物品类别或样本坐标规则。
- 原方法在该区域的规则最高分 3.007(阈值 18.931),Isolation Forest 最高分 0.559(阈值 0.583),均未过线。
- 增强后形成一个 9,467 像素的 `local_appearance_change` 候选,覆盖验证区域全部 6,710 个高饱和红色像素;整图局部变化覆盖率 0.602%。原有 Isolation Forest 建筑区域候选保持不变。
- 排水沟堵塞回归仍产生 1 个候选,规则覆盖率由此前 9.375% 变为 9.594%,候选仍覆盖沟内堵塞物;细长配准噪声经通用连通性过滤清除。
- 五张排水沟正常图逐张留出验证的总规则覆盖率为 7.292%、0%、0%、0.460%、1.209%,均不高于 10%。
- 结果分别位于 `validation-20260819-small-local-v5/` 和 `validation-20260819-imagegen-local-v5/`;小目标修复结果登记为控制台案例 `通用局部异常增强(小目标漏检修复)`。
当前局部通道要求固定机位和相同尺寸,最小保留 512 像素且过滤细长稀疏连通区。它仍可能把移动阴影、树叶变化或生成式影像细节漂移标为异常,不代表理解了物体类别或业务原因。
capabilities/09-anomaly-detection/run_anomaly_detection.py
@@ -26,7 +26,7 @@
from PIL import Image
from rasterio.features import geometry_mask, shapes
from rasterio.transform import Affine
from shapely.geometry import Point, box, mapping, shape
from shapely.geometry import Polygon, mapping, shape
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
@@ -225,11 +225,130 @@
    }
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(
@@ -268,6 +387,8 @@
    }
    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
@@ -434,10 +555,14 @@
            reason = ""
            best_rule = -math.inf
            for tile in tile_rows:
                center_x = (tile["x0"] + tile["x1"]) / 2.0
                center_y = (tile["y0"] + tile["y1"]) / 2.0
                coordinate = transform * (center_x, center_y)
                if geometry.covers(Point(coordinate)) and tile["rule_score"] > best_rule:
                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(
@@ -564,9 +689,18 @@
    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)
    rule_map = scores_to_raster(rule_scores, windows, rgb.shape[:2])
    coarse_rule_map = scores_to_raster(rule_scores, windows, rgb.shape[:2])
    isolation_map = scores_to_raster(isolation_scores, windows, rgb.shape[:2])
    rule_flag = rule_map > models["rule_threshold"]
    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
@@ -598,17 +732,33 @@
    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": float(rule_scores[index]),
            "rule_score": output_rule_score,
            "local_change_score": local_tile_score,
            "isolation_score": float(isolation_scores[index]),
            "rule_anomaly": bool(rule_scores[index] > models["rule_threshold"]),
            "rule_anomaly": bool(rule_flag[y0:y1, x0:x1].any()),
            "isolation_anomaly": bool(isolation_scores[index] > models["isolation_threshold"]),
            "reason_feature": FEATURE_NAMES[int(reason_indices[index])],
            "reason_feature": output_reason,
        }
        row.update({name: float(value) for name, value in zip(FEATURE_NAMES, feature_values, strict=True)})
        tile_rows.append(row)
@@ -621,7 +771,7 @@
        raster_info.crs,
        tile_rows,
        vector_path,
        minimum_area=max(64, tile_size * tile_size // 16),
        minimum_area=512 if models.get("local_change_enabled") else max(64, tile_size * tile_size // 16),
        source_file=input_path.name,
    )
@@ -643,9 +793,11 @@
        "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
        ),
@@ -691,12 +843,14 @@
    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(
            {
@@ -732,6 +886,7 @@
        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 = [
@@ -758,7 +913,7 @@
            "opencv-python-headless": importlib_metadata.version("opencv-python-headless"),
        },
        "method": (
            "position-aligned robust feature rules compared with global Isolation Forest"
            "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"
        ),
@@ -782,6 +937,24 @@
        "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,
@@ -792,6 +965,7 @@
            "受控注入异常只能验证检测链路,不能代表真实矿山场景精度。",
            "自然材质稀有区域、视角、光照、季节和清晰度变化都可能产生误报。",
            "同位置规则模式要求固定机位和相同像素尺寸;auto 条件不满足时自动回退到全局规则。",
            "局部变化通道不使用颜色或物品类别规则;显著机位偏移、移动阴影和生成式影像细节漂移仍可能造成误报。",
            "普通 JPG/PNG 使用像素坐标;只有含有效 CRS 和仿射变换的 GeoTIFF 才保留地图坐标。",
        ],
    }
capabilities/09-anomaly-detection/tests/test_anomaly_detection.py
@@ -22,7 +22,10 @@
    axis_positions,
    extract_features,
    fit_aligned_rule_model,
    fit_local_change_model,
    fit_models,
    clean_local_change_flag,
    local_change_scores,
    run,
)
@@ -84,6 +87,21 @@
        self.assertTrue(set(range(25, 29)).issubset(flagged))
        self.assertEqual(FEATURE_NAMES[int(reasons[26])], "dark_ratio")
    def test_local_change_channel_detects_small_unseen_structure_without_class_rules(self):
        base = patterned_image(192, 160)
        references = [
            cv2.convertScaleAbs(base, alpha=alpha, beta=beta)
            for alpha, beta in ((0.9, 5), (1.0, 0), (1.08, 8))
        ]
        target = cv2.convertScaleAbs(base, alpha=1.02, beta=3)
        checker = (np.indices((28, 28)).sum(axis=0) % 2 * 255).astype(np.uint8)
        target[72:100, 104:132] = np.stack([checker, 255 - checker, checker], axis=2)
        model = fit_local_change_model(references, 0.995)
        scores, _ = local_change_scores(target, model)
        detected = clean_local_change_flag(scores)
        self.assertGreater(float(detected[72:100, 104:132].mean()), 0.75)
        self.assertLess(float(detected.mean()), 0.1)
    def test_cli_contract_produces_visual_and_structured_outputs(self):
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
@@ -93,6 +111,12 @@
            inputs.mkdir()
            normal = patterned_image()
            Image.fromarray(normal).save(reference / "normal.png")
            Image.fromarray(cv2.convertScaleAbs(normal, alpha=0.94, beta=4)).save(
                reference / "normal-dim.png"
            )
            Image.fromarray(cv2.convertScaleAbs(normal, alpha=1.05, beta=2)).save(
                reference / "normal-bright.png"
            )
            Image.fromarray(normal).save(inputs / "normal.png")
            anomalous = normal.copy()
            anomalous[48:96, 64:112] = (5, 5, 5)