| """Build a manifest-driven unlabeled MIM dataset from remote-sensing images.""" | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import json | |
| import math | |
| import os | |
| import random | |
| import re | |
| from collections import Counter | |
| from dataclasses import asdict, dataclass | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff"} | |
| class Stats: | |
| scanned_files: int = 0 | |
| accepted_files: int = 0 | |
| rejected_files: int = 0 | |
| accepted_windows: int = 0 | |
| unreadable: int = 0 | |
| too_small: int = 0 | |
| too_black: int = 0 | |
| too_white: int = 0 | |
| low_texture: int = 0 | |
| invalid_shape: int = 0 | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--roots", nargs="+", required=True) | |
| parser.add_argument("--output-root", required=True) | |
| parser.add_argument("--patch-size", type=int, default=512) | |
| parser.add_argument("--stride", type=int, default=512) | |
| parser.add_argument("--max-files-per-root", type=int, default=30000) | |
| parser.add_argument("--max-total-windows", type=int, default=120000) | |
| parser.add_argument("--max-windows-per-image", type=int, default=16) | |
| parser.add_argument("--progress-every", type=int, default=500) | |
| parser.add_argument("--min-valid-ratio", type=float, default=0.70) | |
| parser.add_argument("--max-black-ratio", type=float, default=0.45) | |
| parser.add_argument("--max-white-ratio", type=float, default=0.65) | |
| parser.add_argument("--min-std", type=float, default=4.0) | |
| parser.add_argument("--seed", type=int, default=20260705) | |
| parser.add_argument("--split-train", type=float, default=0.95) | |
| return parser.parse_args() | |
| def infer_satellite(text: str) -> str | None: | |
| upper = text.upper() | |
| for token in ("GF7", "GF6", "GF5", "GF4", "GF3", "GF2", "GF1", "SENTINEL2", "SENTINEL-2", "LANDSAT8", "LANDSAT9"): | |
| if token in upper: | |
| return token.replace("SENTINEL-2", "SENTINEL2") | |
| match = re.search(r"\bS2[AB]?\b", upper) | |
| if match: | |
| return "SENTINEL2" | |
| return None | |
| def infer_sensor(text: str) -> str | None: | |
| upper = text.upper() | |
| for token in ("PMS1", "PMS2", "PMS", "MUX", "PAN", "MSI", "OLI", "SAR", "WFV"): | |
| if token in upper: | |
| return token | |
| return None | |
| def infer_resolution_m(satellite: str | None, sensor: str | None) -> float | None: | |
| if satellite == "GF2" and sensor in {"PMS1", "PMS2", "PMS"}: | |
| return 1.0 | |
| if satellite in {"GF1", "GF6"} and sensor in {"PMS1", "PMS2", "PMS"}: | |
| return 2.0 | |
| if satellite == "SENTINEL2" or sensor == "MSI": | |
| return 10.0 | |
| return None | |
| def fusion_state(path: Path) -> str: | |
| text = str(path).lower() | |
| if "fuse" in text or "融合" in text: | |
| return "fused_product" | |
| if "pan" in text and ("mss" in text or "mux" in text): | |
| return "runtime_fusion_candidate" | |
| return "unknown" | |
| def iter_image_files(root: Path, limit: int): | |
| yielded = 0 | |
| for dirpath, dirnames, filenames in os.walk(root): | |
| dirnames.sort() | |
| filenames.sort() | |
| for filename in filenames: | |
| path = Path(dirpath) / filename | |
| if path.suffix.lower() not in IMAGE_SUFFIXES: | |
| continue | |
| yield path | |
| yielded += 1 | |
| if yielded >= limit: | |
| return | |
| def read_preview(path: Path) -> tuple[np.ndarray | None, tuple[int, int, int] | None, str]: | |
| try: | |
| import cv2 | |
| arr = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) | |
| if arr is not None: | |
| if arr.ndim == 2: | |
| arr = arr[:, :, None] | |
| elif arr.ndim == 3 and arr.shape[2] >= 3: | |
| arr = arr[:, :, :3] | |
| return arr, normalize_shape(arr), "cv2" | |
| except Exception: | |
| pass | |
| if path.suffix.lower() in {".tif", ".tiff"}: | |
| try: | |
| import tifffile | |
| arr = tifffile.imread(str(path)) | |
| if arr.ndim == 2: | |
| arr = arr[:, :, None] | |
| elif arr.ndim == 3 and arr.shape[0] <= 16 and arr.shape[1] > 32 and arr.shape[2] > 32: | |
| arr = np.moveaxis(arr, 0, -1) | |
| if arr.ndim == 3: | |
| arr = arr[:, :, : min(arr.shape[2], 3)] | |
| return arr, normalize_shape(arr), "tifffile" | |
| except Exception: | |
| pass | |
| try: | |
| from PIL import Image | |
| img = Image.open(path) | |
| arr = np.asarray(img.convert("RGB")) | |
| return arr, normalize_shape(arr), "pil" | |
| except Exception: | |
| return None, None, "unreadable" | |
| def normalize_shape(arr: np.ndarray) -> tuple[int, int, int] | None: | |
| if arr.ndim != 3: | |
| return None | |
| h, w, c = arr.shape | |
| if h <= 0 or w <= 0 or c <= 0: | |
| return None | |
| return int(h), int(w), int(c) | |
| def to_uint8_preview(arr: np.ndarray) -> np.ndarray: | |
| arr = arr.astype(np.float32) | |
| out = np.zeros_like(arr, dtype=np.uint8) | |
| for c in range(arr.shape[2]): | |
| band = arr[:, :, c] | |
| lo, hi = np.percentile(band, [2, 98]) | |
| if hi <= lo: | |
| out[:, :, c] = 0 | |
| else: | |
| out[:, :, c] = np.clip((band - lo) * 255.0 / (hi - lo), 0, 255).astype(np.uint8) | |
| return out | |
| def quality(arr: np.ndarray, max_side: int = 256) -> dict[str, float]: | |
| h, w = arr.shape[:2] | |
| step_y = max(1, math.ceil(h / max_side)) | |
| step_x = max(1, math.ceil(w / max_side)) | |
| sample = arr[::step_y, ::step_x] | |
| sample8 = to_uint8_preview(sample) | |
| gray = sample8.mean(axis=2) | |
| black = float((gray <= 3).mean()) | |
| white = float((gray >= 252).mean()) | |
| valid = float(((gray > 3) & (gray < 252)).mean()) | |
| std = float(gray.std()) | |
| return { | |
| "black_ratio": black, | |
| "white_ratio": white, | |
| "valid_ratio": valid, | |
| "std": std, | |
| } | |
| def make_windows(width: int, height: int, patch: int, stride: int, max_windows: int, rng: random.Random) -> list[dict[str, int]]: | |
| if width < patch or height < patch: | |
| return [{"x": 0, "y": 0, "width": width, "height": height}] | |
| xs = list(range(0, max(width - patch + 1, 1), stride)) | |
| ys = list(range(0, max(height - patch + 1, 1), stride)) | |
| if xs[-1] != width - patch: | |
| xs.append(width - patch) | |
| if ys[-1] != height - patch: | |
| ys.append(height - patch) | |
| windows = [{"x": x, "y": y, "width": patch, "height": patch} for y in ys for x in xs] | |
| if len(windows) > max_windows: | |
| windows = rng.sample(windows, max_windows) | |
| windows.sort(key=lambda item: (item["y"], item["x"])) | |
| return windows | |
| def reject(path: Path, reason: str, rows: list[dict[str, Any]], extra: dict[str, Any] | None = None) -> None: | |
| row = {"path": str(path), "reason": reason} | |
| if extra: | |
| row.update(extra) | |
| rows.append(row) | |
| def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text("\n".join(json.dumps(row, ensure_ascii=False) for row in rows) + ("\n" if rows else ""), encoding="utf-8") | |
| def append_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: | |
| if not rows: | |
| return | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with path.open("a", encoding="utf-8") as fp: | |
| for row in rows: | |
| fp.write(json.dumps(row, ensure_ascii=False) + "\n") | |
| def write_csv(path: Path, rows: list[dict[str, Any]]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| keys = sorted({k for row in rows for k in row}) | |
| with path.open("w", newline="", encoding="utf-8-sig") as fp: | |
| writer = csv.DictWriter(fp, fieldnames=keys) | |
| writer.writeheader() | |
| writer.writerows(rows) | |
| def main() -> None: | |
| args = parse_args() | |
| rng = random.Random(args.seed) | |
| output = Path(args.output_root) | |
| output.mkdir(parents=True, exist_ok=True) | |
| stats = Stats() | |
| accepted_files: list[dict[str, Any]] = [] | |
| rejected: list[dict[str, Any]] = [] | |
| samples: list[dict[str, Any]] = [] | |
| manifest_dir = output / "manifests" | |
| report_dir = output / "reports" | |
| sample_path = manifest_dir / "unlabeled_mim_samples.jsonl" | |
| accepted_path = manifest_dir / "accepted_source_images.jsonl" | |
| rejected_path = manifest_dir / "rejected_source_images.jsonl" | |
| for path in (sample_path, accepted_path, rejected_path): | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text("", encoding="utf-8") | |
| for root_text in args.roots: | |
| root = Path(root_text) | |
| if not root.exists(): | |
| continue | |
| pending_samples: list[dict[str, Any]] = [] | |
| pending_accepted: list[dict[str, Any]] = [] | |
| pending_rejected: list[dict[str, Any]] = [] | |
| for path in iter_image_files(root, args.max_files_per_root): | |
| if len(samples) >= args.max_total_windows: | |
| break | |
| stats.scanned_files += 1 | |
| arr, shape, reader = read_preview(path) | |
| if arr is None or shape is None: | |
| stats.unreadable += 1 | |
| stats.rejected_files += 1 | |
| reject(path, "unreadable", pending_rejected) | |
| continue | |
| h, w, c = shape | |
| if min(h, w) < 64: | |
| stats.too_small += 1 | |
| stats.rejected_files += 1 | |
| reject(path, "too_small", pending_rejected, {"height": h, "width": w}) | |
| continue | |
| q = quality(arr) | |
| if q["valid_ratio"] < args.min_valid_ratio: | |
| stats.too_black += 1 | |
| stats.rejected_files += 1 | |
| reject(path, "low_valid_ratio", pending_rejected, q | {"height": h, "width": w}) | |
| continue | |
| if q["black_ratio"] > args.max_black_ratio: | |
| stats.too_black += 1 | |
| stats.rejected_files += 1 | |
| reject(path, "too_black", pending_rejected, q | {"height": h, "width": w}) | |
| continue | |
| if q["white_ratio"] > args.max_white_ratio: | |
| stats.too_white += 1 | |
| stats.rejected_files += 1 | |
| reject(path, "too_white", pending_rejected, q | {"height": h, "width": w}) | |
| continue | |
| if q["std"] < args.min_std: | |
| stats.low_texture += 1 | |
| stats.rejected_files += 1 | |
| reject(path, "low_texture", pending_rejected, q | {"height": h, "width": w}) | |
| continue | |
| satellite = infer_satellite(str(path)) | |
| sensor = infer_sensor(str(path)) | |
| file_record = { | |
| "source_path": str(path), | |
| "root": str(root), | |
| "height": h, | |
| "width": w, | |
| "channels": c, | |
| "reader": reader, | |
| "satellite": satellite, | |
| "sensor": sensor, | |
| "resolution_m": infer_resolution_m(satellite, sensor), | |
| "fusion": {"state": fusion_state(path), "method": "unknown", "persisted": True}, | |
| "quality": q, | |
| } | |
| accepted_files.append(file_record) | |
| pending_accepted.append(file_record) | |
| stats.accepted_files += 1 | |
| for idx, window in enumerate(make_windows(w, h, args.patch_size, args.stride, args.max_windows_per_image, rng)): | |
| if len(samples) >= args.max_total_windows: | |
| break | |
| sample_id = f"mim_{len(samples):08d}" | |
| samples.append( | |
| { | |
| "sample_id": sample_id, | |
| "source_path": str(path), | |
| "window": window, | |
| "patch_size": args.patch_size, | |
| "reader": reader, | |
| "satellite": satellite, | |
| "sensor": sensor, | |
| "resolution_m": infer_resolution_m(satellite, sensor), | |
| "fusion": file_record["fusion"], | |
| "quality": q, | |
| "task_type": "masked_image_modeling", | |
| "label_path": None, | |
| } | |
| ) | |
| pending_samples.append(samples[-1]) | |
| stats.accepted_windows += 1 | |
| if stats.scanned_files % args.progress_every == 0: | |
| append_jsonl(sample_path, pending_samples) | |
| append_jsonl(accepted_path, pending_accepted) | |
| append_jsonl(rejected_path, pending_rejected) | |
| rejected.extend(pending_rejected) | |
| pending_samples.clear() | |
| pending_accepted.clear() | |
| pending_rejected.clear() | |
| progress = { | |
| **asdict(stats), | |
| "current_root": str(root), | |
| "samples_written": sum(1 for _ in sample_path.open("r", encoding="utf-8")), | |
| } | |
| (output / "progress.json").write_text(json.dumps(progress, indent=2, ensure_ascii=False), encoding="utf-8") | |
| print(json.dumps(progress, ensure_ascii=False), flush=True) | |
| append_jsonl(sample_path, pending_samples) | |
| append_jsonl(accepted_path, pending_accepted) | |
| append_jsonl(rejected_path, pending_rejected) | |
| rejected.extend(pending_rejected) | |
| rng.shuffle(samples) | |
| train_count = int(len(samples) * args.split_train) | |
| for i, sample in enumerate(samples): | |
| sample["split"] = "train" if i < train_count else "val" | |
| samples.sort(key=lambda item: item["sample_id"]) | |
| write_jsonl(sample_path, samples) | |
| write_jsonl(accepted_path, accepted_files) | |
| write_jsonl(rejected_path, rejected) | |
| write_csv(report_dir / "rejected_source_images.csv", rejected) | |
| summary = { | |
| **asdict(stats), | |
| "output_root": str(output), | |
| "roots": args.roots, | |
| "patch_size": args.patch_size, | |
| "stride": args.stride, | |
| "splits": dict(Counter(sample["split"] for sample in samples)), | |
| "accepted_by_satellite": dict(Counter(str(row["satellite"]) for row in samples)), | |
| "accepted_by_sensor": dict(Counter(str(row["sensor"]) for row in samples)), | |
| "accepted_by_reader": dict(Counter(row["reader"] for row in samples)), | |
| "quality_policy": { | |
| "min_valid_ratio": args.min_valid_ratio, | |
| "max_black_ratio": args.max_black_ratio, | |
| "max_white_ratio": args.max_white_ratio, | |
| "min_std": args.min_std, | |
| }, | |
| } | |
| (output / "dataset_card.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8") | |
| print(json.dumps(summary, indent=2, ensure_ascii=False), flush=True) | |
| if __name__ == "__main__": | |
| main() | |