0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

データ構造完全解剖 - FHIBEデータセットのディレクトリ構成とメタデータ解析

0
Last updated at Posted at 2025-12-05

データ構造完全解剖 - FHIBEデータセットのディレクトリ構成とメタデータ解析

はじめに

こんにちは、しゅんです。今回の記事はFHIBEデータセットとは?— ソニーAIが公開した公平性評価の新データセットの続編となります。

FHIBEデータセットのディレクトリ構成とメタデータの統計情報を解析し、その構造について深掘りします。Day 2では、データセットのディレクトリ構成を理解し、fhibe_downsampled.csvファイルを基にデータの統計情報をMarkdown形式で出力する方法について説明します(GitHubに公開しています)。

データセットは非常に容量大きいので、少ない方のu.gT5_rFTA_downsampled_publicを使ってます

少ない方と言いましたが、実際サイズは178.41 GBです。自分の環境で1週間以上かかってダウンロードしました。

目次

  1. 環境構築
  2. Code
  3. 結果
  4. まとめ

環境構築

main.py スクリプトを実行すると、FHIBEデータセットの構造やメタデータに関するレポートをMarkdown形式で出力できます。

依存パッケージのインストール

まず、必要な依存パッケージをインストールします。初回のみ以下のコマンドを実行してください。

python -m venv .venv
pip install rich
pip install tabulate
pip install matplotlib
pip install japanize-matplotlib

Code

main.py
from __future__ import annotations

"""FHIBEデータセットのディレクトリ構造と注釈統計を可視化するCLIレポーター。"""

import argparse
import ast
import csv
import os
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence
import japanize_matplotlib  

try:
    from rich.console import Console
    from rich.table import Table
except ImportError:
    Console = None
    Table = None

try:
    from tabulate import tabulate
except ImportError:
    tabulate = None

try:
    import matplotlib.pyplot as plt
except ImportError:
    plt = None


DEFAULT_FOCUS_DIRS = [
    "data",
    "data/raw",
    "data/raw/fhibe_downsampled",
    "data/processed",
    "data/processed/fhibe_downsampled",
    "data/processed/fhibe_face_crop_align",
    "data/annotator_metadata",
    "data/aggregated_results",
    "data/protocol",
    "results",
]

IGNORE_BASENAMES = {".DS_Store"}

console = Console(highlight=False, soft_wrap=True) if Console else None
USE_RICH = console is not None and Table is not None


@dataclass(frozen=True)
class AttributeField:
    column: str
    title_en: str
    title_ja: str
    treat_as_list: bool = False

    @property
    def title_bilingual(self) -> str:
        return f"{self.title_en} / {self.title_ja}"


def bilingual(en: str, ja: str) -> str:
    """英語と日本語を同時に表示するためのヘルパー。"""
    return f"{en} / {ja}"


def format_pct(count: int, total: int) -> str:
    """割合を0.1%精度で返す。"""
    if total <= 0:
        return "0.0%"
    return f"{(count / total) * 100:.1f}%"


def make_table(title: str, columns: Sequence[str]) -> Table:
    """指定タイトルと列でRichテーブルを生成する。"""
    if not USE_RICH or Table is None:
        raise RuntimeError("Rich tables are unavailable.")
    table = Table(title=title, header_style="bold cyan")
    for column in columns:
        table.add_column(column)
    return table


def format_list(items: Sequence[str]) -> str:
    """代表リストを折り返し表示しやすい文字列に変換する。"""
    return "\n".join(items) if items else "-"


def append_markdown_table(
    markdown_sections: list[str] | None, title: str, headers: Sequence[str], rows: list[Sequence[str]]
) -> None:
    """tabulateでMarkdownテーブルを生成してセクションに追加する。"""
    if markdown_sections is None or tabulate is None:
        return
    markdown_sections.append(
        f"### {title}\n" + tabulate(rows, headers=headers, tablefmt="github")
    )


def print_rule(title: str) -> None:
    """Richの罫線 or フォールバックを出力する。"""
    if USE_RICH and console is not None:
        console.rule(title)
    else:
        print("\n" + title)
        print("-" * len(title))


def slugify(text: str) -> str:
    """グラフ保存用の簡易スラグを生成する。"""
    return "".join(ch.lower() if ch.isalnum() else "_" for ch in text).strip("_")


def save_bar_chart(labels: list[str], values: list[float], title: str, xlabel: str, ylabel: str, path: Path) -> None:
    """matplotlibで棒グラフを保存する。"""
    if plt is None:
        raise RuntimeError("matplotlib backend is unavailable.")
    plt.figure(figsize=(8, 5))
    plt.bar(labels, values, color="#2E86AB")
    plt.title(title)
    plt.xlabel(xlabel)
    plt.ylabel(ylabel)
    plt.xticks(rotation=30, ha="right")
    plt.tight_layout()
    plt.savefig(path)
    plt.close()


ATTRIBUTE_FIELDS: Sequence[AttributeField] = (
    AttributeField("pronoun", "Self-identified pronoun(s)", "自己申告の代名詞", True),
    AttributeField("ancestry", "Ancestry bucket(s)", "祖先カテゴリ", True),
    AttributeField("natural_skin_color", "Natural skin color palette", "自然肌色パレット"),
    AttributeField(
        "apparent_skin_color", "Perceived skin color palette", "見た目肌色パレット"
    ),
    AttributeField("hairstyle", "Hair style category", "ヘアスタイル区分"),
    AttributeField("natural_hair_type", "Natural hair type", "自然な髪質"),
    AttributeField("apparent_hair_type", "Perceived hair type", "見た目の髪質"),
    AttributeField("natural_hair_color", "Natural hair color(s)", "自然な髪色", True),
    AttributeField("apparent_hair_color", "Perceived hair color(s)", "見た目の髪色", True),
    AttributeField("facial_hairstyle", "Facial hairstyle(s)", "顔周りの毛スタイル", True),
    AttributeField(
        "natural_facial_haircolor", "Natural facial hair color(s)", "自然な顔毛の色", True
    ),
    AttributeField(
        "apparent_facial_haircolor", "Perceived facial hair color(s)", "見た目の顔毛の色", True
    ),
)

csv.field_size_limit(min(sys.maxsize, 2_147_483_647))


AGE_BUCKETS = [
    (0, 17, "00-17"),
    (18, 24, "18-24"),
    (25, 34, "25-34"),
    (35, 44, "35-44"),
    (45, 54, "45-54"),
    (55, 64, "55-64"),
    (65, 150, "65+"),
]


def parse_args() -> argparse.Namespace:
    """CLIで受け取るパラメータを定義する。"""
    parser = argparse.ArgumentParser(
        description=(
            "Summarize the FHIBE downsampled dataset structure, metadata and attribute labels.\n"
            "Use --dataset-root to point to the extracted FHIBE archive."
        )
    )
    parser.add_argument(
        "--dataset-root",
        type=Path,
        default=os.environ.get("FHIBE_DATA_DIR"),
        help="Path to the extracted FHIBE dataset (defaults to $FHIBE_DATA_DIR).",
    )
    parser.add_argument(
        "--focus-dirs",
        nargs="*",
        default=None,
        help="Relative directories (from dataset root) to include in the directory summary.",
    )
    parser.add_argument(
        "--max-samples",
        type=int,
        default=4,
        help="Number of sample files/subdirectories to show inside each directory.",
    )
    parser.add_argument(
        "--top-n",
        type=int,
        default=5,
        help="Number of top values to show for attribute/camera distributions.",
    )
    parser.add_argument(
        "--markdown",
        action="store_true",
        help="Also print GitHub-flavored Markdown tables (requires tabulate).",
    )
    parser.add_argument(
        "--plot-dir",
        type=Path,
        help="If set, save bar charts (requires matplotlib) to this directory.",
    )
    return parser.parse_args()


def resolve_dataset_root(raw_path: Path | None) -> Path:
    """指定パスを展開し、存在確認まで行う。"""
    if raw_path is None:
        raise SystemExit(
            "Dataset path is not set. Provide --dataset-root or export FHIBE_DATA_DIR."
        )
    dataset_root = raw_path.expanduser().resolve()
    if not dataset_root.exists():
        raise SystemExit(f"Dataset root does not exist: {dataset_root}")
    return dataset_root


def describe_directories(
    dataset_root: Path, focus_dirs: Sequence[str], max_samples: int
) -> list[dict]:
    """注目ディレクトリごとの件数やサンプルをまとめる。"""
    summaries: list[dict] = []
    for rel_path in focus_dirs:
        absolute = dataset_root / rel_path
        if not absolute.exists():
            continue
        summaries.append(describe_directory(dataset_root, absolute, max_samples))
    return summaries


def describe_directory(dataset_root: Path, absolute: Path, max_samples: int) -> dict:
    """単一ディレクトリ内のファイル/フォルダ数と代表例を返す。"""
    entries = sorted(absolute.iterdir(), key=lambda path: path.name)
    sample_files: list[str] = []
    sample_dirs: list[str] = []
    file_count = 0
    dir_count = 0
    for entry in entries:
        if entry.name in IGNORE_BASENAMES:
            continue
        if entry.is_dir():
            dir_count += 1
            if len(sample_dirs) < max_samples:
                sample_dirs.append(entry.name)
        else:
            file_count += 1
            if len(sample_files) < max_samples:
                sample_files.append(entry.name)
    rel = absolute.relative_to(dataset_root)
    return {
        "path": str(rel),
        "files": file_count,
        "dirs": dir_count,
        "sample_files": sample_files,
        "sample_dirs": sample_dirs,
    }


class NumericStats:
    """数値列の最小・最大・平均を逐次更新で保持する。"""
    def __init__(self) -> None:
        self.count = 0
        self._min: float | None = None
        self._max: float | None = None
        self._sum = 0.0

    def add(self, value: float | int | None) -> None:
        if value is None:
            return
        value_f = float(value)
        self.count += 1
        self._sum += value_f
        self._min = value_f if self._min is None else min(self._min, value_f)
        self._max = value_f if self._max is None else max(self._max, value_f)

    def to_dict(self) -> dict:
        if self.count == 0:
            return {}
        return {
            "count": self.count,
            "min": self._min,
            "max": self._max,
            "avg": self._sum / self.count,
        }


def summarize_metadata(metadata_csv: Path) -> dict:
    """画像メタ情報、カメラ、属性ラベル、年齢の統計を集計する。"""
    attribute_counters: dict[str, Counter[str]] = {f.column: Counter() for f in ATTRIBUTE_FIELDS}
    camera_models: Counter[str] = Counter()
    subject_ids: set[str] = set()
    row_count = 0
    primary_count = 0
    height_stats = NumericStats()
    width_stats = NumericStats()
    age_stats = NumericStats()
    age_buckets: Counter[str] = Counter()

    with metadata_csv.open(encoding="utf-8") as fh:
        reader = csv.DictReader(fh)
        for row in reader:
            row_count += 1
            subject_id = row.get("subject_id", "").strip()
            if subject_id:
                subject_ids.add(subject_id)

            if parse_bool(row.get("is_primary")):
                primary_count += 1

            height_stats.add(parse_float(row.get("image_height")))
            width_stats.add(parse_float(row.get("image_width")))

            manufacturer = (row.get("manufacturer") or "").strip()
            model = (row.get("model") or "").strip()
            if manufacturer or model:
                label = " / ".join(value for value in (manufacturer, model) if value)
                camera_models[label] += 1

            age_value = parse_float(row.get("age"))
            if age_value is not None:
                age_stats.add(age_value)
                age_buckets[bucketize_age(age_value)] += 1

            for field in ATTRIBUTE_FIELDS:
                values = normalize_attribute(row.get(field.column), field.treat_as_list)
                for value in values:
                    attribute_counters[field.column][value] += 1

    return {
        "records": row_count,
        "subjects": len(subject_ids),
        "primary_count": primary_count,
        "height": height_stats.to_dict(),
        "width": width_stats.to_dict(),
        "age": age_stats.to_dict(),
        "age_buckets": age_buckets,
        "camera_models": camera_models,
        "attributes": attribute_counters,
    }


def parse_bool(value: str | None) -> bool:
    if value is None:
        return False
    return value.strip().lower() in {"true", "1", "yes"}


def parse_float(value: str | None) -> float | None:
    if value is None:
        return None
    value = value.strip()
    if not value:
        return None
    try:
        return float(value)
    except ValueError:
        return None


def normalize_attribute(raw_value: str | None, treat_as_list: bool) -> list[str]:
    """CSV内表現にかかわらずラベル文字列のリストに正規化する。"""
    if raw_value is None:
        return []
    value = raw_value.strip()
    if not value or value == "[]":
        return []
    if treat_as_list:
        try:
            parsed = ast.literal_eval(value)
        except (SyntaxError, ValueError):
            parsed = value
        if isinstance(parsed, (list, tuple, set)):
            return [str(item).strip() for item in parsed if str(item).strip()]
        parsed_str = str(parsed).strip()
        return [parsed_str] if parsed_str else []
    return [value]


def bucketize_age(age: float) -> str:
    for lower, upper, label in AGE_BUCKETS:
        if lower <= age <= upper:
            return label
    return "unbounded"


def summarize_annotator_table(csv_path: Path) -> dict:
    """アノテータ属性CSVの件数・年齢帯・Pronoun/Ancestry頻度を集計する。"""
    prefix = "QAannotator" if csv_path.name.startswith("QA") else "annotator"
    age_column = f"{prefix}_age"
    pronoun_column = f"{prefix}_pronoun"
    ancestry_column = f"{prefix}_ancestry"

    pronoun_counter: Counter[str] = Counter()
    ancestry_counter: Counter[str] = Counter()
    ages = NumericStats()
    age_buckets: Counter[str] = Counter()
    row_count = 0

    with csv_path.open(encoding="utf-8") as fh:
        reader = csv.DictReader(fh)
        for row in reader:
            if not any(row.values()):
                continue
            row_count += 1
            for pronoun in normalize_attribute(row.get(pronoun_column), True):
                pronoun_counter[pronoun] += 1
            for ancestry in normalize_attribute(row.get(ancestry_column), True):
                ancestry_counter[ancestry] += 1
            age_value = parse_float(row.get(age_column))
            if age_value is not None:
                ages.add(age_value)
                age_buckets[bucketize_age(age_value)] += 1

    return {
        "path": csv_path,
        "rows": row_count,
        "pronoun": pronoun_counter,
        "ancestry": ancestry_counter,
        "ages": ages.to_dict(),
        "age_buckets": age_buckets,
    }


def render_directory_summary(
    dir_summaries: Iterable[dict], markdown_sections: list[str] | None
) -> None:
    """ディレクトリ構成をRichテーブルとMarkdownで出力する。"""
    title = bilingual("Directory overview", "ディレクトリ概要")
    markdown_rows: list[list[str]] = []
    if USE_RICH:
        table = make_table(
            title,
            [
                bilingual("Path", "パス"),
                bilingual("Dirs/Files", "ディレクトリ/ファイル数"),
                bilingual("Sample dirs", "代表ディレクトリ"),
                bilingual("Sample files", "代表ファイル"),
            ],
        )
    else:
        print_rule(title)
    for summary in dir_summaries:
        dir_counts = f"{summary['dirs']} / {summary['files']}"
        sample_dirs = format_list(summary["sample_dirs"])
        sample_files = format_list(summary["sample_files"])
        if USE_RICH:
            table.add_row(summary["path"], dir_counts, sample_dirs, sample_files)
        else:
            print(f"- {summary['path']}")
            print(f"  - {bilingual('Dirs/Files', '件数')}: {dir_counts}")
            if sample_dirs != "-":
                print(f"  - {bilingual('Sample dirs', '代表ディレクトリ')}: {sample_dirs}")
            if sample_files != "-":
                print(f"  - {bilingual('Sample files', '代表ファイル')}: {sample_files}")
        markdown_rows.append(
            [summary["path"], dir_counts, sample_dirs.replace("\n", ", "), sample_files.replace("\n", ", ")]
        )
    if USE_RICH:
        console.print(table)
    append_markdown_table(
        markdown_sections,
        title,
        [
            bilingual("Path", "パス"),
            bilingual("Dirs/Files", "ディレクトリ/ファイル数"),
            bilingual("Sample dirs", "代表ディレクトリ"),
            bilingual("Sample files", "代表ファイル"),
        ],
        markdown_rows,
    )


def render_metadata_summary(
    summary: dict, top_n: int, markdown_sections: list[str] | None
) -> None:
    """データセット全体の統計・属性別分布をRichテーブルで出力する。"""
    section_title = bilingual("Metadata (fhibe_downsampled.csv)", "メタデータ概要")
    print_rule(section_title)

    stats_headers = [bilingual("Metric", "指標"), bilingual("Value", "値")]
    stats_rows: list[list[str]] = [
        [bilingual("Images (rows)", "画像件数"), f"{summary['records']:,}"],
        [bilingual("Unique subjects", "ユニーク被写体数"), f"{summary['subjects']:,}"],
        [bilingual("Primary hero shots", "主画像件数"), f"{summary['primary_count']:,}"],
    ]
    if summary["height"] and summary["width"]:
        height = summary["height"]
        width = summary["width"]
        stats_rows.append(
            [
                bilingual("Image resolution (HxW)", "画像解像度 (縦×横)"),
                f"min {height['min']:.0f}x{width['min']:.0f}, "
                f"avg {height['avg']:.0f}x{width['avg']:.0f}, "
                f"max {height['max']:.0f}x{width['max']:.0f}",
            ]
        )
    if summary["age"]:
        age = summary["age"]
        stats_rows.append(
            [
                bilingual("Age range", "年齢範囲"),
                f"min {age['min']:.1f}, avg {age['avg']:.1f}, max {age['max']:.1f}",
            ]
        )
    if USE_RICH:
        stats_table = make_table(section_title + " - " + bilingual("Core stats", "基本統計"), stats_headers)
        for metric, value in stats_rows:
            stats_table.add_row(metric, value)
        console.print(stats_table)
    else:
        for metric, value in stats_rows:
            print(f"- {metric}: {value}")
    append_markdown_table(markdown_sections, section_title, stats_headers, stats_rows)

    if summary["age_buckets"]:
        age_title = bilingual("Age buckets", "年齢帯分布")
        age_headers = [
            bilingual("Bucket", "年齢帯"),
            bilingual("Count", "件数"),
            bilingual("Share", "構成比"),
        ]
        age_rows = []
        if USE_RICH:
            age_table = make_table(age_title, age_headers)
        else:
            print(f"\n{age_title}:")
        for label, count in summary["age_buckets"].most_common():
            share = format_pct(count, summary["records"])
            if USE_RICH:
                age_table.add_row(label, str(count), share)
            else:
                print(f"  - {label}: {count} ({share})")
            age_rows.append([label, str(count), share])
        if USE_RICH:
            console.print(age_table)
        append_markdown_table(
            markdown_sections,
            age_title,
            age_headers,
            age_rows,
        )

    camera_title = bilingual("Camera manufacturers/models", "カメラメーカー/モデル")
    camera_headers = [
        bilingual("Model", "機種"),
        bilingual("Images", "件数"),
        bilingual("Share", "構成比"),
    ]
    if USE_RICH:
        camera_table = make_table(camera_title, camera_headers)
    else:
        print(f"\n{camera_title}:")
    camera_rows = []
    for model, count in summary["camera_models"].most_common(top_n):
        share = format_pct(count, summary["records"])
        if USE_RICH:
            camera_table.add_row(model, str(count), share)
        else:
            print(f"- {model}: {count} ({share})")
        camera_rows.append([model, str(count), share])
    if USE_RICH:
        console.print(camera_table)
    append_markdown_table(
        markdown_sections,
        camera_title,
        camera_headers,
        camera_rows,
    )

    attributes = summary["attributes"]
    for field in ATTRIBUTE_FIELDS:
        counter = attributes[field.column]
        total = sum(counter.values())
        if not total:
            message = f"{field.title_bilingual}: no annotations / データなし"
            if USE_RICH and console is not None:
                console.print(f"[yellow]{message}[/yellow]")
            else:
                print(message)
            continue
        attr_headers = [
            bilingual("Label", "ラベル"),
            bilingual("Count", "件数"),
            bilingual("Share", "構成比"),
        ]
        if USE_RICH:
            attr_table = make_table(field.title_bilingual, attr_headers)
        else:
            print(f"\n{field.title_bilingual}:")
        attr_rows = []
        for label, count in counter.most_common(top_n):
            share = format_pct(count, summary["records"])
            if USE_RICH:
                attr_table.add_row(label, str(count), share)
            else:
                print(f"  - {label}: {count} ({share})")
            attr_rows.append([label, str(count), share])
        if USE_RICH:
            console.print(attr_table)
        append_markdown_table(
            markdown_sections,
            field.title_bilingual,
            attr_headers,
            attr_rows,
        )


def render_annotator_summary(
    title: str, summary: dict, top_n: int, markdown_sections: list[str] | None
) -> None:
    """アノテータ統計を表形式で出力する。"""
    section_title = f"{title} ({summary['path'].name})"
    print_rule(section_title)

    info_headers = [bilingual("Metric", "指標"), bilingual("Value", "値")]
    info_rows = [[bilingual("Annotators", "アノテータ数"), str(summary["rows"])]]
    if summary["ages"]:
        age = summary["ages"]
        info_rows.append(
            [
                bilingual("Age stats", "年齢統計"),
                f"min {age['min']:.1f}, avg {age['avg']:.1f}, max {age['max']:.1f}",
            ]
        )
    if USE_RICH:
        info_table = make_table(section_title + " - Info", info_headers)
        for metric, value in info_rows:
            info_table.add_row(metric, value)
        console.print(info_table)
    else:
        for metric, value in info_rows:
            print(f"- {metric}: {value}")
    append_markdown_table(markdown_sections, section_title + " - Info", info_headers, info_rows)

    if summary["age_buckets"]:
        bucket_headers = [
            bilingual("Bucket", "年齢帯"),
            bilingual("Count", "件数"),
        ]
        bucket_rows = []
        if USE_RICH:
            bucket_table = make_table(
                section_title + " - " + bilingual("Age buckets", "年齢帯"), bucket_headers
            )
        else:
            print(f"\n{bilingual('Age buckets', '年齢帯')}:")
        for label, count in summary["age_buckets"].most_common():
            if USE_RICH:
                bucket_table.add_row(label, str(count))
            else:
                print(f"  - {label}: {count}")
            bucket_rows.append([label, str(count)])
        if USE_RICH:
            console.print(bucket_table)
        append_markdown_table(
            markdown_sections,
            section_title + " - " + bilingual("Age buckets", "年齢帯"),
            bucket_headers,
            bucket_rows,
        )

    pronoun_headers = [
        bilingual("Pronouns", "代名詞"),
        bilingual("Count", "件数"),
    ]
    if USE_RICH:
        pronoun_table = make_table(
            section_title + " - " + bilingual("Pronouns", "代名詞"), pronoun_headers
        )
    else:
        print(f"\n{bilingual('Pronouns', '代名詞')}:")
    pronoun_rows = []
    for label, count in summary["pronoun"].most_common(top_n):
        if USE_RICH:
            pronoun_table.add_row(label, str(count))
        else:
            print(f"  - {label}: {count}")
        pronoun_rows.append([label, str(count)])
    if USE_RICH:
        console.print(pronoun_table)
    append_markdown_table(
        markdown_sections,
        section_title + " - " + bilingual("Pronouns", "代名詞"),
        pronoun_headers,
        pronoun_rows,
    )

    ancestry_headers = [
        bilingual("Ancestry", "祖先カテゴリ"),
        bilingual("Count", "件数"),
    ]
    if USE_RICH:
        ancestry_table = make_table(
            section_title + " - " + bilingual("Ancestry", "祖先カテゴリ"), ancestry_headers
        )
    else:
        print(f"\n{bilingual('Ancestry', '祖先カテゴリ')}:")
    ancestry_rows = []
    for label, count in summary["ancestry"].most_common(top_n):
        if USE_RICH:
            ancestry_table.add_row(label, str(count))
        else:
            print(f"  - {label}: {count}")
        ancestry_rows.append([label, str(count)])
    if USE_RICH:
        console.print(ancestry_table)
    append_markdown_table(
        markdown_sections,
        section_title + " - " + bilingual("Ancestry", "祖先カテゴリ"),
        ancestry_headers,
        ancestry_rows,
    )


def generate_plots(
    metadata_summary: dict,
    annotator_summaries: list[dict],
    top_n: int,
    plot_dir: Path,
) -> None:
    """必要なグラフを生成してファイルに保存する。"""
    if plt is None:
        raise SystemExit("グラフ描画には matplotlib が必要です。`pip install matplotlib` を実行してください。")
    plot_dir = plot_dir.expanduser()
    plot_dir.mkdir(parents=True, exist_ok=True)

    def plot_counter(counter: Counter, title: str, xlabel: str, ylabel: str, filename: str) -> None:
        if not counter:
            return
        items = counter.most_common(top_n)
        labels = [label for label, _ in items]
        values = [value for _, value in items]
        save_bar_chart(labels, values, title, xlabel, ylabel, plot_dir / filename)

    age_buckets: Counter = metadata_summary["age_buckets"]
    if age_buckets:
        plot_counter(
            age_buckets,
            bilingual("Age distribution", "年齢帯分布"),
            bilingual("Age bucket", "年齢帯"),
            bilingual("Images", "画像数"),
            "age_distribution.png",
        )

    camera_items = metadata_summary["camera_models"].most_common(top_n)
    if camera_items:
        labels = [label for label, _ in camera_items]
        values = [value for _, value in camera_items]
        save_bar_chart(
            labels,
            values,
            bilingual("Top camera models", "カメラ機種上位"),
            bilingual("Model", "機種"),
            bilingual("Images", "画像数"),
            plot_dir / f"camera_top_{top_n}.png",
        )

    attributes = metadata_summary["attributes"]
    attr_specs = [
        ("pronoun", bilingual("Pronoun distribution", "代名詞分布")),
        ("ancestry", bilingual("Ancestry distribution", "祖先カテゴリ分布")),
    ]
    for key, title in attr_specs:
        counter = attributes.get(key, Counter())
        if counter:
            plot_counter(
                counter,
                title,
                bilingual("Label", "ラベル"),
                bilingual("Count", "件数"),
                f"{slugify(key)}_distribution.png",
            )

    for annotator in annotator_summaries:
        prefix = "qa_" if annotator["path"].name.startswith("QA") else "annotator_"
        pron_counter = annotator["pronoun"]
        if pron_counter:
            plot_counter(
                pron_counter,
                bilingual("Annotator pronouns", "アノテータ代名詞") + f" ({annotator['path'].name})",
                bilingual("Pronoun", "代名詞"),
                bilingual("Annotators", "アノテータ数"),
                f"{prefix}pronouns.png",
            )
        ancestry_counter = annotator["ancestry"]
        if ancestry_counter:
            plot_counter(
                ancestry_counter,
                bilingual("Annotator ancestry", "アノテータ祖先カテゴリ") + f" ({annotator['path'].name})",
                bilingual("Ancestry", "祖先カテゴリ"),
                bilingual("Annotators", "アノテータ数"),
                f"{prefix}ancestry.png",
            )


def main() -> None:
    """CLI→集計→Markdown描画の実行フローを束ねるエントリーポイント。"""
    args = parse_args()
    dataset_root = resolve_dataset_root(args.dataset_root)
    focus_dirs = args.focus_dirs or DEFAULT_FOCUS_DIRS
    if args.markdown and tabulate is None:
        raise SystemExit(
            "Markdownテーブル出力には tabulate が必要です。`pip install tabulate` を実行してください。"
        )
    markdown_sections: list[str] | None = [] if args.markdown else None
    plot_dir = args.plot_dir

    metadata_csv = (
        dataset_root / "data" / "processed" / "fhibe_downsampled" / "fhibe_downsampled.csv"
    )
    if not metadata_csv.exists():
        raise SystemExit(f"Metadata CSV not found: {metadata_csv}")

    directory_summaries = describe_directories(dataset_root, focus_dirs, args.max_samples)
    metadata_summary = summarize_metadata(metadata_csv)

    annotator_dir = dataset_root / "data" / "annotator_metadata"
    annotator_summaries = []
    if annotator_dir.exists():
        for csv_name in ("annotator_demographics.csv", "QAannotator_demographics.csv"):
            candidate = annotator_dir / csv_name
            if candidate.exists():
                annotator_summaries.append(summarize_annotator_table(candidate))

    print_rule("# " + bilingual("FHIBE downsampled dataset report", "FHIBEダウンサンプル版レポート"))
    render_directory_summary(directory_summaries, markdown_sections)
    render_metadata_summary(metadata_summary, args.top_n, markdown_sections)
    for summary in annotator_summaries:
        title = (
            bilingual("QA annotator demographics", "QAアノテータ属性")
            if summary["path"].name.startswith("QA")
            else bilingual("Annotator demographics", "アノテータ属性")
        )
        render_annotator_summary(title, summary, args.top_n, markdown_sections)

    if plot_dir:
        generate_plots(metadata_summary, annotator_summaries, args.top_n, plot_dir)

    if markdown_sections:
        print_rule("Markdown tables (copy & paste)")
        for block in markdown_sections:
            print(block)
            print()


if __name__ == "__main__":
    main()

以下のように修正しました。


使い方

全引数紹介

  • --dataset-root PATH(必須): FHIBE を展開したルートディレクトリ。未指定の場合は、FHIBE_DATA_DIR 環境変数を参照し、それも無ければ終了します。
  • --focus-dirs DIR [DIR …]: ディレクトリ概要に載せる相対パスを列挙します。未指定の場合、主要フォルダのデフォルトセットを使用します。
  • --max-samples INT(既定: 4): 各ディレクトリで代表として表示するファイル/フォルダ名の件数を指定します。
  • --top-n INT(既定: 5): 属性分布やカメラモデルなどで表示する上位件数を指定します。
  • --markdown(フラグ): ON にすると GitHub 形式の Markdown テーブルも標準出力に追加されます(tabulate が必要)。
  • --plot-dir PATH: 指定すると、棒グラフを PNG 形式でそのディレクトリに保存します(matplotlib が必要)。

直接パスを指定する場合

python main.py \
  --dataset-root /path/fhibe.20250716.u.gT5_rFTA_downsampled_public \
  --max-samples 5 \
  --top-n 10

環境変数を使用する場合

export FHIBE_DATA_DIR=/path/fhibe.20250716.u.gT5_rFTA_downsampled_public
python main.py

データセットの場所は、--dataset-root で直接指定するか、FHIBE_DATA_DIR 環境変数で指定できます。

結果

スクリプト実行後に出力されるディレクトリ構成のサンプルです。これにより、データセットがどのように構造化されているかを理解することができます。

Directory overview / ディレクトリ概要

Path / パス Dirs/Files / ディレクトリ/ファイル数 Sample dirs / 代表ディレクトリ Sample files / 代表ファイル
data 5 / 0 - -
data/raw 1 / 0 - -
data/raw/fhibe_downsampled 1711 / 1 - -
data/processed 2 / 0 - -
data/processed/fhibe_downsampled 0 / 1 - -
data/processed/fhibe_face_crop_align 1 / 1 - -
data/annotator_metadata 0 / 2 - -
data/aggregated_results 2 / 0 - -
data/protocol 0 / 1 - -
results 2 / 0 - -

Metadata (fhibe_downsampled.csv) / メタデータ概要

Metric / 指標 Value / 値
Images (rows) / 画像件数 10,941
Unique subjects / ユニーク被写体数 1,981
Primary hero shots / 主画像件数 10,318
Image resolution (HxW) / 画像解像度 (縦×横) min 923x921, avg 2005x1567, max 2048x2048
Age range / 年齢範囲 min 18.0, avg 31.7, max 75.0

Age buckets / 年齢帯分布

Bucket / 年齢帯 Count / 件数 Share / 構成比
25-34 4460 40.8%
18-24 3419 31.2%
35-44 1224 11.2%
45-54 960 8.8%
55-64 611 5.6%
65+ 267 2.4%

Camera manufacturers/models / カメラメーカー/モデル

Model / 機種 Images / 件数 Share / 構成比
realme / realme C11 2021 1656 15.1%
TECNO / TECNO CI6 1270 11.6%
Apple / iPhone 11 681 6.2%
samsung / SM-N960F 465 4.3%
Apple / iPhone XR 257 2.3%
samsung / SM-S901E 148 1.4%
Apple / iPhone 12 134 1.2%
Apple / iPhone 11 Pro Max 132 1.2%
Xiaomi / 22101316I 129 1.2%
Apple / iPhone 13 107 1.0%
Apple / iPhone XS Max 104 1.0%
Apple / iPhone 14 Pro 97 0.9%
OPPO / A1601 87 0.8%
HUAWEI / CLT-AL00 86 0.8%
vivo / vivo 1951 85 0.8%
Apple / iPhone 14 82 0.7%
Apple / iPhone 6s 76 0.7%
Apple / iPhone 7 68 0.6%
Apple / iPhone 13 Pro Max 68 0.6%
samsung / SM-A135F 59 0.5%
Apple / iPhone X 58 0.5%
Xiaomi / 22120RN86G 52 0.5%
Xiaomi / M2101K6G 49 0.4%
Apple / iPhone 7 Plus 47 0.4%
Xiaomi / 2201117TG 45 0.4%
Apple / iPhone XS 45 0.4%
Apple / iPhone 8 44 0.4%
Apple / iPhone 12 Pro Max 44 0.4%
Apple / iPhone 13 Pro 43 0.4%
Apple / iPhone 11 Pro 41 0.4%
INFINIX / Infinix X6819 38 0.3%
samsung / SM-A325F 36 0.3%
Xiaomi / Redmi Note 7 Pro 34 0.3%
OPPO / OPPO A96 34 0.3%
Xiaomi / 2109119DG 33 0.3%
Apple / iPhone 14 Pro Max 32 0.3%
samsung / SM-A125F 32 0.3%
samsung / SM-A127F 31 0.3%
Xiaomi / M2006C3MG 30 0.3%
Xiaomi / 2201116TG 29 0.3%
Apple / iPhone 6 29 0.3%
Apple / iPhone 8 Plus 28 0.3%
Xiaomi / M2010J19SG 27 0.2%
Xiaomi / Redmi Note 9S 27 0.2%
HUAWEI / DUB-LX1 26 0.2%
Apple / iPhone 12 Pro 26 0.2%
samsung / SM-A217F 26 0.2%
samsung / SM-A515F 26 0.2%
Xiaomi / M2003J15SC 25 0.2%
Xiaomi / M2102J20SG 24 0.2%
OnePlus / ONEPLUS A6010 23 0.2%
realme / realme 7 Pro 23 0.2%
HUAWEI / MAO-LX9 23 0.2%
HUAWEI / MED-LX9 22 0.2%
Xiaomi / M2101K7BG 21 0.2%
Xiaomi / Redmi Note 9 Pro Max 21 0.2%
Xiaomi / M2006C3LG 21 0.2%
samsung / SM-G973F 21 0.2%
HUAWEI / JNY-LX1 20 0.2%
Google / Pixel 2 XL 20 0.2%
HUAWEI / ANE-LX1 20 0.2%
Xiaomi / M2012K11AG 20 0.2%
samsung / SM-A536E 20 0.2%
OPPO / OPPO A57 19 0.2%
TECNO MOBILE LIMITED / TECNO KE5k 19 0.2%
INFINIX MOBILITY LIMITED / Infinix X657B 19 0.2%
TECNO MOBILE LIMITED / TECNO KG5k 18 0.2%
vivo / V2146 18 0.2%
realme / realme C30s 18 0.2%
Apple / iPhone 6s Plus 18 0.2%
Xiaomi / 220333QAG 17 0.2%
realme / realme 8i 17 0.2%
Xiaomi / M2101K6P 17 0.2%
Xiaomi / Redmi Note 8 Pro 17 0.2%
itel / itel W6001 16 0.1%
OPPO / OPPO A5 2020 16 0.1%
realme / realme C55 16 0.1%
samsung / SM-A307FN 16 0.1%
GIONEE / GIONEE M12 16 0.1%
samsung / SM-N950U 16 0.1%
samsung / SM-A505F 16 0.1%
samsung / SM-M215F 15 0.1%
samsung / SM-A205F 15 0.1%
Xiaomi / M2010J19SI 15 0.1%
HUAWEI / MRD-LX1F 15 0.1%
vivo / vivo 1819 15 0.1%
samsung / SM-A528B 15 0.1%
INFINIX MOBILITY LIMITED / Infinix X695 15 0.1%
samsung / SM-A716U 15 0.1%
INFINIX MOBILITY LIMITED / Infinix X650D 15 0.1%
realme / realme 8 5G 15 0.1%
OnePlus / OnePlus Nord 2T 5G 15 0.1%
samsung / SM-A715F 15 0.1%
OPPO / OPPO A3s 14 0.1%
OPPO / OPPO A31 14 0.1%
OnePlus / EB2101 14 0.1%
samsung / SM-G935F 14 0.1%
-- / -- 14 0.1%
Xiaomi / M2007J20CG 14 0.1%
HUAWEI / PPA-LX2 14 0.1%

Self-identified pronoun(s) / 自己申告の代名詞

Label / ラベル Count / 件数 Share / 構成比
1. He/him/his 5825 53.2%
0. She/her/hers 5037 46.0%
5. Prefer not to say 38 0.3%
2. They/them/their 30 0.3%
4. None of the above 24 0.2%
3. Ze/zir/zirs 7 0.1%

Ancestry bucket(s) / 祖先カテゴリ

Label / ラベル Count / 件数 Share / 構成比
0. Africa 5249 48.0%
11. Asia 4362 39.9%
14. South-eastern Asia 2115 19.3%
15. Southern Asia 1668 15.2%
3. Middle Africa 1467 13.4%
4. Southern Africa 1360 12.4%
2. Eastern Africa 1069 9.8%
5. Western Africa 1059 9.7%
17. Europe 929 8.5%
6. Americas 713 6.5%
9. South America 346 3.2%
20. Southern Europe 325 3.0%
13. Eastern Asia 280 2.6%
21. Western Europe 243 2.2%
10. Northern America 236 2.2%
1. Northern Africa 235 2.1%
18. Eastern Europe 191 1.7%
19. Northern Europe 149 1.4%
7. Caribbean 108 1.0%
22. Oceania 96 0.9%
23. Australia and New Zealand 89 0.8%
12. Central Asia 77 0.7%
8. Central America 54 0.5%
16. Western Asia 50 0.5%
26. Polynesia 12 0.1%

Natural skin color palette / 自然肌色パレット

Label / ラベル Count / 件数 Share / 構成比
0. [102, 78, 65] 2982 27.3%
4. [189, 163, 137] 1850 16.9%
5. [198, 180, 157] 1644 15.0%
3. [175, 148, 120] 1580 14.4%
2. [164, 131, 103] 1516 13.9%
1. [136, 105, 81] 1369 12.5%

Perceived skin color palette / 見た目肌色パレット

Label / ラベル Count / 件数 Share / 構成比
0. [102, 78, 65] 3151 28.8%
3. [175, 148, 120] 1961 17.9%
2. [164, 131, 103] 1950 17.8%
1. [136, 105, 81] 1948 17.8%
4. [189, 163, 137] 1398 12.8%
5. [198, 180, 157] 533 4.9%

Hair style category / ヘアスタイル区分

Label / ラベル Count / 件数 Share / 構成比
5. Down (Short) 3542 32.4%
1. Buzz cut 1692 15.5%
15. Down (Long) 1419 13.0%
13. Up (Long) 1190 10.9%
0. None 1107 10.1%
10. Down (Medium) 728 6.7%
8. Up (Medium) 351 3.2%
14. Half-up (Long) 270 2.5%
6. Not listed(Short) 194 1.8%
3. Up (Short) 162 1.5%
9. Half-up (Medium) 136 1.2%
4. Half-up (Short) 61 0.6%
16. Not listed(Long) 39 0.4%
11. Not listed(Medium) 30 0.3%
18. Free-text 19 0.2%
17. Not listed 1 0.0%

Natural hair type / 自然な髪質

Label / ラベル Count / 件数 Share / 構成比
1. Straight 4070 37.2%
4. Kinky-coily 3669 33.5%
2. Wavy 2435 22.3%
3. Curly 652 6.0%
0. None 69 0.6%
6. Free-text 37 0.3%
5. Not listed 9 0.1%

Perceived hair type / 見た目の髪質

Label / ラベル Count / 件数 Share / 構成比
4. Kinky-coily 3662 33.5%
1. Straight 3627 33.2%
2. Wavy 2113 19.3%
0. None 1116 10.2%
3. Curly 385 3.5%
6. Free-text 33 0.3%
5. Not listed 5 0.0%

Natural hair color(s) / 自然な髪色

Label / ラベル Count / 件数 Share / 構成比
6. Dark brown/black 9486 86.7%
5. Light brown to medium brown 637 5.8%
4. Dark blond 253 2.3%
9. Gray 242 2.2%
3. Blond 162 1.5%
10. White 152 1.4%
12. Free-text 121 1.1%
0. None 108 1.0%
8. Red blond 37 0.3%
2. Light blond 36 0.3%
7. Red 29 0.3%
1. Very light blond 15 0.1%
11. Not listed 10 0.1%

Perceived hair color(s) / 見た目の髪色

Label / ラベル Count / 件数 Share / 構成比
6. Dark brown/black 8787 80.3%
0. None 1111 10.2%
5. Light brown to medium brown 752 6.9%
10. White 276 2.5%
9. Gray 254 2.3%
3. Blond 193 1.8%
4. Dark blond 149 1.4%
2. Light blond 81 0.7%
12. Free-text 54 0.5%
7. Red 53 0.5%
8. Red blond 39 0.4%
1. Very light blond 22 0.2%

Facial hairstyle(s) / 顔周りの毛スタイル

Label / ラベル Count / 件数 Share / 構成比
0. None 7318 66.9%
2. Mustache 3039 27.8%
3. Goatee 2837 25.9%
1. Beard 1923 17.6%

Natural facial hair color(s) / 自然な顔毛の色

Label / ラベル Count / 件数 Share / 構成比
6. Dark brown/black 6509 59.5%
0. None 3374 30.8%
5. Light brown to medium brown 523 4.8%
10. White 183 1.7%
4. Dark blond 143 1.3%
2. Light blond 114 1.0%
9. Gray 104 1.0%
12. Free-text 92 0.8%
3. Blond 59 0.5%
1. Very light blond 47 0.4%
11. Not listed 24 0.2%
7. Red 10 0.1%

Perceived facial hair color(s) / 見た目の顔毛の色

Label / ラベル Count / 件数 Share / 構成比
0. None 7186 65.7%
6. Dark brown/black 3534 32.3%
10. White 259 2.4%
9. Gray 95 0.9%
11. Not listed 26 0.2%
5. Light brown to medium brown 25 0.2%
4. Dark blond 18 0.2%
12. Free-text 9 0.1%
3. Blond 8 0.1%
2. Light blond 2 0.0%
1. Very light blond 1 0.0%

Annotator demographics / アノテータ属性 (annotator_demographics.csv) - Info

Metric / 指標 Value / 値
Annotators / アノテータ数 284
Age stats / 年齢統計 min 18.0, avg 33.8, max 61.0

Annotator demographics / アノテータ属性 (annotator_demographics.csv) - Age buckets / 年齢帯

Bucket / 年齢帯 Count / 件数
25-34 111
35-44 57
18-24 30
45-54 29
55-64 7

Annotator demographics / アノテータ属性 (annotator_demographics.csv) - Pronouns / 代名詞

Pronouns / 代名詞 Count / 件数
0. She/her/hers 123
1. He/him/his 113
2. They/them/their 2

Annotator demographics / アノテータ属性 (annotator_demographics.csv) - Ancestry / 祖先カテゴリ

Ancestry / 祖先カテゴリ Count / 件数
14. South-Eastern Asia 149
18. Eastern Europe 26
9. South America 21
21. Western Europe 10
20. Southern Europe 10
1. Northern Africa 9
15. Southern Asia 8
5. Western Africa 8
2. Eastern Africa 6
23. Australia and New Zealand 6
16. Western Asia 4
13. Eastern Asia 4
19. Northern Europe 4
7. Caribbean 3
3. Middle Africa 3
12. Central Asia 2
4. Southern Africa 2
10. Northern America 1
8. Central America 1

QA annotator demographics / QAアノテータ属性 (QAannotator_demographics.csv) - Info

Metric / 指標 Value / 値
Annotators / アノテータ数 73
Age stats / 年齢統計 min 21.0, avg 31.9, max 61.0

QA annotator demographics / QAアノテータ属性 (QAannotator_demographics.csv) - Age buckets / 年齢帯

Bucket / 年齢帯 Count / 件数
25-34 10
18-24 6
35-44 4
55-64 2
45-54 1

QA annotator demographics / QAアノテータ属性 (QAannotator_demographics.csv) - Pronouns / 代名詞

Pronouns / 代名詞 Count / 件数
0. She/her/hers 13
1. He/him/his 10

QA annotator demographics / QAアノテータ属性 (QAannotator_demographics.csv) - Ancestry / 祖先カテゴリ

Ancestry / 祖先カテゴリ Count / 件数
14. South-Eastern Asia 22
15. Southern Asia 2
16. Western Asia 2
12. Central Asia 1
13. Eastern Asia 1
21. Western Europe 1

まとめ

この記事では、FHIBEデータセットのディレクトリ構成とメタデータ解析結果を紹介しました。
今回も最後まで見てくれてありがとうございます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?