253 lines
9.8 KiB
Python
253 lines
9.8 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from opentoonz_line_tools.autoclose import AutoCloseSettings, autoclose_gaps, closure_overlay
|
|
from opentoonz_line_tools.cleanup import BlueCleanupSettings, cleanup_blue_lines
|
|
from opentoonz_line_tools.regions import RegionSettings, label_fill_regions, labels_to_preview
|
|
|
|
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Evaluate OpenToonz Line Tools on research image sets.")
|
|
source = parser.add_mutually_exclusive_group(required=True)
|
|
source.add_argument("--dataset-root", type=Path, help="Dataset root containing images/train and images/val.")
|
|
source.add_argument("--image-root", type=Path, help="Directory containing images directly or recursively.")
|
|
parser.add_argument("--output-dir", required=True, type=Path, help="Directory for metrics and contact sheet outputs.")
|
|
parser.add_argument("--image-glob", default="panel*.*", help="Glob used with --image-root. Default: panel*.*")
|
|
parser.add_argument("--region-min-area", default=256, type=int)
|
|
parser.add_argument("--max-regions", default=128, type=int)
|
|
parser.add_argument("--max-endpoints", default=600, type=int)
|
|
parser.add_argument("--thumb-width", default=220, type=int)
|
|
return parser.parse_args()
|
|
|
|
|
|
def dataset_image_paths(dataset_root: Path) -> list[Path]:
|
|
image_root = dataset_root / "images"
|
|
return [
|
|
path
|
|
for path in sorted(image_root.glob("*/*.*"))
|
|
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
|
|
]
|
|
|
|
|
|
def root_image_paths(image_root: Path, image_glob: str) -> list[Path]:
|
|
return [
|
|
path
|
|
for path in sorted(image_root.rglob(image_glob))
|
|
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
|
|
]
|
|
|
|
|
|
def label_count(dataset_root: Path, image_path: Path) -> int:
|
|
rel = image_path.relative_to(dataset_root)
|
|
label_path = dataset_root / "labels" / rel.parent.name / f"{image_path.stem}.txt"
|
|
if not label_path.exists():
|
|
return 0
|
|
return sum(1 for line in label_path.read_text(encoding="utf-8").splitlines() if line.strip())
|
|
|
|
|
|
def fit(image: Image.Image, width: int) -> Image.Image:
|
|
height = max(1, round(image.height * width / image.width))
|
|
return image.resize((width, height), Image.Resampling.LANCZOS)
|
|
|
|
|
|
def make_contact_row(
|
|
*,
|
|
index: int,
|
|
rel: Path,
|
|
label_count_value: int,
|
|
line_ratio: float,
|
|
skipped: str,
|
|
region_count: int,
|
|
images: list[Image.Image],
|
|
thumb_width: int,
|
|
) -> Image.Image:
|
|
thumbs = [fit(image, thumb_width) for image in images]
|
|
label_h = 42
|
|
row_h = max(thumb.height for thumb in thumbs) + label_h
|
|
strip = Image.new("RGB", (thumb_width * len(thumbs), row_h), "white")
|
|
draw = ImageDraw.Draw(strip)
|
|
title = f"{index:02d} {rel.name} labels={label_count_value} line={line_ratio:.3f} regions={region_count}"
|
|
draw.text((4, 4), title, fill=(0, 0, 0))
|
|
if skipped:
|
|
draw.text((4, 20), skipped[:90], fill=(180, 0, 0))
|
|
for column, thumb in enumerate(thumbs):
|
|
strip.paste(thumb, (column * thumb_width, label_h))
|
|
return strip
|
|
|
|
|
|
def write_contact_sheet(rows: list[Image.Image], path: Path) -> None:
|
|
width = max(row.width for row in rows)
|
|
height = sum(row.height for row in rows)
|
|
contact = Image.new("RGB", (width, height), "white")
|
|
y = 0
|
|
for row in rows:
|
|
contact.paste(row, (0, y))
|
|
y += row.height
|
|
contact.save(path, quality=90)
|
|
|
|
|
|
def write_summary(summary: dict[str, Any], path: Path) -> None:
|
|
rows = summary["rows"]
|
|
max_regions = summary["settings"]["regions"]["max_regions"]
|
|
lines = [
|
|
f"# {summary['title']}",
|
|
"",
|
|
f"Dataset: `{summary['dataset']}`",
|
|
"",
|
|
"## Summary",
|
|
"",
|
|
f"- Images: {summary['image_count']}",
|
|
f"- Avg blue line ratio: {summary['averages']['line_ratio']}",
|
|
f"- Avg cleanup sec: {summary['averages']['cleanup_sec']}",
|
|
f"- Avg autoclose sec: {summary['averages']['autoclose_sec']}",
|
|
f"- Autoclose skipped: {summary['counts']['autoclose_skipped']}/{summary['image_count']}",
|
|
f"- Avg region count: {summary['averages']['region_count']}",
|
|
f"- Region capped at {max_regions}: {summary['counts']['region_capped']}/{summary['image_count']}",
|
|
"",
|
|
"## Rows",
|
|
"",
|
|
"| file | split | labels | line ratio | skip | regions |",
|
|
"|---|---:|---:|---:|---|---:|",
|
|
]
|
|
for row in rows:
|
|
lines.append(
|
|
f"| {row['file']} | {row['split']} | {row['label_count']} | {row['line_ratio']} | "
|
|
f"{row['autoclose_skipped']} | {row['region_count']} |"
|
|
)
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
if args.dataset_root:
|
|
source_root = args.dataset_root
|
|
paths = dataset_image_paths(source_root)
|
|
title = "MC/MS Research Image Evaluation"
|
|
source_kind = "dataset"
|
|
else:
|
|
source_root = args.image_root
|
|
paths = root_image_paths(source_root, args.image_glob)
|
|
title = "Panel Crop Image Evaluation"
|
|
source_kind = "image_root"
|
|
if not paths:
|
|
raise SystemExit(f"No images found under {source_root} with the selected input mode.")
|
|
cleanup_settings = BlueCleanupSettings()
|
|
autoclose_settings = AutoCloseSettings(max_endpoints=args.max_endpoints)
|
|
region_settings = RegionSettings(
|
|
min_area=args.region_min_area,
|
|
max_regions=args.max_regions,
|
|
ignore_border_regions=True,
|
|
)
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
contact_rows: list[Image.Image] = []
|
|
for index, path in enumerate(paths, 1):
|
|
rel = path.relative_to(source_root)
|
|
source_pil = Image.open(path).convert("RGB")
|
|
source = np.asarray(source_pil)
|
|
labels = label_count(source_root, path) if args.dataset_root else 0
|
|
|
|
start = time.perf_counter()
|
|
clean = cleanup_blue_lines(source, cleanup_settings)
|
|
cleanup_sec = time.perf_counter() - start
|
|
|
|
start = time.perf_counter()
|
|
closed = autoclose_gaps(clean["line_mask"], autoclose_settings)
|
|
autoclose_sec = time.perf_counter() - start
|
|
|
|
start = time.perf_counter()
|
|
regions = label_fill_regions(closed["closed_mask"], region_settings)
|
|
region_sec = time.perf_counter() - start
|
|
|
|
line_ratio = float(clean["line_mask"].mean())
|
|
row = {
|
|
"index": index,
|
|
"split": rel.parts[1] if args.dataset_root and len(rel.parts) > 1 else rel.parent.as_posix() or ".",
|
|
"file": rel.name,
|
|
"relative_path": str(rel),
|
|
"width": int(source.shape[1]),
|
|
"height": int(source.shape[0]),
|
|
"label_count": labels,
|
|
"line_ratio": round(line_ratio, 6),
|
|
"cleanup_sec": round(cleanup_sec, 3),
|
|
"autoclose_sec": round(autoclose_sec, 3),
|
|
"segments": len(closed["segments"]),
|
|
"autoclose_skipped": closed["skipped"],
|
|
"region_sec": round(region_sec, 3),
|
|
"region_count": len(regions["regions"]),
|
|
"region_capped": len(regions["regions"]) >= region_settings.max_regions,
|
|
}
|
|
rows.append(row)
|
|
|
|
contact_rows.append(
|
|
make_contact_row(
|
|
index=index,
|
|
rel=rel,
|
|
label_count_value=labels,
|
|
line_ratio=line_ratio,
|
|
skipped=closed["skipped"],
|
|
region_count=len(regions["regions"]),
|
|
images=[
|
|
source_pil,
|
|
Image.fromarray(clean["line_rgb"]),
|
|
Image.fromarray(closure_overlay(clean["line_mask"], closed["closed_mask"])),
|
|
Image.fromarray(labels_to_preview(regions["label_map"], closed["closed_mask"])),
|
|
],
|
|
thumb_width=args.thumb_width,
|
|
)
|
|
)
|
|
print(
|
|
f"{index:02d}/{len(paths)} {rel} "
|
|
f"line={line_ratio:.4f} skip={closed['skipped'] or '-'} regions={len(regions['regions'])}"
|
|
)
|
|
|
|
summary = {
|
|
"title": title,
|
|
"dataset": str(source_root),
|
|
"source_kind": source_kind,
|
|
"image_glob": args.image_glob if args.image_root else None,
|
|
"image_count": len(rows),
|
|
"settings": {
|
|
"cleanup": cleanup_settings.__dict__,
|
|
"autoclose": autoclose_settings.__dict__,
|
|
"regions": region_settings.__dict__,
|
|
},
|
|
"averages": {
|
|
"line_ratio": round(sum(row["line_ratio"] for row in rows) / max(1, len(rows)), 6),
|
|
"cleanup_sec": round(sum(row["cleanup_sec"] for row in rows) / max(1, len(rows)), 3),
|
|
"autoclose_sec": round(sum(row["autoclose_sec"] for row in rows) / max(1, len(rows)), 3),
|
|
"region_sec": round(sum(row["region_sec"] for row in rows) / max(1, len(rows)), 3),
|
|
"region_count": round(sum(row["region_count"] for row in rows) / max(1, len(rows)), 2),
|
|
},
|
|
"counts": {
|
|
"autoclose_skipped": sum(1 for row in rows if row["autoclose_skipped"]),
|
|
"region_capped": sum(1 for row in rows if row["region_capped"]),
|
|
"low_blue_signal_lt_0_03": sum(1 for row in rows if row["line_ratio"] < 0.03),
|
|
},
|
|
"rows": rows,
|
|
}
|
|
(args.output_dir / "metrics.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
write_summary(summary, args.output_dir / "summary.md")
|
|
write_contact_sheet(contact_rows, args.output_dir / "contact_sheet.jpg")
|
|
print(f"wrote {args.output_dir}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|