"""合成データセットを生成する(手元で実データと同じコードパスを流すため).
実データと同じ構成 `<out>/images/<label>_<severity>/*.png` と `<out>/manifest.csv` を作る.
画像は黒背景 (0, 0, 0) の RGB PNG に,クラスごとに色の異なる楕円(舌の代わり)を描いたもの.
使い方:
uv run python scripts/make_synthetic_dataset.py --out data/synthetic --per-dir 10 --seed 0
"""
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
from tias_judge_model.data.classes import ALL_DIR_NAMES, parse_dir_name
from tias_judge_model.data.manifest import build_manifest_records, write_manifest
logger = logging.getLogger(__name__)
# 出力先がこのスクリプトの生成物であることを示す目印.
# --overwrite で消してよいのは目印のあるディレクトリだけにし,実データを誤って消さないようにする.
MARKER_FILE = ".synthetic_dataset"
# クラスごとの基準色(RGB).学習 smoke テストで学習可能なよう,互いに十分離しておく.
BASE_COLORS: dict[str, tuple[int, int, int]] = {
"pale": (235, 205, 200),
"light_red": (225, 140, 140),
"red": (215, 40, 45),
"dark_red": (120, 20, 30),
"purple": (110, 50, 140),
}
# mild は strong より「正常(淡紅)」寄りの薄い色にする(境界例を模すため).
MILD_BLEND_TO_NORMAL = 0.25
MIN_SIZE = 96
MAX_SIZE = 192
COLOR_JITTER = 8
PIXEL_NOISE_STD = 4.0
def _class_color(label: str, severity: str) -> np.ndarray:
base = np.array(BASE_COLORS[label], dtype=np.float64)
if severity == "mild":
normal = np.array(BASE_COLORS["light_red"], dtype=np.float64)
base = (1.0 - MILD_BLEND_TO_NORMAL) * base + MILD_BLEND_TO_NORMAL * normal
return base
def make_image(color: np.ndarray, rng: np.random.Generator) -> Image.Image:
"""黒背景に楕円を描いた RGB 画像を 1 枚作る.
Args:
color: 楕円の基準色(`(3,)`, float64, 値域 0〜255).
rng: 乱数生成器.
Returns:
RGB 画像(サイズは `MIN_SIZE`〜`MAX_SIZE` の範囲でランダム).
"""
width = int(rng.integers(MIN_SIZE, MAX_SIZE + 1))
height = int(rng.integers(MIN_SIZE, MAX_SIZE + 1))
margin_x = int(rng.integers(0, width // 10 + 1))
margin_y = int(rng.integers(0, height // 10 + 1))
mask_img = Image.new("L", (width, height), 0)
ImageDraw.Draw(mask_img).ellipse(
(margin_x, margin_y, width - 1 - margin_x, height - 1 - margin_y), fill=255
)
mask = np.asarray(mask_img) > 0 # (H, W), bool
jittered = color + rng.integers(-COLOR_JITTER, COLOR_JITTER + 1, size=3)
noise = rng.normal(0.0, PIXEL_NOISE_STD, size=(height, width, 3))
tongue = np.clip(jittered + noise, 1, 255) # 舌領域が背景の黒 (0,0,0) と紛れないよう 1 以上
pixels = np.zeros((height, width, 3), dtype=np.uint8) # (H, W, 3)
pixels[mask] = tongue[mask].astype(np.uint8)
return Image.fromarray(pixels)
def generate(out_dir: Path, per_dir: int, seed: int) -> int:
"""合成データセットを生成する.
Args:
out_dir: 出力先(空または存在しないディレクトリ).
per_dir: 1 ディレクトリあたりの枚数.
seed: 乱数 seed.
Returns:
生成した画像の枚数.
"""
rng = np.random.default_rng(seed)
images_dir = out_dir / "images"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / MARKER_FILE).write_text(f"seed={seed}\nper_dir={per_dir}\n", encoding="utf-8")
serial = 0
for dir_name in ALL_DIR_NAMES:
label, severity = parse_dir_name(dir_name)
color = _class_color(label, severity)
class_dir = images_dir / dir_name
class_dir.mkdir(parents=True, exist_ok=True)
for _ in range(per_dir):
make_image(color, rng).save(class_dir / f"syn_{serial:06d}.png")
serial += 1
logger.info("%s/: %d 枚", dir_name, per_dir)
records = build_manifest_records(images_dir)
write_manifest(records, out_dir / "manifest.csv")
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"), help="出力先(既定: data/synthetic)"
)
parser.add_argument(
"--per-dir", type=int, default=10, help="1 ディレクトリあたりの枚数(既定: 10)"
)
parser.add_argument("--seed", type=int, default=0, help="乱数 seed(既定: 0)")
parser.add_argument(
"--overwrite",
action="store_true",
help="既存の合成データを削除して作り直す(目印ファイルのある出力先のみ)",
)
args = parser.parse_args(argv)
if args.per_dir < 1:
parser.error("--per-dir は 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_dir, args.seed)
logger.info("合成データを生成した: %d 枚 -> %s", count, args.out)
return 0
if __name__ == "__main__":
sys.exit(main())