ASCIIアート作るツール作ってみた

## 概要
画像処理をやりたいと考えていたため、シンプルなツールを作ってみた。

## 処理概要と限界
画像処理初心者なため、シンプルな実装を行っている。
使うライブラリは有名どころのOpenCVである。

また、`ASCII_CHARS = "@%#*+=-:. "`のような形で輝度によって指定する文字を変えるようにしているが、よく見るようなASCIIアートと比べると文字数が少ないため、表現も限定的になってしまう。

## 画像の読み込みと前処理
画像を読み込み、グレースケール変換する。通常の画像はBGRの3要素扱う必要があるため大変である。

画像処理で、よくグレースケールが出てくるがあまり知らなかったので、簡単に調べた。

>カラー画像は一般的にRGB（Red, Green, Blue）の3つのチャンネルで表されますが、グレースケール画像は各ピクセルが単一の明るさを表す1つのチャンネルで構成されます。

https://musashi-ai.com/glossary/2023/06/132b15f51be239030f34b40a152b0c724506964c.html#:~:text=%E3%82%AB%E3%83%A9%E3%83%BC%E7%94%BB%E5%83%8F%E3%81%AF%E4%B8%80%E8%88%AC%E7%9A%84,%E5%80%A4%E3%82%92%E8%A8%88%E7%AE%97%E3%81%97%E3%81%BE%E3%81%99%E3%80%82

画像を処理する場合に、余計な情報を落として画像を1つのチャンネルで表現できるため、閾値の設定等が容易に可能になるイメージである。


またBGRとは何ぞやとなったので簡単に調べてみたところ以下の記事がヒットした。
どうやら順序が違うだけのようだ。

https://qiita.com/yoya/items/bfef7404ded22649a2af


```py
    # 画像の読み込み
    img = cv2.imread(image_path)
    if img is None:
        raise IOError("画像が見つかりません。パスを確認してください。")

    # グレースケール変換
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
```

次に画像を指定のサイズにリサイズする。
```py
    # 指定の幅にリサイズして高さはアスペクト比を保つ
    height, width = gray.shape
    aspect_ratio = height / width
    new_height = int(aspect_ratio * new_width)
    gray_resized = cv2.resize(gray, (new_width, new_height))
```

## 背景の推定
最初はこの設定をしていなかったが、これがないとすべてを埋めてしまいASCIIアートが埋もれてしまうため設定した。

最頻値の輝度を特定し、最頻値のものは「 」半角スペースで埋めるようにしている。最も多い部分を表現しないことで、ASCIIアートが際立つ。
```py
    # ヒストグラムを計算し、最も多い輝度値（max_bin）を取得
    hist = cv2.calcHist([gray_resized], [0], None, [256], [0, 256])
    max_bin = np.argmax(hist)
```

## どの程度の明るさか判定する
ブロック単位で分割して、平均輝度を求める。ピクセル単位ではASCIIアートにするには細かすぎるため、ブロックに分割して画像のノイズなどに影響されなようにする。

```py
    # ブロック単位で平均輝度を計算し、ASCII文字にマッピング
    ascii_art_lines = []
    h, w = gray_resized.shape
    num_chars = len(ASCII_CHARS)

    for row_start in range(0, h, block_size):
        row_end = min(row_start + block_size, h)  # 画像端を超えないように
        ascii_line = []
        for col_start in range(0, w, block_size):
            col_end = min(col_start + block_size, w)
            block = gray_resized[row_start:row_end, col_start:col_end]

            # ブロック内の平均輝度を求める
            avg_val = np.mean(block)

```

## アスキー文字にマッピングする
アスキー文字の数で割ることで、どの輝度がどのASCII文字にマッピングされるのが適切か判別する。

```py
            # 「最も多い輝度」に近い場合はスペースに置き換える
            if abs(avg_val - max_bin) <= threshold:
                ascii_char = ' '
            else:
                # 通常の方法でマッピング（0～255 -> 0～(num_chars-1)）
                index = int(avg_val * (num_chars - 1) / 255)
                ascii_char = ASCII_CHARS[index]

            ascii_line.append(ascii_char)

        ascii_art_lines.append("".join(ascii_line))
```

## 全体のコード

```py
import cv2
import numpy as np

ASCII_CHARS = "@%#*+=-:. "

def convert_to_ascii_block_with_space_bg(image_path, block_size=4, new_width=100, threshold=10):
    """
    画像ファイルを読み込み、ブロック単位でASCIIアートに変換して返す。
    画像全体で最頻出の輝度に近いブロックは背景（スペース）に置き換える。
    """
    # 画像の読み込み
    img = cv2.imread(image_path)
    if img is None:
        raise IOError("画像が見つかりません。パスを確認してください。")

    # グレースケール変換
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # 指定の幅にリサイズして高さはアスペクト比を保つ
    height, width = gray.shape
    aspect_ratio = height / width
    new_height = int(aspect_ratio * new_width)
    gray_resized = cv2.resize(gray, (new_width, new_height))

    # ヒストグラムを計算し、最も多い輝度値（max_bin）を取得
    # histはshapeが(256, 1)になる（0～255の輝度をカウント）
    hist = cv2.calcHist([gray_resized], [0], None, [256], [0, 256])
    max_bin = np.argmax(hist)  # 輝度値のうち、カウントが最大のもの

    # ブロック単位で平均輝度を計算し、ASCII文字にマッピング
    ascii_art_lines = []
    h, w = gray_resized.shape
    num_chars = len(ASCII_CHARS)

    for row_start in range(0, h, block_size):
        row_end = min(row_start + block_size, h)  # 画像端を超えないように
        ascii_line = []
        for col_start in range(0, w, block_size):
            col_end = min(col_start + block_size, w)
            block = gray_resized[row_start:row_end, col_start:col_end]

            # ブロック内の平均輝度を求める
            avg_val = np.mean(block)

            # 「最も多い輝度」に近い場合はスペースに置き換える
            if abs(avg_val - max_bin) <= threshold:
                ascii_char = ' '
            else:
                # 通常の方法でマッピング（0～255 -> 0～(num_chars-1)）
                index = int(avg_val * (num_chars - 1) / 255)
                ascii_char = ASCII_CHARS[index]

            ascii_line.append(ascii_char)

        ascii_art_lines.append("".join(ascii_line))

    return "\n".join(ascii_art_lines)


if __name__ == "__main__":
    import sys

    # 使い方:
    #   python ascii_art_block_bg.py image.png
    # ブロックサイズや幅、thresholdはお好みで調整
    image_path = sys.argv[1] if len(sys.argv) > 1 else "image.png"
    ascii_art = convert_to_ascii_block_with_space_bg(
        image_path, 
        block_size=2, 
        new_width=80,   # 幅を少し狭めにしてみる例
        threshold=10    # 近いとみなすしきい値
    )
    print(ascii_art)

```

**処理前：**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125890/3a9c0754-3928-482d-a06a-575c7074e097.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125890/61c8d6ad-0666-413f-94b5-ae952aa0135c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125890/9ab4c836-6aa6-441a-b86b-699edf20e809.png)


画像は以下から拝借

https://publicdomainq.net/


**処理後：**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125890/6af7b40a-d8e2-42fc-9694-51ae4201d577.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125890/0761617f-bbe0-4e91-a91c-9703b1739726.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125890/1d9b6c3b-a101-49c5-bf47-49deb2434acf.png)

## まとめ
シンプルな実装ではあるものの、それなりにASCIIアートっぽいものが作れるようになったとは思う。

しかし、人物画などの背景があるものなどはやはり難しいところがあると感じた。
結局ほとんど画像処理という画像処理をやっていないが、今回は良しとしよう、