"""Build a normalized manifest for marine ecological feature datasets. The script scans one or more roots, classifies likely feature types from path keywords, pairs images and masks when possible, and writes a manifest without copying large raster files. """ from __future__ import annotations import argparse import csv import json import re from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path from typing import Iterable IMAGE_EXTS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"} MASK_HINTS = ("mask", "masks", "label", "labels", "gt", "annotation", "annotations", "seg") IMAGE_HINTS = ("image", "images", "img", "imgs", "tif", "tile", "tiles") ELEMENT_KEYWORDS = { "green_tide": ("浒苔", "绿潮", "green_tide", "greentide", "entgreentide", "enteromorpha", "seaweed"), "red_tide": ("赤潮", "red_tide", "redtide", "harmful_algal", "hab"), "golden_tide": ("马尾藻", "金潮", "sarg", "sargassum", "golden_tide", "goldentide"), "aquaculture": ("养殖", "aquaculture", "raft", "cage", "pond"), } SATELLITE_PATTERN = re.compile(r"\b(GF\d+|HY\d+|Sentinel-?2|Landsat-?\d*)\b", re.IGNORECASE) DATE_PATTERN = re.compile(r"(20\d{6}|19\d{6})") PATCH_SIZE_PATTERN = re.compile(r"(?:^|[_\\/\-])(?:size)?(128|256|512|1024)(?:[_\\/\-]|$)") @dataclass class AssetRecord: asset_id: str path: str filename: str suffix: str role: str element: str satellite: str | None sensor: str | None acquired_at: str | None patch_size: int | None source_project: str source_dataset: str size_bytes: int modified_at: str quality_flags: list[str] @dataclass class SampleRecord: sample_id: str element: str task_type: str image_path: str mask_path: str | None label_encoding: dict[str, str] | None satellite: str | None sensor: str | None resolution_m: float | None patch_size: int | None bands: list[str] | None band_count: int | None dtype: str | None fusion: dict acquired_at: str | None source_project: str source_dataset: str split: str | None quality_flags: list[str] notes: str def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--roots", nargs="+", required=True, help="Dataset roots to scan.") parser.add_argument("--output-root", required=True, help="Output normalized dataset root.") parser.add_argument("--max-files", type=int, default=0, help="Optional scan limit for debugging.") return parser.parse_args() def norm_text(path: Path) -> str: return str(path).replace("\\", "/").lower() def infer_element(path: Path) -> str: text = norm_text(path) for element, keywords in ELEMENT_KEYWORDS.items(): if any(keyword.lower() in text for keyword in keywords): return element return "unknown" def infer_role(path: Path) -> str: parts = [part.lower() for part in path.parts] stem = path.stem.lower() if any(hint in parts or hint in stem for hint in MASK_HINTS): return "mask" if any(hint in parts for hint in IMAGE_HINTS): return "image" if path.suffix.lower() in {".png", ".jpg", ".jpeg"} and any(hint in stem for hint in MASK_HINTS): return "mask" return "image" def infer_satellite(path: Path) -> str | None: match = SATELLITE_PATTERN.search(str(path)) return match.group(1).upper().replace("-", "") if match else None def infer_sensor(path: Path) -> str | None: upper = path.name.upper() for sensor in ("PMS", "MUX", "MSS", "PAN", "WFV"): if sensor in upper: return sensor return None def infer_date(path: Path) -> str | None: match = DATE_PATTERN.search(path.name) if not match: return None raw = match.group(1) try: return datetime.strptime(raw, "%Y%m%d").date().isoformat() except ValueError: return None def infer_patch_size(path: Path) -> int | None: match = PATCH_SIZE_PATTERN.search(str(path)) return int(match.group(1)) if match else None def infer_source_project(path: Path, roots: list[Path]) -> str: for root in roots: try: rel = path.relative_to(root) except ValueError: continue return rel.parts[0] if len(rel.parts) > 1 else root.name return path.parent.name def infer_split(path: Path) -> str | None: parts = {part.lower() for part in path.parts} for split in ("train", "val", "test"): if split in parts: return split return None def source_dataset(path: Path) -> str: for part in reversed(path.parts): lower = part.lower() if any(token in lower for token in ("gf", "sentinel", "landsat", "浒苔", "赤潮", "马尾藻", "养殖")): return part return path.parent.name def is_fused(path: Path) -> bool | None: lower = path.name.lower() if "fuse" in lower or "fusion" in lower or "pan" not in lower and "mux" in lower: return True if "pan" in lower or "mss" in lower: return False return None def infer_fusion(path: Path) -> dict: fused = is_fused(path) lower = path.name.lower() if fused is True: state = "fused_product" method = "unknown_vendor_product" persisted = True elif fused is False and ("pan" in lower or "mss" in lower): state = "none" method = "none" persisted = False else: state = "unknown" method = "unknown" persisted = False return { "state": state, "method": method, "sources": [{"role": "source", "path": str(path), "resolution_m": None}], "target_resolution_m": None, "native_multispectral_resolution_m": None, "persisted": persisted, "reproducible": state != "unknown", "spectral_preservation": "unknown", "notes": "Auto-inferred from local filename; verify before training.", } def asset_id(path: Path) -> str: safe = re.sub(r"[^A-Za-z0-9]+", "_", str(path.stem)).strip("_").lower() return safe[:180] def iter_files(roots: Iterable[Path], max_files: int) -> Iterable[Path]: count = 0 for root in roots: if not root.exists(): continue for path in root.rglob("*"): if not path.is_file() or path.suffix.lower() not in IMAGE_EXTS: continue yield path count += 1 if max_files and count >= max_files: return def write_jsonl(path: Path, rows: Iterable[dict]) -> None: with path.open("w", encoding="utf-8") as f: for row in rows: f.write(json.dumps(row, ensure_ascii=False) + "\n") def main() -> None: args = parse_args() roots = [Path(root) for root in args.roots] output_root = Path(args.output_root) manifest_dir = output_root / "manifests" report_dir = output_root / "reports" manifest_dir.mkdir(parents=True, exist_ok=True) report_dir.mkdir(parents=True, exist_ok=True) assets: list[AssetRecord] = [] for path in iter_files(roots, args.max_files): stat = path.stat() role = infer_role(path) flags = [] if infer_element(path) == "unknown": flags.append("unknown_element") if role == "image" and "black" in norm_text(path): flags.append("possibly_invalid") assets.append( AssetRecord( asset_id=asset_id(path), path=str(path), filename=path.name, suffix=path.suffix.lower(), role=role, element=infer_element(path), satellite=infer_satellite(path), sensor=infer_sensor(path), acquired_at=infer_date(path), patch_size=infer_patch_size(path), source_project=infer_source_project(path, roots), source_dataset=source_dataset(path), size_bytes=stat.st_size, modified_at=datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"), quality_flags=flags, ) ) masks_by_stem = {Path(asset.path).stem.lower(): asset for asset in assets if asset.role == "mask"} samples: list[SampleRecord] = [] for asset in assets: if asset.role != "image": continue path = Path(asset.path) mask_asset = masks_by_stem.get(path.stem.lower()) element = asset.element if asset.element != "unknown" else (mask_asset.element if mask_asset else "unknown") flags = list(asset.quality_flags) if mask_asset is None: flags.append("unpaired_image") sample_id = f"{element}_{asset.asset_id}" samples.append( SampleRecord( sample_id=sample_id, element=element, task_type="semantic_segmentation", image_path=asset.path, mask_path=mask_asset.path if mask_asset else None, label_encoding={"0": "background", "1": element} if mask_asset else None, satellite=asset.satellite, sensor=asset.sensor, resolution_m=None, patch_size=asset.patch_size, bands=None, band_count=None, dtype=None, fusion=infer_fusion(path), acquired_at=asset.acquired_at, source_project=asset.source_project, source_dataset=asset.source_dataset, split=infer_split(path), quality_flags=flags, notes="auto-generated; verify ambiguous labels before training", ) ) write_jsonl(manifest_dir / "assets_raw.jsonl", (asdict(asset) for asset in assets)) write_jsonl(manifest_dir / "samples.jsonl", (asdict(sample) for sample in samples)) with (report_dir / "asset_inventory.csv").open("w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=list(asdict(assets[0]).keys()) if assets else ["asset_id"]) writer.writeheader() for asset in assets: row = asdict(asset) row["quality_flags"] = ";".join(row["quality_flags"]) writer.writerow(row) summary = { "roots": [str(root) for root in roots], "assets": len(assets), "samples": len(samples), "by_element": {}, "output_root": str(output_root), } for sample in samples: summary["by_element"][sample.element] = summary["by_element"].get(sample.element, 0) + 1 print(json.dumps(summary, indent=2, ensure_ascii=False)) if __name__ == "__main__": main()