型抜きクッキーの効率化を機械学習で出来るかな?③

M10i
2025-05-20
2025-05-22

ハイ。M10iです。

機械学習を使って型抜きクッキーの効率化
の続きでございます。前回の予告どおり

やっと!機械学習ですよ!!お待たせしました!!

さっそく機械学習モデルを作っていきたいと思います。
が。機械学習モデルはたくさんあって、そのうちのどのモデルは何を使うのか?
ってところで、今回は深層強化学習モデルを使います。

強化学習モデルの特徴をざっと説明↓↓cookieAI_3_1
目的は微調整をしつつ隙間を埋めてクッキー生地の無駄をなくしたい。
ってなると、この中ではPPOが相性が良さそうです。では早速コードを書いていきますが
その前に!そもそも強化学習っていうのは

AIさんに状態を確認しながら決められた行動を取ってもらって、いい感じの場合報酬を与える。
そして報酬を最大化するようにAIさんが最適な行動を学習する。


です!簡単!!(※ほんとはモデルによってQ値とか価値とかあるけど要約)

なので先にざっくりと行動と報酬と状態を定義しますね。

行動

クッキーの型を抜く。当たり前ですねw
ここでは抜く位置(x、y)と☆の回転を指定します。

報酬

型同士が重ならないように抜く。+1点
型同士が重なってしまうので抜けない。ー1点
抜いた位置が前の抜いた位置に近い。+α

状態

クッキーの生地
生地がある状態を0、抜かれた状態を1で表現。今回200px × 300px

とりあえず、イメージが大事なのでコードにします。
ちょっと長いけど大事なのはstepだけですw

※今回画面サイズとの兼ね合いもあって☆は綺麗なマップで作り直しました。
1pxがだいたい1mmのイメージで☆は3cm弱なるようにポリゴン調節してます。

cookie_cutter_env.py

import gymnasium as gym
import numpy as np
import json
import cv2
from gymnasium import spaces
from shapely.geometry import Polygon


class CookieCutterEnv(gym.Env):
    def __init__(self, json_path):
        super(CookieCutterEnv, self).__init__()

        # JSON からポリゴンデータを読み込む
        with open(json_path, "r") as f:
            self.polygon_data = json.load(f)

        # 生地サイズ (200px x 300px)
        self.canvas_height = 200
        self.canvas_width = 300
        # 配置済みのポリゴン
        self.placed_polygons = []
        # 試行回数
        self.trial_count = 0
        self.trial_limit = 100  # 試行回数の上限
        # 空のキャンバスを初期化
        self.canvas = np.zeros(
            (self.canvas_height, self.canvas_width), dtype=np.uint8)

        # 行動空間の設定: (x座標, y座標, 回転角度)
        self.action_space = spaces.MultiDiscrete(
            [self.canvas_width, self.canvas_height, 6])

        # # 観測空間: (200px x 300px) の 2D マップ
        self.observation_space = spaces.Box(
            low=0, high=1, shape=(self.canvas_height, self.canvas_width), dtype=np.uint8)

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)  # 追加
        # 配置済みのポリゴン
        self.placed_polygons = []
        # 空のキャンバスを初期化
        self.canvas = np.zeros(
            (self.canvas_height, self.canvas_width), dtype=np.uint8)
        # 試行回数
        self.trial_count = 0
        return self._get_observation(), {}  # Gymnasium はタプルを返す

    def step(self, action):
        x, y, rotation = action  # actionの3番目の値が回転のインデックス
        reward = -1  # デフォルトのペナルティ
        done = False

        polygon = np.array(self.polygon_data["polygon"])  # ポリゴンの元データ

        # 回転角度を計算 (12°刻みで回転)
        rotation_angle = rotation * 12  # 0°から72°の範囲で回転

        # 指定された位置 (x, y) と回転角度でポリゴンを配置する
        rotation_matrix = np.array([
            [np.cos(np.radians(rotation_angle)), -
             np.sin(np.radians(rotation_angle))],
            [np.sin(np.radians(rotation_angle)),
             np.cos(np.radians(rotation_angle))]
        ])

        rotated_polygon = np.dot(
            polygon - np.mean(polygon, axis=0), rotation_matrix.T) + np.mean(polygon, axis=0)

        placed_polygon = rotated_polygon + np.array([x, y])

        # 試行回数が上限に達した場合
        if self.trial_count >= self.trial_limit:
            done = True  # 終了フラグを立てる
            return self._get_observation(), reward, done, False, {}

        if self._is_valid_placement(placed_polygon):
            self.placed_polygons.append(placed_polygon)  # 配置成功

            # 配置されたポリゴンをcanvasに反映
            self._update_canvas(placed_polygon)

            reward = 1  # 成功報酬

            # 密集度を計算
            distances = []
            # 既存の型との距離を計算
            for other_polygon in self.placed_polygons[:-1]:
                center1 = np.mean(placed_polygon, axis=0)
                center2 = np.mean(other_polygon, axis=0)
                distances.append(np.linalg.norm(center1 - center2))

            if distances:
                min_distance = min(distances)  # 最も近い型との距離
                density_reward = max(
                    0, (100 - min_distance) / 100)  # 距離が近いほど報酬UP
                reward += density_reward * 5  # 密集報酬を調整

            # 配置が成功したので、報酬を返す
            return self._get_observation(), reward, False, False, {}
        else:
            # 置けなかった場合
            if self.can_place_polygon(placed_polygon):
                self.trial_count += 1  # 試行回数を増やす
                return self._get_observation(), reward, False, False, {}
            # 面積が無い場合終了
            done = True  # 終了フラグを立てる
            return self._get_observation(), reward, done, False, {}

    def _update_canvas(self, placed_polygon):
        """配置されたポリゴンを canvas に反映させる"""
       # ポリゴンの頂点を整数型に変換
        placed_polygon = placed_polygon.astype(np.int32)
        # 配置されたポリゴンを canvas に反映(塗りつぶし)
        cv2.fillPoly(self.canvas, [placed_polygon], 1)

    def _is_valid_placement(self, polygon):
        """ポリゴンが生地の中に収まり、重ならないか判定"""
        # 生地の外にはみ出していないか
        min_x, min_y = polygon.min(axis=0)
        max_x, max_y = polygon.max(axis=0)
        if min_x < 0 or min_y < 0 or max_x > self.canvas_width or max_y > self.canvas_height:
            return False  # 生地の外なら配置NG

        # 他のポリゴンと重ならないか
        for placed in self.placed_polygons:
            if self._polygons_overlap(polygon, placed):
                return False  # 重なったら配置NG
        return True  # 配置OK

    def _get_observation(self):
        """配置済みポリゴンを 2D マップに反映"""
        return self.canvas

    def _polygons_overlap(self, poly1, poly2):
        """Shapely を使ってポリゴンの重なりを判定"""
        p1 = Polygon(poly1)
        p2 = Polygon(poly2)

        # ポリゴン1がポリゴン2を含むか、または逆を確認
        if p1.intersects(p2) or p1.contains(p2) or p2.contains(p1):
            return True
        return False

    def can_place_polygon(self, polygon):
        # 空きスペースの抽出
        contours, _ = cv2.findContours(
            self.canvas, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

        # 画像全体の面積
        total_area = self.canvas.size  # 全ピクセル数

        # オブジェクトの面積(輪郭内の面積)
        object_area = sum(cv2.contourArea(cnt) for cnt in contours)

        # 空きスペースの面積を計算
        empty_space_area = total_area - object_area

        polygon = Polygon(np.array(self.polygon_data["polygon"]))
        # ポリゴンの面積
        polygon_area = polygon.area
        # 面積の比較
        if empty_space_area >= polygon_area:
            return True
        else:
            return False
stepで何をしてるかの説明

指定された位置(x,y)と回転(12°ずつ1回転分0~6で指定可能)で配置できるか?
→配置できた場合
 配置OK =1点
 さらに密集度+α
→配置できなかった場合
 空スペースあり=マイナス1点、試行回数を増やして継続
 空スペースがない→エピソードが終了

なんともざっくりとした行動と報酬です。いろいろ突っ込みどころはありますが
これを強化
学習する前に、どんな感じで動くのか確認してみましょう

cookie_cutter.py

import matplotlib.pyplot as plt
from cookie_cutter_env import CookieCutterEnv  # 環境クラスをインポート

def
render(env):
    """ 環境の現在の状態を可視化 """
    plt.figure(figsize=(5, 5))
    plt.imshow(env._get_observation(), cmap="gray", origin="upper")
    plt.colorbar()
    plt.show()

 # JSON ファイルのパス
json_path = "polygon_data.json"
# 環境を作成
env = CookieCutterEnv(json_path)

# 初回描画
render(env)

MAX_STEPS = 100

for step in range(MAX_STEPS):
    action = env.action_space.sample()  # ランダムなアクション
    obs, reward, done, truncated, info = env.step(action)
    # 1 ステップ実行後の結果を表示
    print("Action Taken:", action)
    print("Reward:", reward)
    print("Done:", done)
    print("Next Observation Shape:", obs.shape)
# 変更後の状態を描画
render(env)

ここでは100回ランダムでお試しで実行しますが、本来はdone==Trueになるまで試行し学習するはずです。
そこまでを1つのサイクルとして、だいたい1万step~学習してもらいます。
繰り返すうちに報酬が高くなるようにactionを選択するようになります(願望)

初回描画

なにもしてない状態ですね。
1pxを1mmとしたイメージで、20cm×30cmの生地です。
cookieAI_3_f

100回後
cookieAI_3_2

わりと自由に型抜きしますねw
学習するともっと良くなるのか不安ですね、まるでうちの子供のようだwwww

では早速SageMakerに乗っけて学習させていきたいと思います。
え?長い?
でもさっきのコードを学習してねってお願いするだけなので、実はコードはとても簡単。

いよいよ機械学習ですよ!!!!!

train.py

from stable_baselines3 import PPO
from cookie_cutter_env import CookieCutterEnv  # 環境クラスをインポート

json_path
= "polygon_data.json"
env = CookieCutterEnv(json_path)
model = PPO("MlpPolicy", env, verbose=2)

model.learn(total_timesteps=100000)
model.save("cookie_cutter_model")

学習コードです。polygon_data.jsonは☆の型をそのまま置いてます。
PPOモデルをさっきのコード(cookie_cutter_env.py)で、100000stepほど学習してもらいます。
cookie_cutter_model.zipが出力されたら学習完了です!

ちょっと学習ログの見方をご紹介。※verbose=2に設定するとlearn中に出力してくれます。

rollout/      
 ep_len_mean 117 エピソード終了までの平均Step数
 ep_rew_mean -53 エピソード終了時の平均報酬
time/        
 total_timesteps 2048 総学習Step数
 train/    
explained_variance 0.0921 データからAIが予測するActionがどれだけ報酬をもらえるか?を-1~1で表現

てことで、ep_len_meanはまずまず。しかしそれに対して
ep_rew_meanが-53と報酬がものすごく低いのがわかります。
explained_varianceもほぼ0なので予測しても報酬が受け取れてません。

このあんまり報酬のもらえなかった子に、予測をしてもらいます(嫌な予感しかないw)

test.py

import os
from stable_baselines3 import PPO
import matplotlib.pyplot as plt
from cookie_cutter_env import CookieCutterEnv

# 環境を作成
env = CookieCutterEnv(json_path="polygon_data.json")
obs, _ = env.reset()

# 学習済みモデルのロード
model = PPO.load(f"cookie_cutter_model.zip")

MAX_STEPS = 100
obs, _ = env.reset()
for step in range(MAX_STEPS):
    action, _ = model.predict(obs, deterministic=True)  # 予測
    obs, reward, done, truncated, info = env.step(action)  # 環境を更新
    # 1ステップ実行後の結果を表示
    print("Action Taken:", action)
    print("Reward:", reward)
 
# 変更後の状態を描画
    """ 環境の現在の状態を可視化 """
    obs = env._get_observation()
    plt.figure(figsize=(6, 4))
    plt.imshow(obs, cmap="gray", origin="upper")
    plt.colorbar()
    plt.show()

実験と同じで100回実行します。

結果・・・・・・
cookieAI_3_2-1
なんと!!!!!ランダムで出すよりもひどいwwwwwww
ちなみにログですが、1回目〇、2回目〇、3回目でNG
その後NGにかかわらず3回目と同じ値をずーーーっとだします。
2回目付近なのでうまく入れば報酬をもらえるはずなのはわかるのですが交差ペナルティが無いせいか頑なに選んでますね。

1回目:Action Taken: [188  77   2]
Reward: 1
2回目:AAction Taken: [139 109   2]
Reward: 3.0738250223200922
3回目:AAction Taken: [139  77   2]
Reward: -1
4回目:Action Taken: [139  77   2]
Reward: -1
~以下ずっと同じ[139  77   2]~

deterministic=Trueだと再現性が強過ぎるのかもしれません。
Falseで実行してみると・・・・
cookieAI_3_2f
最初のランダムと似てますねw
☆が16個に増えたので、ランダムより打率は良いかもしれませんっ

今回はちょっと残念な形になってしました。。。。。。
では、終わる前に反省点を。。。。。

問題点

状態(State(観測空間)が 0,1 の配列のみ)

現在状態 は「型抜きが終わった 0,1 の配列」なので情報が少なすぎるかも?

  1. どの部分に空きスペースがあるのか?
  2. どの場所に配置すると密集度が高くなるか?
  3. どれくらいのスペースが残っているのか?

この辺りを考慮しないといけないですよね。

報酬(rewardが -1,  1のみ)

追加報酬ほぼ意味無し、ペナルティの方が強すぎて報酬が得れてませんwwww
あと報酬が増えないので学習が進んでませんw
置いてもペナルティばっかりじゃどこに置くかなんてわかんないデスヨネー
学習中から何となくわかってた事ですが、結果を出してみるとより歴然としましたね。
最低でも以下は改善しないとダメだと思われます。

  1. 既存と交差した時のペナルティがない
  2. 配置できた時の報酬が低い

終わりに

型取りから機械学習までひととおり実行してみましたが、いかがだったでしょう?
M10iはとりあえず作ってみてから考える派なので、次回!!!!
~~~~~もっと精度を上げてみよう~~~~~~
に続きたいと思いますw

M10iでしたっ