🐕

OpenCVを使ってAIで生成した画像からそれっぽい消失点を見つける

2024/02/14に公開

はじめに

コンピュータービジョンをガリガリつかってAI画像生成を快適にしようの回です。
今回はAIで生成した画像から自動で消失点を見つけられないか?という挑戦です。

さて、そもそも消失点が見つけられると何が嬉しいのでしょうか?
想定される利用方法としては、背景を生成した後に人物画像を合成する際どこに配置すると違和感がないかのガイドや、生成された画像に背景の歪みなど何か違和感があるときに、違和感を検知しやすくしどこを修正すればよいのかを明確にする、等の利用が考えられます。

それでは、実際に消失点検出をやってみましょう。

消失点の検出

今回は、シトラス(@AI_Illust_000)さんから提供いただいたAI生成画像をベースに検出を行います(ご協力いただきありがとうございました!)

直線検出

今回対象とする画像は、一点透視図法と呼ばれる消失点が画面に一つだけ存在するような絵になっています。
一点透視図法の場合、消失点から放射状に物体が配置され、消失点に近づくほど物が小さく、離れるほど物が大きく描写されます。
また、本来並行に存在する直線は全て、伸ばすと消失点に収束するという性質を持っています。

上記を踏まえ、どのように消失点を探すか?ですが、基本的な考え方としては、画像から直線を検出し、その直線を伸ばした際に最も多く直線が交わる点が消失点といえます。

まずは愚直にOpenCVを使って直線を検出してみましょう。
入力画像からエッジを検出しハフ変換を用いて直線を選びます。

直線を検出するコードは以下

img = cv2.imread(image_path)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
auto_contrast = cv2.equalizeHist(blurred)
edges = cv2.Canny(auto_contrast, 50, 150, apertureSize=3)
lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=50, minLineLength=100, maxLineGap=3)

line_image = np.zeros_like(img)
for line in lines:
    x1, y1, x2, y2 = line[0]
    cv2.line(line_image, (x1, y1), (x2, y2), (255, 255, 255), 2)  # 白色の線で描画

幾つかの直線が検出されました。
ただ、結果から見てもわかるようにこの直線すべての交点を求めたとしても消失点にはなりえません。
この時点で検出された直線は主に、地平線に対して平行な直線、地平線に対して垂直の直線、延長すると消失点に交わる直線、水平線とは関係なく消失点とも関係ない直線の4つに分類されます。

では、この直線を分類して延長すると消失点に交わる直線だけを抽出しましょう。
今回は角度に基づいてDBSCANを用いたクラスタリングを行います。

def group_parallel_lines(lines, eps=0.01, min_samples=2):
    """
    並行線をグループ化するためにクラスタリングする関数。
    lines: 検出された線のリスト
    eps: DBSCANの距離パラメータ
    min_samples: DBSCANの最小サンプル数
    """
    # 線の角度を計算
    angles = []
    for line in lines:
        x1, y1, x2, y2 = line[0]
        angle = calculate_angle(x1, y1, x2, y2)
        angles.append([angle])

    # 角度に基づいてDBSCANでクラスタリング
    clustering = DBSCAN(eps=eps, min_samples=min_samples).fit(angles)
    labels = clustering.labels_

    return labels, angles


line_image = np.zeros_like(img)
if lines is not None:
    labels, angles = group_parallel_lines(lines)

    # 各クラスタに対するカラーパレットを生成
    unique_labels = np.unique(labels)
    colors = {label: tuple(np.random.randint(0, 255, 3).tolist()) for label in unique_labels if label != -1}

    # クラスタごとに色を変えて線を描画し、角度を表示
    for line, label, angle in zip(lines, labels, angles):
        x1, y1, x2, y2 = line[0]
        color = colors[label] if label != -1 else (255, 255, 255)
        cv2.line(line_image, (x1, y1), (x2, y2), color, 2)
        cv2.putText(line_image, f"{angle[0]:.2f}", (x1, y1), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)

続いて、クラスタ毎に代表角度を求め、代表角度どうしが直行する直線を取り除きます。
(作ってから思いましたが一点透視図法の場合は、単純に地平線に平行及び直角のクラスタだけ省くでも良い気がします)

line_image = np.zeros_like(img)
# 各クラスタの平均角度を計算
mean_cluster_angles = {label: np.mean(cluster_angles[label]) for label in cluster_angles}

non_tgt_cluster = []
for k1, v1 in mean_cluster_angles.items():
    for k2, v2 in mean_cluster_angles.items():
        if k1 == k2:
            continue
        if abs(mean_cluster_angles[k1] - mean_cluster_angles[k2]) > 80:
            non_tgt_cluster.append(k1)

# クラスタの代表角度が直行するものを除外して線を描画
for line, label, angle in zip(lines, labels, angles):
    if label in non_tgt_cluster:
        continue

    x1, y1, x2, y2 = line[0]
    color = colors[label]
    cv2.line(line_image, (x1, y1), (x2, y2), color, 2)
    cv2.putText(line_image, f"{angle[0]:.2f}", (x1, y1), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)

結果がこちら

良い感じに邪魔な線を省けました。
それでは、残った直線を画面の端から端まで延長して交点を見てみましょう。

def extend_line(x1, y1, x2, y2, img_shape):
    """線を画像の端まで伸ばす関数"""
    rows, cols = img_shape[:2]
    if x2 - x1 == 0:  # 垂直線
        return x1, 0, x1, rows
    else:
        slope = (y2 - y1) / (x2 - x1)
        intercept = y1 - slope * x1

        # 画像の左端と右端でのy座標を計算
        y_left = int(intercept)
        y_right = int(slope * cols + intercept)

        return 0, y_left, cols, y_right

line_image = np.zeros_like(img)
extended_lines = [extend_line(line[0][0], line[0][1], line[0][2], line[0][3], img.shape) for line in vp_lines]
intersections = defaultdict(int)
for line in extended_lines:
    cv2.line(line_image, (line[0], line[1]), (line[2], line[3]), (0, 255, 0), 2)

凡そ、消失点として設定すべき場所が見えてきましたね。
ただ、一点で交わっているわけではない事も同時に見えます。
これは、画像生成AIがパース通りに厳密な画像を生成しているわけではないためこのような結果が得られるのだと考えられます。

では、このように直線の交点が複数ある場合、一番尤もらしい消失点を設定するにはどうすればよいでしょうか?
手段は色々あるかと思いますが、今回は画面を幾つかの四角形に分割し、最も直線の交点が含まれる四角形の中心を消失点として設定しました。


    
def find_intersection(line1, line2):
    """二つの線分の交点を見つける関数"""
    x1, y1, x2, y2 = line1
    x3, y3, x4, y4 = line2

    # 線分が平行でないか確認
    den = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
    if den == 0:
        return None

    # 交点を計算
    px = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / den
    py = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / den

    return px, py


def create_boxes_around_intersections(intersections, box_size=5):
    """交差点を含むボックスを生成し、それぞれの交差点数をカウントする関数"""
    box_counts = defaultdict(int)
    half_box = box_size // 2

    for (x, y), count in intersections.items():
        box_x = x // box_size * box_size
        box_y = y // box_size * box_size
        box_counts[(box_x + half_box, box_y + half_box)] += count

    return box_counts

# すべての線分のペアについて交点を見つけ、カウントする
for line1, line2 in itertools.combinations(extended_lines, 2):
    intersection = find_intersection(line1, line2)
    if intersection:
        intersections[intersection] += 1
        
# ボックスを生成し、それぞれの交差点数をカウント
box_counts = create_boxes_around_intersections(intersections, box_size=20)

# 交差点数が最も多いボックスを見つける
most_common_box, count = max(box_counts.items(), key=lambda x: x[1], default=(None, 0))

# most_common_box の座標を整数に変換
box_center_x, box_center_y = int(most_common_box[0]), int(most_common_box[1])
cv2.circle(line_image, (box_center_x, box_center_y), 5, (0, 0, 255), -1)
cv2.putText(line_image, f"Box Center: ({box_center_x}, {box_center_y}), Count: {count}", 
            (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)

これで消失点が得られました。
それでは消失点を通り、画面の端から端まで5度ずつ回転させた直線を引いてみましょう。
これがパースガイドとなります。

def draw_rotating_lines_through_point(img, point, angle_range=360, step=5):
    rows, cols = img.shape[:2]
    cx, cy = point

    for angle in range(0, angle_range, step):
        for offset in (0, 90):  # 90度回転を追加
            # 角度をラジアンに変換(90度回転を含む)
            theta = np.radians(angle + offset)
            cos_theta, sin_theta = np.cos(theta), np.sin(theta)

            # 直線の方程式のパラメータを計算
            if cos_theta == 0:  # 水平線
                y1, y2 = cy, cy
                x1, x2 = 0, cols
            elif sin_theta == 0:  # 垂直線
                x1, x2 = cx, cx
                y1, y2 = 0, rows
            else:
                slope = sin_theta / cos_theta
                intercept = cy - slope * cx

                # 画像の上端と下端でのx座標を計算
                x_top = (0 - intercept) / slope
                x_bottom = (rows - intercept) / slope

                # X座標の範囲外の場合、X座標を調整し、対応するY座標を計算
                if x_top < 0:
                    x1 = 0
                    y1 = int(intercept)
                elif x_top > cols:
                    x1 = cols
                    y1 = int(slope * cols + intercept)
                else:
                    x1 = int(x_top)
                    y1 = 0

                if x_bottom < 0:
                    x2 = 0
                    y2 = int(intercept)
                elif x_bottom > cols:
                    x2 = cols
                    y2 = int(slope * cols + intercept)
                else:
                    x2 = int(x_bottom)
                    y2 = rows

            # 座標を整数に変換し、直線を描画
            cv2.line(img, (x1, y1), (x2, y2), (255, 0, 0), 1)

img = process_image_with_rotating_lines(image_path, vp_point) #vp_point: 得られた消失点の座標

最終的な結果がこちら


おわりに

今回は画像生成AIで生成した画像から、尤もらしい消失点を検出する挑戦をしてみました!
この結果をもとに、画像生成時に消失点を制御するControlNetを作成したので、別の機会に消失点制御ControlNetの制作過程も記事にしようと思います。

また、今回いくつかのコードを掲載していますが、基本的には関数の設計だけ自分で行い中身はChatGPTに書かせています。
結果、このプログラムの制作は構想から最終結果に至るまで2時間かからない程度で完成と爆速で開発することができました。
ChatGPT様様ですね。

Discussion