agrobot_base/Python/OAK/datasets/oak-fcc-3/_2_clean_dataset.py

418 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Audita e organiza as amostras multiespectrais brutas.
Regras por amostra:
- completa/mapeada: CAM_A + CAM_B + CAM_C + meta JSON + preview PNG + mask PNG;
- completa/pendente: CAM_A + CAM_B + CAM_C + meta JSON + preview PNG, sem mask;
- incompleta: falta qualquer item obrigatório acima (exceto a mask).
Por padrão o programa apenas simula. Com --aplicar, copia os previews pendentes
e move conjuntos incompletos para uma quarentena recuperável. A exclusão
definitiva exige também --excluir-definitivamente.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import re
import shutil
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Iterable
GRUPOS_PADRAO = (
"cana",
"cana_erva",
"chao",
"chao_cana",
"chao_cana_erva",
"chao_erva",
"erva",
)
SUBPASTAS = ("bins", "masks", "metas", "previews")
RE_COPIA_WINDOWS = re.compile(r"\s*\((\d+)\)\s*$")
RE_CAM = re.compile(r"(?i)(?:^|[_\-\s])CAM[_\-\s]*([ABC])(?:$|[_\-\s])")
@dataclass(frozen=True, order=True)
class ChaveAmostra:
base: str
copia: int
@property
def texto(self) -> str:
return self.base if self.copia == 0 else f"{self.base} ({self.copia})"
@dataclass
class Amostra:
grupo: str
chave: ChaveAmostra
cams: dict[str, list[Path]] = field(
default_factory=lambda: {"A": [], "B": [], "C": []}
)
metas: list[Path] = field(default_factory=list)
previews: list[Path] = field(default_factory=list)
masks: list[Path] = field(default_factory=list)
outros: list[Path] = field(default_factory=list)
@property
def arquivos(self) -> list[Path]:
resultado: list[Path] = []
for camera in "ABC":
resultado.extend(self.cams[camera])
resultado.extend(self.metas)
resultado.extend(self.previews)
resultado.extend(self.masks)
resultado.extend(self.outros)
return resultado
@property
def conflitos(self) -> list[str]:
erros: list[str] = []
for camera in "ABC":
if len(self.cams[camera]) > 1:
erros.append(f"{len(self.cams[camera])} arquivos CAM_{camera}")
if len(self.metas) > 1:
erros.append(f"{len(self.metas)} metas")
if len(self.previews) > 1:
erros.append(f"{len(self.previews)} previews")
if len(self.masks) > 1:
erros.append(f"{len(self.masks)} masks")
return erros
@property
def faltando(self) -> list[str]:
faltas = [f"CAM_{c}" for c in "ABC" if len(self.cams[c]) == 0]
if not self.metas:
faltas.append("meta")
if not self.previews:
faltas.append("preview")
return faltas
@property
def estado(self) -> str:
if self.conflitos:
return "CONFLITO"
if self.faltando:
return "INCOMPLETA"
if self.masks:
return "PRONTA"
return "PENDENTE_MASK"
def separar_numero_copia(stem: str) -> tuple[str, int]:
match = RE_COPIA_WINDOWS.search(stem)
if not match:
return stem.strip(), 0
return stem[: match.start()].strip(), int(match.group(1))
def limpar_base(stem: str, tipo: str) -> ChaveAmostra:
sem_copia, copia = separar_numero_copia(stem)
# Alguns pipelines incluem CAM_A também no nome do preview/mask/meta.
# Removê-lo em todos os tipos permite associar essas variações ao mesmo trio.
sem_copia = RE_CAM.sub("_", sem_copia)
if tipo == "meta":
sem_copia = re.sub(r"(?i)(?:[_\-\s]+)(?:meta|metadata)$", "", sem_copia)
elif tipo == "preview":
sem_copia = re.sub(r"(?i)(?:[_\-\s]+)preview$", "", sem_copia)
elif tipo == "mask":
# A ferramenta de anotação pode devolver a mask com o próprio nome do
# preview copiado. Portanto, em masks aceitamos tanto _mask quanto
# _preview como sufixo da mesma amostra.
sem_copia = re.sub(r"(?i)(?:[_\-\s]+)(?:mask|preview)$", "", sem_copia)
sem_copia = re.sub(r"[_\-\s]+$", "", sem_copia)
sem_copia = re.sub(r"[_\-\s]+", "_", sem_copia).strip("_").casefold()
return ChaveAmostra(sem_copia, copia)
def camera_do_bin(path: Path) -> str | None:
sem_copia, _ = separar_numero_copia(path.stem)
match = RE_CAM.search(sem_copia)
return match.group(1).upper() if match else None
def arquivos_validos(pasta: Path, extensoes: set[str]) -> Iterable[Path]:
if not pasta.exists():
return ()
return (
p
for p in pasta.iterdir()
if p.is_file() and p.suffix.casefold() in extensoes
)
def inventariar_grupo(raiz: Path, grupo: str) -> dict[ChaveAmostra, Amostra]:
pasta_grupo = raiz / grupo
amostras: dict[ChaveAmostra, Amostra] = {}
def obter(chave: ChaveAmostra) -> Amostra:
return amostras.setdefault(chave, Amostra(grupo, chave))
for path in arquivos_validos(pasta_grupo / "bins", {".bin"}):
camera = camera_do_bin(path)
chave = limpar_base(path.stem, "bin")
amostra = obter(chave)
if camera:
amostra.cams[camera].append(path)
else:
amostra.outros.append(path)
for path in arquivos_validos(pasta_grupo / "metas", {".json"}):
obter(limpar_base(path.stem, "meta")).metas.append(path)
for path in arquivos_validos(pasta_grupo / "previews", {".png"}):
obter(limpar_base(path.stem, "preview")).previews.append(path)
for path in arquivos_validos(pasta_grupo / "masks", {".png"}):
obter(limpar_base(path.stem, "mask")).masks.append(path)
return amostras
def hash_arquivo(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as arquivo:
for bloco in iter(lambda: arquivo.read(1024 * 1024), b""):
digest.update(bloco)
return digest.hexdigest()
def destino_sem_sobrescrever(origem: Path, pasta_destino: Path) -> tuple[Path, str]:
destino = pasta_destino / origem.name
if not destino.exists():
return destino, "copiar"
if origem.stat().st_size == destino.stat().st_size:
if hash_arquivo(origem) == hash_arquivo(destino):
return destino, "ja_existe_igual"
contador = 2
while True:
alternativo = pasta_destino / f"{origem.stem}__duplicado_{contador}{origem.suffix}"
if not alternativo.exists():
return alternativo, "copiar_renomeado"
contador += 1
def mover_preservando_estrutura(path: Path, raiz: Path, quarentena: Path) -> Path:
relativo = path.relative_to(raiz)
destino = quarentena / relativo
destino.parent.mkdir(parents=True, exist_ok=True)
if destino.exists():
destino = destino.with_name(
f"{destino.stem}__conflito_{datetime.now():%H%M%S%f}{destino.suffix}"
)
shutil.move(str(path), str(destino))
return destino
def escrever_relatorio(path: Path, linhas: list[dict[str, str]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
campos = [
"grupo",
"amostra",
"estado",
"faltando",
"conflitos",
"cams",
"meta",
"preview",
"mask",
"acao",
]
with path.open("w", newline="", encoding="utf-8-sig") as arquivo:
writer = csv.DictWriter(arquivo, fieldnames=campos, delimiter=";")
writer.writeheader()
writer.writerows(linhas)
def validar_raiz(raiz: Path, grupos: tuple[str, ...]) -> None:
if not raiz.is_dir():
raise SystemExit(f"ERRO: pasta de entrada não encontrada: {raiz}")
ausentes = [grupo for grupo in grupos if not (raiz / grupo).is_dir()]
if ausentes:
raise SystemExit(
"ERRO: faltam as pastas de grupo: " + ", ".join(ausentes)
)
for grupo in grupos:
faltantes = [s for s in SUBPASTAS if not (raiz / grupo / s).is_dir()]
if faltantes:
raise SystemExit(
f"ERRO: {raiz / grupo} não contém: {', '.join(faltantes)}"
)
def executar(args: argparse.Namespace) -> int:
raiz = args.entrada.resolve()
saida = args.saida.resolve()
grupos = tuple(args.grupos)
validar_raiz(raiz, grupos)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
quarentena = raiz.parent / f"_quarentena_incompletos_{timestamp}"
linhas: list[dict[str, str]] = []
totais: defaultdict[str, int] = defaultdict(int)
print(f"Modo: {'APLICAR' if args.aplicar else 'SIMULAÇÃO (nenhum arquivo será alterado)'}")
print(f"Entrada: {raiz}")
print(f"Previews pendentes: {saida}")
if args.aplicar and not args.excluir_definitivamente:
print(f"Quarentena: {quarentena}")
print()
for grupo in grupos:
amostras = inventariar_grupo(raiz, grupo)
print(f"[{grupo}] {len(amostras)} amostras encontradas")
for chave in sorted(amostras):
amostra = amostras[chave]
estado = amostra.estado
totais[estado] += 1
acao = "ignorar"
if estado == "PENDENTE_MASK":
destino, modo_copia = destino_sem_sobrescrever(
amostra.previews[0], saida / grupo
)
if modo_copia == "ja_existe_igual":
acao = f"preview já existe: {destino}"
totais["PREVIEW_JA_EXISTIA"] += 1
else:
acao = f"copiar preview -> {destino}"
totais["PREVIEWS_A_COPIAR"] += 1
if args.aplicar:
destino.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(amostra.previews[0], destino)
totais["PREVIEWS_COPIADOS"] += 1
elif estado == "INCOMPLETA":
if args.excluir_definitivamente:
acao = f"excluir {len(amostra.arquivos)} arquivo(s) definitivamente"
else:
acao = f"mover {len(amostra.arquivos)} arquivo(s) para quarentena"
totais["ARQUIVOS_INCOMPLETOS"] += len(amostra.arquivos)
if args.aplicar:
for arquivo in amostra.arquivos:
if args.excluir_definitivamente:
arquivo.unlink()
else:
mover_preservando_estrutura(arquivo, raiz, quarentena)
totais["AMOSTRAS_INCOMPLETAS_TRATADAS"] += 1
elif estado == "CONFLITO":
acao = "revisar manualmente; nada será removido"
linhas.append(
{
"grupo": grupo,
"amostra": chave.texto,
"estado": estado,
"faltando": ", ".join(amostra.faltando),
"conflitos": ", ".join(amostra.conflitos),
"cams": ", ".join(
f"{c}:{len(amostra.cams[c])}" for c in "ABC"
),
"meta": str(len(amostra.metas)),
"preview": str(len(amostra.previews)),
"mask": str(len(amostra.masks)),
"acao": acao,
}
)
relatorio = (
args.relatorio.resolve()
if args.relatorio
else Path.cwd() / f"relatorio_limpeza_{timestamp}.csv"
)
escrever_relatorio(relatorio, linhas)
print("\nResumo")
for nome in (
"PRONTA",
"PENDENTE_MASK",
"INCOMPLETA",
"CONFLITO",
"PREVIEWS_A_COPIAR",
"PREVIEW_JA_EXISTIA",
"PREVIEWS_COPIADOS",
"ARQUIVOS_INCOMPLETOS",
"AMOSTRAS_INCOMPLETAS_TRATADAS",
):
print(f" {nome}: {totais[nome]}")
print(f"\nRelatório: {relatorio}")
if not args.aplicar:
print("\nSIMULAÇÃO concluída. Revise o CSV e rode novamente com --aplicar.")
elif args.excluir_definitivamente:
print("\nAplicação concluída com exclusão definitiva dos incompletos.")
else:
print("\nAplicação concluída. Os incompletos foram preservados na quarentena.")
return 0
def criar_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Audita brutas multiespectrais e separa previews sem mask."
)
parser.add_argument(
"--entrada",
type=Path,
default=Path("dataset/brutas/group"),
help="Pasta que contém os sete grupos.",
)
parser.add_argument(
"--saida",
type=Path,
default=Path(r"C:\Users\USER\Desktop\fotos_multiespectrais\mapear\group"),
help="Destino dos previews que ainda não possuem mask.",
)
parser.add_argument(
"--grupos",
nargs="+",
default=list(GRUPOS_PADRAO),
help="Grupos que serão processados.",
)
parser.add_argument(
"--relatorio",
type=Path,
help="Caminho opcional do relatório CSV.",
)
parser.add_argument(
"--aplicar",
action="store_true",
help="Executa cópias e limpeza. Sem esta flag, apenas simula.",
)
parser.add_argument(
"--excluir-definitivamente",
action="store_true",
help="Com --aplicar, apaga incompletos em vez de movê-los à quarentena.",
)
return parser
def main() -> int:
parser = criar_parser()
args = parser.parse_args()
if args.excluir_definitivamente and not args.aplicar:
parser.error("--excluir-definitivamente só pode ser usado com --aplicar")
return executar(args)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nCancelado pelo usuário.", file=sys.stderr)
sys.exit(130)