```
# src/utils/cropper.py
import os
from logging import getLogger

from PIL import Image
from imgutils.detect import detect_person, detection_visualize
from matplotlib import pyplot as plt

from configs.path_config import EXAMPLE_IMAGE, OUTPUT_DIR
from utils.bbox import calculate_bbox_metrics, expand_bbox_to_min_length, bbox_overlapping

logger_local = getLogger(__name__)


def merge_overlapping_detections(detections):
    """
    重複している検出矩形をマージして、新しい検出として追加します。
    元のdetectionsは削除せず、マージされた検出を追加します。

    Args:
        detections (list): (bbox, label, score) のリスト

    Returns:
        list: 元の検出 + マージされた検出のリスト
    """
    if len(detections) <= 1:
        return detections

    # 各検出にインデックスを付与
    indexed_detections = list(enumerate(detections))

    # 重なっているグループを見つける
    groups = []
    used_indices = set()

    for i, (idx1, (bbox1, label1, score1)) in enumerate(indexed_detections):
        if idx1 in used_indices:
            continue

        # 新しいグループを開始
        current_group = [idx1]
        used_indices.add(idx1)

        # このグループに属する他の検出を探す
        group_changed = True
        while group_changed:
            group_changed = False
            for j, (idx2, (bbox2, label2, score2)) in enumerate(indexed_detections):
                if idx2 in used_indices:
                    continue

                # 現在のグループの任意のメンバーと重なっているかチェック
                for group_idx in current_group:
                    group_bbox = detections[group_idx][0]
                    if bbox_overlapping(group_bbox, bbox2):
                        current_group.append(idx2)
                        used_indices.add(idx2)
                        group_changed = True
                        break

        # グループが複数の検出を含む場合のみ追加
        if len(current_group) > 1:
            groups.append(current_group)

    # 元のdetectionsをコピーして開始
    result_detections = detections.copy()

    # 各グループについてマージされた検出を作成
    for group_indices in groups:
        # グループ内のすべての矩形を包含する最小矩形を計算
        min_x1 = float('inf')
        min_y1 = float('inf')
        max_x2 = float('-inf')
        max_y2 = float('-inf')

        total_score = 0
        labels = []

        for idx in group_indices:
            bbox, label, score = detections[idx]
            x1, y1, x2, y2 = bbox

            min_x1 = min(min_x1, x1)
            min_y1 = min(min_y1, y1)
            max_x2 = max(max_x2, x2)
            max_y2 = max(max_y2, y2)

            total_score += score
            labels.append(label)

        # マージされた検出を作成
        merged_bbox = (min_x1, min_y1, max_x2, max_y2)
        merged_label = f"merged_{len(group_indices)}_persons"  # マージされたことを示すラベル
        merged_score = total_score / len(group_indices)  # 平均スコア

        merged_detection = (merged_bbox, merged_label, merged_score)
        result_detections.append(merged_detection)

        logger_local.info(
            f"Merged {len(group_indices)} overlapping detections into one: "
            f"indices {group_indices}, bbox: {merged_bbox}, avg_score: {merged_score:.2f}"
        )

    return result_detections


def crop_and_save_persons(
        image_path, detections, output_dir=OUTPUT_DIR / "person",
        min_length=1024, min_area_ratio=None, merge_overlapping_areas=False,
        max_length=1344,
):
    """
    画像から人物領域を切り出して保存します。
    短辺が指定した最低値を下回る場合、領域を拡大して保存します。

    Args:
        image_path (str): 元画像のパス
        detections (list): detect_person の戻り値 (矩形座標, ラベル, スコア) のリスト
        output_dir (str): 切り出し画像の保存先ディレクトリ
        min_length (int, optional): クロップする領域の短辺の最低値。
                                    指定された場合、短辺がこの値を下回らないように領域を拡大します。
                                    拡大時、画像境界からはみ出す場合は領域をずらして調整します。
                                    Defaults to None.
        min_area_ratio (float, optional): クロップ領域に占める検出された矩形領域の最小比率。
                                          指定された場合、この値を下回る場合はクロップをスキップします。
                                          Defaults to None.
        merge_overlapping_areas (bool, optional): 重複している検出矩形をマージするかどうか。
                                                  Trueの場合、重複している検出を1つにまとめた新しい検出を追加します。
                                                  元の検出は削除されません。
                                                  Defaults to False.
    """
    # 重複している検出をマージ（指定されている場合）
    if merge_overlapping_areas:
        logger_local.info("Merging overlapping detections.")
        detections = merge_overlapping_detections(detections)
        logger_local.info(f"Total detections after merging: {len(detections)}")

    # 出力ディレクトリがなければ作成
    os.makedirs(output_dir, exist_ok=True)

    # 元画像を開く
    with Image.open(image_path) as img:
        img_w, img_h = img.size

        # min_lengthが元画像のサイズより大きい場合は処理不可能なので終了
        if min_length is not None and (min_length > img_w or min_length > img_h):
            logger_local.warning(
                f"min_length ({min_length}) is larger than image dimensions "
                f"({img_w}x{img_h}). Skipping cropping."
            )
            return

        for idx, (bbox, label, score) in enumerate(detections):
            # 元の検出矩形のメトリクスを計算
            orig_metrics = calculate_bbox_metrics(bbox, img_w, img_h)
            logger_local.info(f"Detected: {label}, bbox: {bbox}, score: {score:.2f}")

            # min_lengthが指定されている場合のみ拡大処理を検討
            crop_bbox = expand_bbox_to_min_length(bbox, img_w, img_h, min_length) if min_length is not None else bbox

            # 最終的なクロップ領域のメトリクスを計算
            final_metrics = calculate_bbox_metrics(crop_bbox, img_w, img_h)

            # 検出矩形の面積比率を計算
            area_ratio = orig_metrics['area'] / final_metrics['area']
            logger_local.info(
                f"  - Detection area ratio: {area_ratio:.4f} ({orig_metrics['area']}/{final_metrics['area']})")

            # min_area_ratioが指定されており、面積比率が閾値を下回る場合はスキップ
            if min_area_ratio is not None and area_ratio < min_area_ratio:
                logger_local.info(f"  - Skipping crop: area ratio {area_ratio:.4f} is below threshold {min_area_ratio}")
                continue

            # 最終的な領域でクロップ
            cropped = img.crop(crop_bbox)

            # 保存ファイル名例: person_0_0.85.jpg（0番目, スコア0.85）
            # マージされた検出の場合はラベルに"merged"が含まれる
            safe_label = label.replace(' ', '_')  # ファイル名用にスペースをアンダースコアに変更
            save_path = os.path.join(
                output_dir,
                f'{safe_label}_{idx}_{score:.2f}.png'
            )
            cropped.save(save_path)
            logger_local.info(f'Saved: {save_path}')


if __name__ == "__main__":
    from logging.config import dictConfig
    from configs.logging_config import LOGGING_CONFIG

    dictConfig(LOGGING_CONFIG)

    dets = detect_person(EXAMPLE_IMAGE)
    crop_and_save_persons(
        EXAMPLE_IMAGE,
        dets,
        OUTPUT_DIR / "detect_merge",
        min_length=1024,
        merge_overlapping_areas=True
    )

    # 結果を可視化
    visualized_image = detection_visualize(EXAMPLE_IMAGE, dets)

    # 画像を表示
    plt.imshow(visualized_image)
    plt.axis('off')
    plt.show()
```

max_lengthを実装して。
検出領域のアスペクト比が1:1でない場合、長辺が最大でmax_length、短辺はmin_lengthとなるように、クロップ領域のアスペクト比を適応させる。max_lengthを省略した場合、現状と同様の正方形クロップにする。