Files

337 lines
9.9 KiB
Python

# Dataset cleaner for DLAA / anti-aliasing training
# Moves weak / broken / duplicate images into _rejected instead of deleting them.
import argparse
import json
import shutil
import warnings
from pathlib import Path
from PIL import Image, ImageStat, ImageOps, ImageFilter
warnings.filterwarnings("ignore", category=DeprecationWarning)
IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"}
def is_image_file(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in IMG_EXTS
def safe_open(path: Path):
try:
img = Image.open(path)
img.verify()
img = Image.open(path).convert("RGB")
return img, None
except Exception as e:
return None, f"corrupt:{e}"
def luma(img: Image.Image) -> Image.Image:
return ImageOps.grayscale(img)
def brightness_score(gray: Image.Image) -> float:
stat = ImageStat.Stat(gray.resize((256, 256), Image.BILINEAR))
return float(stat.mean[0])
def contrast_score(gray: Image.Image) -> float:
stat = ImageStat.Stat(gray.resize((256, 256), Image.BILINEAR))
return float(stat.stddev[0])
def detail_score(gray: Image.Image) -> float:
small = gray.resize((256, 256), Image.BILINEAR)
px = small.load()
w, h = small.size
total = 0.0
count = 0
for y in range(1, h - 1):
for x in range(1, w - 1):
c = px[x, y]
lap = abs(
4 * c
- px[x - 1, y]
- px[x + 1, y]
- px[x, y - 1]
- px[x, y + 1]
)
total += lap
count += 1
return total / max(count, 1)
def edge_density_score(gray: Image.Image, threshold: int = 18) -> float:
small = gray.resize((256, 256), Image.BILINEAR)
px = small.load()
w, h = small.size
hits = 0
count = 0
for y in range(1, h - 1):
for x in range(1, w - 1):
gx = abs(px[x + 1, y] - px[x - 1, y])
gy = abs(px[x, y + 1] - px[x, y - 1])
if gx + gy >= threshold:
hits += 1
count += 1
return hits / max(count, 1)
def thin_detail_score(gray: Image.Image) -> float:
small = gray.resize((256, 256), Image.BILINEAR)
blur = small.filter(ImageFilter.GaussianBlur(radius=1.0))
a = list(small.getdata())
b = list(blur.getdata())
total = sum(abs(x - y) for x, y in zip(a, b))
return total / max(len(a), 1)
def document_page_score(gray: Image.Image):
small = gray.resize((256, 256), Image.BILINEAR)
vals = list(small.getdata())
white_ratio = sum(v > 235 for v in vals) / len(vals)
dark_ratio = sum(v < 80 for v in vals) / len(vals)
mid_ratio = sum(80 <= v <= 220 for v in vals) / len(vals)
return white_ratio, dark_ratio, mid_ratio
def avg_hash(gray: Image.Image, size: int = 8) -> str:
small = gray.resize((size, size), Image.BILINEAR)
vals = list(small.getdata())
mean = sum(vals) / len(vals)
return "".join("1" if v >= mean else "0" for v in vals)
def hamming(a: str, b: str) -> int:
return sum(x != y for x, y in zip(a, b))
def reject_reasons(path: Path, img: Image.Image, args, seen_hashes) -> list[str]:
reasons = []
w, h = img.size
if w < args.min_width or h < args.min_height:
reasons.append(f"too_small:{w}x{h}")
aspect = max(w / max(h, 1), h / max(w, 1))
if aspect > args.max_aspect:
reasons.append(f"bad_aspect:{aspect:.2f}")
gray = luma(img)
bright = brightness_score(gray)
contrast = contrast_score(gray)
detail = detail_score(gray)
edge_density = edge_density_score(gray, threshold=args.edge_threshold)
thin_detail = thin_detail_score(gray)
white_ratio, dark_ratio, mid_ratio = document_page_score(gray)
if bright < args.min_brightness:
reasons.append(f"too_dark:{bright:.1f}")
elif bright > args.max_brightness:
reasons.append(f"too_bright:{bright:.1f}")
if contrast < args.min_contrast:
reasons.append(f"low_contrast:{contrast:.1f}")
looks_like_document = (
white_ratio >= args.doc_white_ratio
and args.doc_dark_min <= dark_ratio <= args.doc_dark_max
and mid_ratio <= args.doc_mid_max
)
if looks_like_document:
reasons.append(
f"document_page:white={white_ratio:.2f},dark={dark_ratio:.2f},mid={mid_ratio:.2f}"
)
useful_for_aa = (
detail >= args.min_detail
and edge_density >= args.min_edge_density
and thin_detail >= args.min_thin_detail
)
# Keep strong edge/detail images
edge_exception = (
edge_density >= args.edge_keep_density
and detail >= args.edge_keep_detail
)
# Keep HD portraits
portrait_like_exception = (
w >= args.portrait_min_width
and h >= args.portrait_min_height
and contrast >= args.portrait_min_contrast
and thin_detail >= args.portrait_min_thin_detail
)
if not useful_for_aa and not edge_exception and not portrait_like_exception:
reasons.append(
f"not_useful_for_aa:"
f"detail={detail:.2f},"
f"edge={edge_density:.3f},"
f"thin={thin_detail:.2f}"
)
ah = avg_hash(gray)
for old_hash, old_path in seen_hashes:
if hamming(ah, old_hash) <= args.duplicate_hamming:
reasons.append(f"duplicate_of:{old_path.name}")
break
seen_hashes.append((ah, path))
return reasons
def unique_destination(path: Path, rejected_dir: Path) -> Path:
target = rejected_dir / path.name
if not target.exists():
return target
stem = path.stem
suffix = path.suffix
i = 1
while True:
candidate = rejected_dir / f"{stem}_{i:03d}{suffix}"
if not candidate.exists():
return candidate
i += 1
def clean_dataset(args):
dataset_dir = Path(args.dataset)
rejected_dir = dataset_dir / args.rejected_dir
rejected_dir.mkdir(parents=True, exist_ok=True)
files = sorted([p for p in dataset_dir.iterdir() if is_image_file(p)])
if not files:
print(f"No images found in: {dataset_dir}")
return
report = []
seen_hashes = []
kept = 0
rejected = 0
print("==============================================================")
print("DLAA Dataset Cleaner")
print("Mode: anti-aliasing / thin-detail focused")
print("==============================================================")
print(f"Dataset : {dataset_dir}")
print(f"Images : {len(files)}")
print(f"Action : {'DRY RUN' if args.dry_run else f'MOVE TO {args.rejected_dir}'}")
print("==============================================================")
for path in files:
img, error = safe_open(path)
if error:
reasons = [error]
else:
reasons = reject_reasons(path, img, args, seen_hashes)
if reasons:
rejected += 1
report.append(
{
"file": path.name,
"action": "reject",
"reasons": reasons,
}
)
print(f"[REJECT] {path.name} -> {', '.join(reasons)}")
if not args.dry_run:
dst = unique_destination(path, rejected_dir)
shutil.move(str(path), str(dst))
else:
kept += 1
report.append(
{
"file": path.name,
"action": "keep",
"reasons": [],
}
)
report_path = dataset_dir / "clean_report.json"
report_path.write_text(
json.dumps(report, indent=2, ensure_ascii=False),
encoding="utf-8",
)
print("==============================================================")
print(f"Kept : {kept}")
print(f"Rejected : {rejected}")
print(f"Report : {report_path}")
if args.dry_run:
print("Dry run only. Nothing was moved.")
else:
print(f"Rejected images moved to: {rejected_dir}")
print("==============================================================")
def parse_args():
parser = argparse.ArgumentParser(
description="Clean image dataset for DLAA / anti-aliasing training"
)
parser.add_argument("--dataset", type=str, default="dataset")
parser.add_argument("--rejected-dir", type=str, default="_rejected")
parser.add_argument("--min-width", type=int, default=512)
parser.add_argument("--min-height", type=int, default=512)
parser.add_argument("--max-aspect", type=float, default=3.0)
parser.add_argument("--min-brightness", type=float, default=10.0)
parser.add_argument("--max-brightness", type=float, default=248.0)
parser.add_argument("--min-contrast", type=float, default=10.0)
# Soft defaults: keeps portraits, fur, hair, wires and useful textures.
parser.add_argument("--min-detail", type=float, default=2.6)
parser.add_argument("--min-edge-density", type=float, default=0.025)
parser.add_argument("--min-thin-detail", type=float, default=1.8)
parser.add_argument("--edge-threshold", type=int, default=18)
parser.add_argument("--edge-keep-density", type=float, default=0.055)
parser.add_argument("--edge-keep-detail", type=float, default=2.6)
parser.add_argument("--portrait-min-width", type=int, default=768)
parser.add_argument("--portrait-min-height", type=int, default=768)
parser.add_argument("--portrait-min-contrast", type=float, default=8.0)
parser.add_argument("--portrait-min-thin-detail", type=float, default=1.4)
parser.add_argument("--doc-white-ratio", type=float, default=0.72)
parser.add_argument("--doc-dark-min", type=float, default=0.015)
parser.add_argument("--doc-dark-max", type=float, default=0.22)
parser.add_argument("--doc-mid-max", type=float, default=0.22)
parser.add_argument("--duplicate-hamming", type=int, default=2)
parser.add_argument("--dry-run", action="store_true")
return parser.parse_args()
if __name__ == "__main__":
clean_dataset(parse_args())