Newer
Older
TIASJudgeModel / scripts / make_synthetic_segmentation.py
"""セグメンテーションの評価(`tias-seg eval`)を手元で通すための合成データを生成する.

実データ(TongueSegmentation の `input/`)と同じ構成を作る:

    <out>/input/train/{sample,label}/*.jpg
    <out>/input/test/cropped/{sample,label}/*.jpg
    <out>/input/test_extra/cropped/{sample,label}/*.jpg
    <out>/input/test/fullsize/{sample,label}/*.jpg

撮影画像(fullsize)は青みがかった灰色の背景に舌の代わりの赤い楕円を描いたもの,正解マスクは
楕円の内側を 255 にしたグレースケール JPEG(実データと同じく JPEG のため境界に中間値を含む).
切り出し済み(cropped)は楕円の外接矩形に余白を付けて切り出したもの.一部の画像には楕円の外に
小さな赤い点を置き,小領域除去が働く場面を作る.ダミーの ONNX モデルは
`scripts/make_dummy_seg_models.py` で作る.

使い方:
    uv run python scripts/make_synthetic_segmentation.py --out data/synthetic_seg --per-set 8
"""

from __future__ import annotations

import argparse
import logging
import shutil
import sys
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw

logger = logging.getLogger(__name__)

# 出力先がこのスクリプトの生成物であることを示す目印(--overwrite で消すのは目印のある所だけ).
MARKER_FILE = ".synthetic_segmentation"

# (評価セットのディレクトリ, 撮影画像か).
SETS: tuple[tuple[str, bool], ...] = (
    ("train", False),
    ("test/cropped", False),
    ("test_extra/cropped", False),
    ("test/fullsize", True),
)

BACKGROUND_RGB = (110, 120, 150)  # 青みがかった灰色(ダミーの ResUNet が背景と判定する色)
TONGUE_RGB = (200, 95, 105)  # 赤みのある舌の色
COLOR_JITTER = 10
PIXEL_NOISE_STD = 5.0
MIN_SIZE = 200
MAX_SIZE = 360
SPOT_PROBABILITY = 0.5  # 楕円の外に小さな赤い点を置く確率
JPEG_QUALITY = 95


def make_fullsize(rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
    """撮影画像を模した画像と正解マスクを 1 組作る.

    Args:
        rng: 乱数生成器.

    Returns:
        (RGB 画像 `(H, W, 3)` uint8, 正解マスク `(H, W)` bool).
    """
    width = int(rng.integers(MIN_SIZE, MAX_SIZE + 1))
    height = int(rng.integers(MIN_SIZE, MAX_SIZE + 1))
    ew = int(rng.integers(width // 4, width // 2))
    eh = int(rng.integers(height // 4, height // 2))
    cx = int(rng.integers(ew // 2 + 5, width - ew // 2 - 5))
    cy = int(rng.integers(eh // 2 + 5, height - eh // 2 - 5))
    mask_img = Image.new("L", (width, height), 0)
    ImageDraw.Draw(mask_img).ellipse(
        (cx - ew // 2, cy - eh // 2, cx + ew // 2, cy + eh // 2), fill=255
    )
    mask = np.asarray(mask_img) > 0  # (H, W)

    # 背景は横方向に少し明るさが変わる(一様な色だけにしない).
    gradient = np.linspace(-15.0, 15.0, width)[None, :, None]
    background = np.array(BACKGROUND_RGB, dtype=np.float64) + gradient
    tongue = np.array(TONGUE_RGB, dtype=np.float64) + rng.integers(
        -COLOR_JITTER, COLOR_JITTER + 1, size=3
    )
    pixels = np.where(mask[..., None], tongue, background)
    pixels = pixels + rng.normal(0.0, PIXEL_NOISE_STD, size=(height, width, 3))

    if rng.random() < SPOT_PROBABILITY:
        # 舌と同じ色の小さな点(小領域除去で消えるべき誤検出の種).正解マスクには含めない.
        spot = Image.new("L", (width, height), 0)
        sx = int(rng.integers(3, width - 8))
        sy = int(rng.integers(3, height - 8))
        ImageDraw.Draw(spot).ellipse((sx, sy, sx + 4, sy + 4), fill=255)
        spot_mask = (np.asarray(spot) > 0) & ~mask
        pixels[spot_mask] = tongue
    return np.clip(pixels, 0, 255).astype(np.uint8), mask


def crop_around(
    rgb: np.ndarray, mask: np.ndarray, rng: np.random.Generator
) -> tuple[np.ndarray, np.ndarray]:
    """正解マスクの外接矩形に余白を付けて切り出す(切り出し済みの画像を模す).

    Args:
        rgb: RGB 画像(`(H, W, 3)`, uint8).
        mask: 正解マスク(`(H, W)`, bool).
        rng: 乱数生成器.

    Returns:
        (切り出した RGB 画像, 切り出した正解マスク).
    """
    ys, xs = np.nonzero(mask)
    height, width = mask.shape
    mx = int(rng.integers(2, max(3, (xs.max() - xs.min()) // 8)))
    my = int(rng.integers(2, max(3, (ys.max() - ys.min()) // 8)))
    x0, x1 = max(0, xs.min() - mx), min(width, xs.max() + 1 + mx)
    y0, y1 = max(0, ys.min() - my), min(height, ys.max() + 1 + my)
    return rgb[y0:y1, x0:x1], mask[y0:y1, x0:x1]


def generate(out_dir: Path, per_set: int, seed: int) -> int:
    """合成データを生成する.

    Args:
        out_dir: 出力先(空または存在しないディレクトリ).
        per_set: 評価セットあたりの枚数.
        seed: 乱数 seed.

    Returns:
        生成した組の数.
    """
    rng = np.random.default_rng(seed)
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / MARKER_FILE).write_text(f"seed={seed}\nper_set={per_set}\n", encoding="utf-8")
    serial = 0
    for set_dir, fullsize in SETS:
        sample_dir = out_dir / "input" / set_dir / "sample"
        label_dir = out_dir / "input" / set_dir / "label"
        sample_dir.mkdir(parents=True, exist_ok=True)
        label_dir.mkdir(parents=True, exist_ok=True)
        for _ in range(per_set):
            rgb, mask = make_fullsize(rng)
            if not fullsize:
                rgb, mask = crop_around(rgb, mask, rng)
            name = f"seg_{serial:06d}.jpg"
            Image.fromarray(rgb).save(sample_dir / name, quality=JPEG_QUALITY)
            Image.fromarray((mask * 255).astype(np.uint8)).save(
                label_dir / name, quality=JPEG_QUALITY
            )
            serial += 1
        logger.info("%s: %d 組", set_dir, per_set)
    return serial


def _prepare_out_dir(out_dir: Path, overwrite: bool) -> None:
    if not out_dir.exists():
        return
    if not out_dir.is_dir():
        raise ValueError(f"出力先がディレクトリではない: {out_dir}")
    if not any(out_dir.iterdir()):
        return
    if not overwrite:
        raise ValueError(f"出力先が空でない: {out_dir}(作り直すには --overwrite を付ける)")
    if not (out_dir / MARKER_FILE).is_file():
        raise ValueError(
            f"出力先に合成データの目印 {MARKER_FILE} が無いため削除しない: {out_dir}"
            "(合成データ以外を誤って消さないため)"
        )
    logger.info("既存の合成データを削除する: %s", out_dir)
    shutil.rmtree(out_dir)


def _parse_args(argv: list[str] | None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="セグメンテーションの評価用の合成データを生成する")
    parser.add_argument(
        "--out",
        type=Path,
        default=Path("data/synthetic_seg"),
        help="出力先(既定: data/synthetic_seg)",
    )
    parser.add_argument("--per-set", type=int, default=8, help="評価セットあたりの枚数(既定: 8)")
    parser.add_argument("--seed", type=int, default=0, help="乱数 seed(既定: 0)")
    parser.add_argument(
        "--overwrite",
        action="store_true",
        help="既存の合成データを削除して作り直す(目印ファイルのある出力先のみ.models/ も消える)",
    )
    args = parser.parse_args(argv)
    if args.per_set < 1:
        parser.error("--per-set は 1 以上を指定する")
    return args


def main(argv: list[str] | None = None) -> int:
    """エントリポイント.

    Args:
        argv: コマンドライン引数(None なら `sys.argv[1:]`).

    Returns:
        終了コード(成功 0・入力の問題 1).
    """
    logging.basicConfig(
        level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
    )
    args = _parse_args(argv)
    try:
        _prepare_out_dir(args.out, args.overwrite)
    except ValueError as e:
        logger.error("%s", e)
        return 1
    count = generate(args.out, args.per_set, args.seed)
    logger.info("合成データを生成した: %d 組 -> %s", count, args.out / "input")
    return 0


if __name__ == "__main__":
    sys.exit(main())