criado script para separação de fotos
This commit is contained in:
parent
b33b4a2ae5
commit
8c4d10f329
|
|
@ -0,0 +1,816 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
sample_bundle_sorter.py
|
||||||
|
-------------------------------------------------
|
||||||
|
Classificador manual para o formato de dataset do módulo multiespectral.
|
||||||
|
|
||||||
|
Cada amostra é formada por:
|
||||||
|
<base>.png
|
||||||
|
<base>.json
|
||||||
|
<base>_cam0.bin
|
||||||
|
<base>_cam1.bin
|
||||||
|
<base>_cam2.bin # opcional
|
||||||
|
|
||||||
|
Fluxo:
|
||||||
|
- Entrada: uma ou mais pastas-raiz contendo amostras em subpastas.
|
||||||
|
- O script mostra o preview (.png).
|
||||||
|
- Você usa teclas 1..9/0 para enviar o conjunto da amostra para uma label.
|
||||||
|
|
||||||
|
Estrutura de saída:
|
||||||
|
out_root/
|
||||||
|
<label>/
|
||||||
|
bins/
|
||||||
|
previews/
|
||||||
|
metas/
|
||||||
|
masks/
|
||||||
|
|
||||||
|
Regras:
|
||||||
|
- PNG vai para previews/
|
||||||
|
- JSON vai para metas/
|
||||||
|
- BINs vão para bins/
|
||||||
|
- masks/ é criada vazia
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox, filedialog
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageTk
|
||||||
|
except Exception:
|
||||||
|
print("ERROR: Pillow (PIL) is required. Install with: pip install pillow", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
PREVIEW_EXT = ".png"
|
||||||
|
META_EXT = ".json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SampleBundle:
|
||||||
|
sample_dir: Path
|
||||||
|
preview_path: Path
|
||||||
|
meta_path: Path
|
||||||
|
bin_paths: List[Path]
|
||||||
|
sample_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def find_sample_bundles_in_folder(folder: Path) -> List[SampleBundle]:
|
||||||
|
"""
|
||||||
|
Procura amostras em formatos antigos e atuais.
|
||||||
|
|
||||||
|
Suporta formato antigo Pi:
|
||||||
|
<base>.png
|
||||||
|
<base>.json
|
||||||
|
<base>_cam0.bin
|
||||||
|
<base>_cam1.bin
|
||||||
|
<base>_cam2.bin
|
||||||
|
|
||||||
|
Suporta formato OAK-FFC-3:
|
||||||
|
preview.png
|
||||||
|
meta.json
|
||||||
|
CAM_A.bin / CAM_B.bin / CAM_C.bin
|
||||||
|
|
||||||
|
Também suporta:
|
||||||
|
<base>_CAM_A.bin
|
||||||
|
<base>_CAM_B.bin
|
||||||
|
<base>_CAM_C.bin
|
||||||
|
|
||||||
|
<base>_rgb.bin
|
||||||
|
<base>_nir.bin
|
||||||
|
<base>_re.bin
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
if not folder.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
bundles: List[SampleBundle] = []
|
||||||
|
candidate_dirs = [folder]
|
||||||
|
candidate_dirs.extend([p for p in folder.rglob("*") if p.is_dir()])
|
||||||
|
|
||||||
|
# Padrões aceitos
|
||||||
|
old_cam_re = re.compile(r"^(?P<base>.+)_cam(?P<cam>\d+)\.bin$", re.IGNORECASE)
|
||||||
|
oak_cam_re = re.compile(r"^(?:(?P<base>.+)_)?(?P<cam>CAM_[ABC])\.bin$", re.IGNORECASE)
|
||||||
|
role_re = re.compile(r"^(?P<base>.+)_(?P<role>rgb|nir|re)\.bin$", re.IGNORECASE)
|
||||||
|
|
||||||
|
role_order = {
|
||||||
|
"CAM_A": 0,
|
||||||
|
"CAM_B": 1,
|
||||||
|
"CAM_C": 2,
|
||||||
|
"RGB": 0,
|
||||||
|
"NIR": 1,
|
||||||
|
"RE": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
generic_preview_names = {"preview.png", "preview.jpg", "preview.jpeg"}
|
||||||
|
generic_meta_names = {"meta.json", "metadata.json"}
|
||||||
|
|
||||||
|
for d in candidate_dirs:
|
||||||
|
if not d.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
files = [p for p in d.iterdir() if p.is_file()]
|
||||||
|
if not files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pngs = {}
|
||||||
|
jsons = {}
|
||||||
|
bins_by_base = {}
|
||||||
|
|
||||||
|
generic_preview = None
|
||||||
|
generic_meta = None
|
||||||
|
generic_bins = []
|
||||||
|
|
||||||
|
for p in files:
|
||||||
|
name_lower = p.name.lower()
|
||||||
|
suffix = p.suffix.lower()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Preview
|
||||||
|
# -----------------------------
|
||||||
|
if suffix in (".png", ".jpg", ".jpeg"):
|
||||||
|
if name_lower in generic_preview_names:
|
||||||
|
generic_preview = p
|
||||||
|
else:
|
||||||
|
pngs[p.stem] = p
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Meta
|
||||||
|
# -----------------------------
|
||||||
|
if suffix == ".json":
|
||||||
|
if name_lower in generic_meta_names:
|
||||||
|
generic_meta = p
|
||||||
|
else:
|
||||||
|
jsons[p.stem] = p
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# BIN
|
||||||
|
# -----------------------------
|
||||||
|
if suffix != ".bin":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Antigo: <base>_cam0.bin
|
||||||
|
m = old_cam_re.match(p.name)
|
||||||
|
if m:
|
||||||
|
base = m.group("base")
|
||||||
|
cam_idx = int(m.group("cam"))
|
||||||
|
bins_by_base.setdefault(base, []).append((cam_idx, p))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Novo: <base>_CAM_A.bin ou CAM_A.bin
|
||||||
|
m = oak_cam_re.match(p.name)
|
||||||
|
if m:
|
||||||
|
base = m.group("base") or d.name
|
||||||
|
cam = m.group("cam").upper()
|
||||||
|
order = role_order.get(cam, 99)
|
||||||
|
bins_by_base.setdefault(base, []).append((order, p))
|
||||||
|
generic_bins.append((order, p))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Alternativo: <base>_rgb.bin / _nir.bin / _re.bin
|
||||||
|
m = role_re.match(p.name)
|
||||||
|
if m:
|
||||||
|
base = m.group("base")
|
||||||
|
role = m.group("role").upper()
|
||||||
|
order = role_order.get(role, 99)
|
||||||
|
bins_by_base.setdefault(base, []).append((order, p))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Caso 1: formato OAK em pasta de amostra:
|
||||||
|
# preview.png + meta.json + CAM_A/B/C.bin
|
||||||
|
# -------------------------------------------------
|
||||||
|
if generic_preview and generic_meta and generic_bins:
|
||||||
|
sorted_bins = [p for _, p in sorted(generic_bins, key=lambda x: x[0])]
|
||||||
|
sample_id = d.name
|
||||||
|
|
||||||
|
bundles.append(
|
||||||
|
SampleBundle(
|
||||||
|
sample_dir=d,
|
||||||
|
preview_path=generic_preview,
|
||||||
|
meta_path=generic_meta,
|
||||||
|
bin_paths=sorted_bins,
|
||||||
|
sample_id=sample_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Caso 2: formato com base comum:
|
||||||
|
# sample_x.png/json + sample_x_CAM_A.bin
|
||||||
|
# ou sample_x.png/json + sample_x_cam0.bin
|
||||||
|
# -------------------------------------------------
|
||||||
|
valid_bases = sorted(set(pngs.keys()) & set(jsons.keys()) & set(bins_by_base.keys()))
|
||||||
|
|
||||||
|
for base in valid_bases:
|
||||||
|
sorted_bins = [p for _, p in sorted(bins_by_base[base], key=lambda x: x[0])]
|
||||||
|
|
||||||
|
if not sorted_bins:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bundles.append(
|
||||||
|
SampleBundle(
|
||||||
|
sample_dir=d,
|
||||||
|
preview_path=pngs[base],
|
||||||
|
meta_path=jsons[base],
|
||||||
|
bin_paths=sorted_bins,
|
||||||
|
sample_id=base,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Caso 3: meta/preview genéricos, mas bins com base:
|
||||||
|
# preview.png + meta.json + sample_x_CAM_A.bin...
|
||||||
|
# -------------------------------------------------
|
||||||
|
if generic_preview and generic_meta and bins_by_base and not generic_bins:
|
||||||
|
for base, bin_items in sorted(bins_by_base.items()):
|
||||||
|
sorted_bins = [p for _, p in sorted(bin_items, key=lambda x: x[0])]
|
||||||
|
|
||||||
|
if not sorted_bins:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bundles.append(
|
||||||
|
SampleBundle(
|
||||||
|
sample_dir=d,
|
||||||
|
preview_path=generic_preview,
|
||||||
|
meta_path=generic_meta,
|
||||||
|
bin_paths=sorted_bins,
|
||||||
|
sample_id=base,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
bundles.sort(key=lambda x: (str(x.sample_dir), x.sample_id))
|
||||||
|
return bundles
|
||||||
|
|
||||||
|
|
||||||
|
def collect_all_bundles(folders: List[Path]) -> List[SampleBundle]:
|
||||||
|
all_bundles: List[SampleBundle] = []
|
||||||
|
seen_dirs = set()
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
for bundle in find_sample_bundles_in_folder(folder):
|
||||||
|
key = str((bundle.sample_dir / bundle.sample_id).resolve())
|
||||||
|
if key in seen_dirs:
|
||||||
|
continue
|
||||||
|
seen_dirs.add(key)
|
||||||
|
all_bundles.append(bundle)
|
||||||
|
|
||||||
|
all_bundles.sort(key=lambda x: (str(x.sample_dir), x.sample_id))
|
||||||
|
return all_bundles
|
||||||
|
|
||||||
|
|
||||||
|
class ActionLogger:
|
||||||
|
"""
|
||||||
|
Loga ações em:
|
||||||
|
- CSV: sorting_log.csv
|
||||||
|
- JSONL: sorting_log.jsonl
|
||||||
|
|
||||||
|
O resume usa sample_dir como chave.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, out_root: Path):
|
||||||
|
self.out_root = out_root
|
||||||
|
self.log_csv = out_root / "sorting_log.csv"
|
||||||
|
self.log_json = out_root / "sorting_log.jsonl"
|
||||||
|
self.seen = set()
|
||||||
|
|
||||||
|
if self.log_csv.exists():
|
||||||
|
try:
|
||||||
|
with self.log_csv.open("r", newline="", encoding="utf-8") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
key = row.get("meta_path", "")
|
||||||
|
if key:
|
||||||
|
self.seen.add(key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def already_logged(self, sample_key: Path) -> bool:
|
||||||
|
return str(sample_key.resolve()) in self.seen
|
||||||
|
|
||||||
|
def log(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
sample_dir: Path,
|
||||||
|
preview_path: Path,
|
||||||
|
meta_path: Path,
|
||||||
|
bin_paths: List[Path],
|
||||||
|
label: Optional[str] = None,
|
||||||
|
dest_sample_dir: Optional[Path] = None,
|
||||||
|
):
|
||||||
|
sample_dir_str = str(sample_dir.resolve())
|
||||||
|
meta_key_str = str(meta_path.resolve())
|
||||||
|
bin_paths_str = [str(p.resolve()) for p in bin_paths]
|
||||||
|
|
||||||
|
new_file = not self.log_csv.exists()
|
||||||
|
with self.log_csv.open("a", newline="", encoding="utf-8") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
if new_file:
|
||||||
|
writer.writerow([
|
||||||
|
"timestamp",
|
||||||
|
"action",
|
||||||
|
"label",
|
||||||
|
"sample_dir",
|
||||||
|
"preview_path",
|
||||||
|
"meta_path",
|
||||||
|
"bin_paths_json",
|
||||||
|
"dest_sample_dir",
|
||||||
|
])
|
||||||
|
writer.writerow([
|
||||||
|
datetime.now().isoformat(timespec="seconds"),
|
||||||
|
action,
|
||||||
|
label or "",
|
||||||
|
sample_dir_str,
|
||||||
|
str(preview_path.resolve()),
|
||||||
|
str(meta_path.resolve()),
|
||||||
|
json.dumps(bin_paths_str, ensure_ascii=False),
|
||||||
|
str(dest_sample_dir.resolve()) if dest_sample_dir else "",
|
||||||
|
])
|
||||||
|
|
||||||
|
with self.log_json.open("a", encoding="utf-8") as f:
|
||||||
|
rec = {
|
||||||
|
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"action": action,
|
||||||
|
"label": label,
|
||||||
|
"sample_dir": sample_dir_str,
|
||||||
|
"preview_path": str(preview_path.resolve()),
|
||||||
|
"meta_path": str(meta_path.resolve()),
|
||||||
|
"bin_paths": bin_paths_str,
|
||||||
|
"dest_sample_dir": str(dest_sample_dir.resolve()) if dest_sample_dir else None,
|
||||||
|
}
|
||||||
|
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
self.seen.add(str(meta_path.resolve()))
|
||||||
|
|
||||||
|
|
||||||
|
class SampleBundleSorterApp:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bundles: List[SampleBundle],
|
||||||
|
labels: List[str],
|
||||||
|
out_root: Path,
|
||||||
|
move: bool,
|
||||||
|
resume: bool,
|
||||||
|
display_height: int = 512,
|
||||||
|
):
|
||||||
|
self.all_bundles = bundles
|
||||||
|
self.labels = labels
|
||||||
|
self.out_root = out_root
|
||||||
|
self.move = move
|
||||||
|
self.display_height = display_height
|
||||||
|
self.logger = ActionLogger(out_root)
|
||||||
|
|
||||||
|
if resume:
|
||||||
|
self.all_bundles = [
|
||||||
|
b for b in self.all_bundles if not self.logger.already_logged(b.meta_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.idx = 0
|
||||||
|
self.history = []
|
||||||
|
|
||||||
|
for label in self.labels:
|
||||||
|
label_root = out_root / label
|
||||||
|
(label_root / "bins").mkdir(parents=True, exist_ok=True)
|
||||||
|
(label_root / "previews").mkdir(parents=True, exist_ok=True)
|
||||||
|
(label_root / "metas").mkdir(parents=True, exist_ok=True)
|
||||||
|
(label_root / "masks").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.root = tk.Tk()
|
||||||
|
self.root.title("Agrobotics Sample Bundle Sorter")
|
||||||
|
self.root.geometry("1100x750")
|
||||||
|
self.root.bind("<Key>", self.on_key)
|
||||||
|
|
||||||
|
self.top_frame = tk.Frame(self.root)
|
||||||
|
self.top_frame.pack(side=tk.TOP, fill=tk.X)
|
||||||
|
|
||||||
|
self.info_label = tk.Label(self.top_frame, text="", font=("Segoe UI", 11))
|
||||||
|
self.info_label.pack(side=tk.LEFT, padx=10, pady=6)
|
||||||
|
|
||||||
|
self.legend_label = tk.Label(self.top_frame, text=self.build_legend_text(), font=("Segoe UI", 10))
|
||||||
|
self.legend_label.pack(side=tk.RIGHT, padx=10, pady=6)
|
||||||
|
|
||||||
|
self.img_frame = tk.Frame(self.root)
|
||||||
|
self.img_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
self.preview_label = tk.Label(self.img_frame)
|
||||||
|
self.preview_label.pack(side=tk.LEFT, expand=True, padx=6, pady=6)
|
||||||
|
|
||||||
|
self.status_var = tk.StringVar(value="Pronto.")
|
||||||
|
self.status_label = tk.Label(self.root, textvariable=self.status_var, font=("Segoe UI", 10), anchor="w")
|
||||||
|
self.status_label.pack(side=tk.BOTTOM, fill=tk.X, padx=8, pady=4)
|
||||||
|
|
||||||
|
self.footer = tk.Label(
|
||||||
|
self.root,
|
||||||
|
text="1..9/0 = labels | espaço/n/→ = próxima (skip) | p/← = anterior | b = undo | q/Esc = sair",
|
||||||
|
font=("Segoe UI", 10),
|
||||||
|
)
|
||||||
|
self.footer.pack(side=tk.BOTTOM, fill=tk.X, pady=2)
|
||||||
|
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
def unique_file(self, p: Path) -> Path:
|
||||||
|
if not p.exists():
|
||||||
|
return p
|
||||||
|
|
||||||
|
stem = p.stem
|
||||||
|
suffix = p.suffix
|
||||||
|
k = 1
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cand = p.with_name(f"{stem}__{k}{suffix}")
|
||||||
|
if not cand.exists():
|
||||||
|
return cand
|
||||||
|
k += 1
|
||||||
|
|
||||||
|
def build_legend_text(self) -> str:
|
||||||
|
parts = []
|
||||||
|
for i, label in enumerate(self.labels, start=1):
|
||||||
|
key = i if i <= 9 else 0
|
||||||
|
parts.append(f"[{key}] {label}")
|
||||||
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
def pil_load_preview(self, preview_path: Path) -> ImageTk.PhotoImage:
|
||||||
|
img = Image.open(preview_path).convert("RGB")
|
||||||
|
h_target = self.display_height
|
||||||
|
w, h = img.size
|
||||||
|
new_w = int(w * (h_target / h))
|
||||||
|
img = img.resize((new_w, h_target), Image.BILINEAR)
|
||||||
|
return ImageTk.PhotoImage(img)
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
if not self.all_bundles:
|
||||||
|
messagebox.showinfo("Fim", "Não há amostras para exibir.")
|
||||||
|
self.root.destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.idx = max(0, min(self.idx, len(self.all_bundles) - 1))
|
||||||
|
bundle = self.all_bundles[self.idx]
|
||||||
|
|
||||||
|
try:
|
||||||
|
tk_img = self.pil_load_preview(bundle.preview_path)
|
||||||
|
self.preview_tk = tk_img
|
||||||
|
self.preview_label.configure(image=self.preview_tk)
|
||||||
|
except Exception as e:
|
||||||
|
self.info_label.configure(text=f"Erro ao abrir preview: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
bins_text = ", ".join(p.name for p in bundle.bin_paths)
|
||||||
|
self.info_label.configure(
|
||||||
|
text=(
|
||||||
|
f"{self.idx+1}/{len(self.all_bundles)} | "
|
||||||
|
f"sample='{bundle.sample_id}' | "
|
||||||
|
f"bins={len(bundle.bin_paths)} [{bins_text}]"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_to_label(self, label_index: int):
|
||||||
|
if label_index < 0 or label_index >= len(self.labels):
|
||||||
|
return
|
||||||
|
|
||||||
|
label = self.labels[label_index]
|
||||||
|
bundle = self.all_bundles[self.idx]
|
||||||
|
|
||||||
|
label_root = self.out_root / label
|
||||||
|
dst_preview = self.unique_file(label_root / "previews" / bundle.preview_path.name)
|
||||||
|
dst_meta = self.unique_file(label_root / "metas" / bundle.meta_path.name)
|
||||||
|
dst_bins = [self.unique_file(label_root / "bins" / p.name) for p in bundle.bin_paths]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.move:
|
||||||
|
shutil.move(str(bundle.preview_path), str(dst_preview))
|
||||||
|
shutil.move(str(bundle.meta_path), str(dst_meta))
|
||||||
|
for src_bin, dst_bin in zip(bundle.bin_paths, dst_bins):
|
||||||
|
shutil.move(str(src_bin), str(dst_bin))
|
||||||
|
else:
|
||||||
|
shutil.copy2(bundle.preview_path, dst_preview)
|
||||||
|
shutil.copy2(bundle.meta_path, dst_meta)
|
||||||
|
for src_bin, dst_bin in zip(bundle.bin_paths, dst_bins):
|
||||||
|
shutil.copy2(src_bin, dst_bin)
|
||||||
|
|
||||||
|
self.logger.log(
|
||||||
|
action="assign",
|
||||||
|
sample_dir=bundle.sample_dir,
|
||||||
|
preview_path=bundle.preview_path,
|
||||||
|
meta_path=bundle.meta_path,
|
||||||
|
bin_paths=bundle.bin_paths,
|
||||||
|
label=label,
|
||||||
|
dest_sample_dir=label_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.history.append({
|
||||||
|
"action": "assign",
|
||||||
|
"label": label,
|
||||||
|
"sample_src": bundle.sample_dir,
|
||||||
|
"preview_src": bundle.preview_path,
|
||||||
|
"meta_src": bundle.meta_path,
|
||||||
|
"bin_srcs": list(bundle.bin_paths),
|
||||||
|
"preview_dst": dst_preview,
|
||||||
|
"meta_dst": dst_meta,
|
||||||
|
"bin_dsts": dst_bins,
|
||||||
|
"moved": self.move,
|
||||||
|
"index": self.idx,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.status_var.set(
|
||||||
|
f"{'Movido' if self.move else 'Copiado'} → '{label}': {bundle.sample_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.idx += 1
|
||||||
|
if self.idx >= len(self.all_bundles):
|
||||||
|
messagebox.showinfo("Concluído", "Você chegou ao final da fila!")
|
||||||
|
self.root.destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Erro", f"Falha ao copiar/mover amostra: {e}")
|
||||||
|
self.status_var.set(f"ERRO: {e}")
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
if not self.history:
|
||||||
|
return
|
||||||
|
|
||||||
|
last = self.history.pop()
|
||||||
|
if last["action"] != "assign":
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
preview_src = Path(last["preview_src"])
|
||||||
|
meta_src = Path(last["meta_src"])
|
||||||
|
bin_srcs = [Path(p) for p in last["bin_srcs"]]
|
||||||
|
|
||||||
|
preview_dst = Path(last["preview_dst"])
|
||||||
|
meta_dst = Path(last["meta_dst"])
|
||||||
|
bin_dsts = [Path(p) for p in last["bin_dsts"]]
|
||||||
|
|
||||||
|
if last["moved"]:
|
||||||
|
shutil.move(str(preview_dst), str(preview_src))
|
||||||
|
shutil.move(str(meta_dst), str(meta_src))
|
||||||
|
for dst_bin, src_bin in zip(bin_dsts, bin_srcs):
|
||||||
|
shutil.move(str(dst_bin), str(src_bin))
|
||||||
|
else:
|
||||||
|
if preview_dst.exists():
|
||||||
|
preview_dst.unlink()
|
||||||
|
if meta_dst.exists():
|
||||||
|
meta_dst.unlink()
|
||||||
|
for dst_bin in bin_dsts:
|
||||||
|
if dst_bin.exists():
|
||||||
|
dst_bin.unlink()
|
||||||
|
|
||||||
|
self.logger.log(
|
||||||
|
action="undo",
|
||||||
|
sample_dir=Path(last["sample_src"]),
|
||||||
|
preview_path=preview_src,
|
||||||
|
meta_path=meta_src,
|
||||||
|
bin_paths=bin_srcs,
|
||||||
|
label=last["label"],
|
||||||
|
dest_sample_dir=self.out_root / last["label"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.idx = max(0, min(last.get("index", self.idx), len(self.all_bundles) - 1))
|
||||||
|
self.status_var.set(f"Desfeito ← '{last['label']}': {preview_src.stem}")
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Erro", f"Falha no undo: {e}")
|
||||||
|
self.status_var.set(f"ERRO: {e}")
|
||||||
|
|
||||||
|
def on_key(self, event):
|
||||||
|
ch = event.keysym.lower()
|
||||||
|
|
||||||
|
if ch in [str(i) for i in range(1, 10)] or ch == "0":
|
||||||
|
label_idx = 9 if ch == "0" else int(ch) - 1
|
||||||
|
self.send_to_label(label_idx)
|
||||||
|
return
|
||||||
|
|
||||||
|
if ch in ("space", "s", "n", "right"):
|
||||||
|
bundle = self.all_bundles[self.idx]
|
||||||
|
self.logger.log(
|
||||||
|
action="skip",
|
||||||
|
sample_dir=bundle.sample_dir,
|
||||||
|
preview_path=bundle.preview_path,
|
||||||
|
meta_path=bundle.meta_path,
|
||||||
|
bin_paths=bundle.bin_paths,
|
||||||
|
label=None,
|
||||||
|
dest_sample_dir=None,
|
||||||
|
)
|
||||||
|
self.status_var.set(f"Pulada → {bundle.sample_id}")
|
||||||
|
self.idx += 1
|
||||||
|
if self.idx >= len(self.all_bundles):
|
||||||
|
messagebox.showinfo("Concluído", "Você chegou ao final da fila!")
|
||||||
|
self.root.destroy()
|
||||||
|
return
|
||||||
|
self.render()
|
||||||
|
return
|
||||||
|
|
||||||
|
if ch in ("p", "left"):
|
||||||
|
self.idx = max(0, self.idx - 1)
|
||||||
|
self.status_var.set("Voltou uma amostra.")
|
||||||
|
self.render()
|
||||||
|
return
|
||||||
|
|
||||||
|
if ch == "b":
|
||||||
|
self.undo()
|
||||||
|
return
|
||||||
|
|
||||||
|
if ch in ("q", "escape"):
|
||||||
|
self.root.destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
class SetupWindow:
|
||||||
|
def __init__(self):
|
||||||
|
self.root = tk.Tk()
|
||||||
|
self.root.title("Configurar - Sample Bundle Sorter")
|
||||||
|
self.root.geometry("720x520")
|
||||||
|
|
||||||
|
frm_in = tk.LabelFrame(self.root, text="Pastas de entrada (raízes com subpastas de amostras)")
|
||||||
|
frm_in.pack(fill=tk.BOTH, expand=False, padx=10, pady=8)
|
||||||
|
|
||||||
|
self.inputs_listbox = tk.Listbox(frm_in, height=6)
|
||||||
|
self.inputs_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=6, pady=6)
|
||||||
|
|
||||||
|
btns_in = tk.Frame(frm_in)
|
||||||
|
btns_in.pack(side=tk.RIGHT, fill=tk.Y, padx=6, pady=6)
|
||||||
|
tk.Button(btns_in, text="Adicionar pasta...", command=self.add_input).pack(fill=tk.X, pady=2)
|
||||||
|
tk.Button(btns_in, text="Remover selecionada", command=self.remove_selected).pack(fill=tk.X, pady=2)
|
||||||
|
tk.Button(btns_in, text="Limpar lista", command=self.clear_inputs).pack(fill=tk.X, pady=2)
|
||||||
|
|
||||||
|
frm_out = tk.LabelFrame(self.root, text="Pasta de saída (raiz do dataset rotulado)")
|
||||||
|
frm_out.pack(fill=tk.X, expand=False, padx=10, pady=8)
|
||||||
|
self.out_root_var = tk.StringVar(value="")
|
||||||
|
tk.Entry(frm_out, textvariable=self.out_root_var).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6, pady=6)
|
||||||
|
tk.Button(frm_out, text="Escolher...", command=self.choose_out_root).pack(side=tk.RIGHT, padx=6, pady=6)
|
||||||
|
|
||||||
|
frm_labels = tk.LabelFrame(self.root, text="Labels (classes) separadas por vírgula")
|
||||||
|
frm_labels.pack(fill=tk.X, expand=False, padx=10, pady=8)
|
||||||
|
self.labels_var = tk.StringVar(value="chao,chao_cana,chao_erva,chao_cana_erva,cana,cana_erva,erva")
|
||||||
|
tk.Entry(frm_labels, textvariable=self.labels_var).pack(fill=tk.X, padx=6, pady=6)
|
||||||
|
|
||||||
|
frm_opts = tk.LabelFrame(self.root, text="Opções")
|
||||||
|
frm_opts.pack(fill=tk.X, expand=False, padx=10, pady=8)
|
||||||
|
self.move_var = tk.BooleanVar(value=False)
|
||||||
|
self.resume_var = tk.BooleanVar(value=True)
|
||||||
|
self.height_var = tk.IntVar(value=512)
|
||||||
|
|
||||||
|
tk.Checkbutton(frm_opts, text="Mover em vez de copiar", variable=self.move_var).pack(anchor="w", padx=6)
|
||||||
|
tk.Checkbutton(frm_opts, text="Retomar (pular itens já logados)", variable=self.resume_var).pack(anchor="w", padx=6)
|
||||||
|
|
||||||
|
frm_height = tk.Frame(frm_opts)
|
||||||
|
frm_height.pack(fill=tk.X, padx=6, pady=6)
|
||||||
|
tk.Label(frm_height, text="Altura de exibição (px):").pack(side=tk.LEFT)
|
||||||
|
tk.Entry(frm_height, textvariable=self.height_var, width=6).pack(side=tk.LEFT, padx=6)
|
||||||
|
|
||||||
|
tk.Button(self.root, text="Iniciar classificação", command=self.start).pack(pady=10)
|
||||||
|
|
||||||
|
tk.Label(
|
||||||
|
self.root,
|
||||||
|
text="Teclas: 1..9 (0=10ª), espaço/n/→=pular, p/←=anterior, b=undo, q/Esc=sair",
|
||||||
|
fg="#555",
|
||||||
|
).pack(pady=4)
|
||||||
|
|
||||||
|
self.result = None
|
||||||
|
|
||||||
|
def add_input(self):
|
||||||
|
p = filedialog.askdirectory(title="Selecione a pasta raiz contendo as amostras")
|
||||||
|
if p:
|
||||||
|
self.inputs_listbox.insert(tk.END, p)
|
||||||
|
|
||||||
|
def remove_selected(self):
|
||||||
|
sel = list(self.inputs_listbox.curselection())
|
||||||
|
for idx in reversed(sel):
|
||||||
|
self.inputs_listbox.delete(idx)
|
||||||
|
|
||||||
|
def clear_inputs(self):
|
||||||
|
self.inputs_listbox.delete(0, tk.END)
|
||||||
|
|
||||||
|
def choose_out_root(self):
|
||||||
|
p = filedialog.askdirectory(title="Selecione a pasta raiz de saída")
|
||||||
|
if p:
|
||||||
|
self.out_root_var.set(p)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
inputs = [self.inputs_listbox.get(i) for i in range(self.inputs_listbox.size())]
|
||||||
|
out_root = self.out_root_var.get().strip()
|
||||||
|
labels_raw = self.labels_var.get().strip()
|
||||||
|
labels = [s.strip() for s in labels_raw.split(",") if s.strip()]
|
||||||
|
|
||||||
|
if not inputs:
|
||||||
|
messagebox.showwarning("Faltando entradas", "Adicione pelo menos uma pasta de entrada.")
|
||||||
|
return
|
||||||
|
if not out_root:
|
||||||
|
messagebox.showwarning("Faltando saída", "Escolha a pasta raiz de saída.")
|
||||||
|
return
|
||||||
|
if not labels:
|
||||||
|
messagebox.showwarning("Faltando labels", "Informe pelo menos uma label (classe).")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.result = (
|
||||||
|
[Path(p) for p in inputs],
|
||||||
|
labels,
|
||||||
|
Path(out_root),
|
||||||
|
self.move_var.get(),
|
||||||
|
self.resume_var.get(),
|
||||||
|
self.height_var.get(),
|
||||||
|
)
|
||||||
|
self.root.destroy()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.root.mainloop()
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
def run_with_gui_setup():
|
||||||
|
setup = SetupWindow()
|
||||||
|
res = setup.run()
|
||||||
|
if not res:
|
||||||
|
return
|
||||||
|
|
||||||
|
input_folders, labels, out_root, move, resume, height = res
|
||||||
|
|
||||||
|
bundles = collect_all_bundles(input_folders)
|
||||||
|
if not bundles:
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Sem amostras",
|
||||||
|
"Nenhuma amostra válida encontrada.\n"
|
||||||
|
"Cada amostra precisa ter preview/meta e os .bin das câmeras "
|
||||||
|
"(CAM_A/CAM_B/CAM_C, _CAM_A/_CAM_B/_CAM_C ou _cam0/_cam1/_cam2).",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
app = SampleBundleSorterApp(
|
||||||
|
bundles=bundles,
|
||||||
|
labels=labels,
|
||||||
|
out_root=out_root,
|
||||||
|
move=move,
|
||||||
|
resume=resume,
|
||||||
|
display_height=height,
|
||||||
|
)
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Classificar manualmente amostras no novo formato do módulo multiespectral."
|
||||||
|
)
|
||||||
|
parser.add_argument("--inputs", nargs="+", help="Pastas-raiz contendo subpastas de amostras")
|
||||||
|
parser.add_argument("--labels", nargs="+", help="Labels (classes) mapeadas para teclas 1..9/0")
|
||||||
|
parser.add_argument("--out-root", help="Pasta raiz de saída")
|
||||||
|
parser.add_argument("--move", action="store_true", help="Mover em vez de copiar")
|
||||||
|
parser.add_argument("--resume", action="store_true", help="Pular itens já presentes no sorting_log.csv")
|
||||||
|
parser.add_argument("--display-height", type=int, default=512, help="Altura de exibição do preview (px)")
|
||||||
|
parser.add_argument("--no-gui-setup", action="store_true", help="Não abrir a GUI de setup")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if (not args.inputs or not args.labels or not args.out_root) and not args.no_gui_setup:
|
||||||
|
run_with_gui_setup()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.inputs or not args.labels or not args.out_root:
|
||||||
|
print("ERRO: informe --inputs, --labels e --out-root ou use a GUI.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
input_folders = [Path(p) for p in args.inputs]
|
||||||
|
labels = args.labels
|
||||||
|
out_root = Path(args.out_root)
|
||||||
|
|
||||||
|
bundles = collect_all_bundles(input_folders)
|
||||||
|
if not bundles:
|
||||||
|
print(
|
||||||
|
"Nenhuma amostra válida encontrada. "
|
||||||
|
"Formatos aceitos: preview.png + meta.json + CAM_A/B/C.bin, "
|
||||||
|
"ou <base>.png/json + <base>_CAM_A/B/C.bin, "
|
||||||
|
"ou formato antigo <base>_cam0/cam1/cam2.bin."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
app = SampleBundleSorterApp(
|
||||||
|
bundles=bundles,
|
||||||
|
labels=labels,
|
||||||
|
out_root=out_root,
|
||||||
|
move=args.move,
|
||||||
|
resume=args.resume,
|
||||||
|
display_height=args.display_height,
|
||||||
|
)
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue