見出し画像

【悲報】未経験者がAIと作ったツール、自分のミスで意味がなかった話

こんにちは華音です。
数ある記事の中から、この記事を見つけてくれてありがとうございます!
趣味でLINEスタンプを作ったり、AIで画像生成を楽しんでいる、ごく普通のクリエイターです。

このnoteはプログラミング経験ゼロの華音が、Geminiを使ってをアプリを作り上げた「泥臭くて、ちょっと笑える?全記録」です。




今回のアプリを支える「魔法の道具箱」

役割(Geminiが入れろというのでw)
CustomTkinter
アプリの見た目を「今風でオシャレ」にするための化粧箱。Pillow (PIL)画像をミリ単位で切り刻んだり、拡大縮小したりするハサミ。rembg熟練の職人のように、一瞬で背景だけを切り抜くAIの目。
PyInstaller作ったコードを、誰でもクリックで動かせる「.exe」に変える魔法。

すべては「だるい」から始まった

生成AIでスタンプを作っている人なら、一度はこう思ったはず。
「まとめて出力される画像を、一個ずつ切り抜いて背景消すの、だるすぎない……?」

1枚ずつ出力するのは手間もコストもかかる。
でも、まとめて出した後の「分割→リサイズ→透過処理」という作業。
人によってはここに文字入れも入るでしょ?
地味に時間かかりませんか?

ちょっとでも楽したい!
華音は思いました。
「プログラムは書けないけど、AIにツールを作らせよう!」

低スペックPCでも、夢は追える

華音の作業環境を紹介。
数年使い倒した安価なノートPCです。

  • CPU: Core i3-8130U

  • メモリ: 4.00 GB

ちょっとでもこの辺の界隈にいる方ならわかると思います。
低スぺですwww
ついでに言うとWin10ですwwwwwww
もっと言うとLANポートが接触不良気味ですwwwwwwww
「(数年前に)ネットが快適に動けばいい」と買った、
今や早急に買い替えレベルのこの子でも
AIがいればLINEスタンプもアプリも作れます!


主席エンジニア(Gemini)との格闘

LLMの生成AIを使う方はおそらく当たり前にやることかと思いますが、
役割を与えます。
私はGeminiにこう指示を出しました。

あなたは名だたるIT関連企業で主席を務めたエンジニアです。

以下のことができるアプリケーションを作成してください。

Pythonで作成し、Windows上で動作する.exe形式にしてください。

・一枚の画像を指定した枚数に分割できる

・アスペクト比を固定できる

・分割した画像を自動で透過処理できる

・分割する前にどう分割されるかプレビューを確認できる

・分割したファイル名を自動で連番で名前を付ける

そこからはエラーとの戦いでした。
Pythonのバージョンの壁、聞いたこともないシステム部品の欠如……。
画面が真っ赤に染まるたび、エラーってでるたび、何か動かなくなるたびにスクショをGeminiに投げ続けました。


とにかく不安になる赤い文字
動かなくてとにかく不安
起動したけど絶対におかしい線

「こうなった、説明して」「こうなった直して」
仕事だったら相手にブチ切れられるやり取りを繰り返す。

ついにその時は来ました。


衝撃の結末:完成したけれど……?

Gemini Proの知能を借りて完成したツールは、まさに「職人仕様」。

  • 任意のアスペクト比で一括分割

  • AIによる背景透過

  • 一括ZIP保存機能

「これで有料noteが出せるかも!」と舞い上がったの束の間、
ふと思ったのです。

華音のスタンプ制作フロー(無課金)
①生成AIでスタンプ画像の作成
②Windows標準フォトで切り出し
③Canvaでリサイズと必要に応じて文字入れ
④Windows標準フォトで透過処理
さて、気が付いた人います??


Canvaで保存し直すと、せっかく透過した背景が真っ白に戻るんです。

「あれ??透過処理した意味、なくない……!?」

PCの前で崩れ落ちました。
あんなに苦労して実装した機能が…
自分の作業フローのせいで無効化されていたのです。


それでも、この「失敗」を誇りたい

結局、リサイズ機能を実装するか、透過処理でAIが入れた文字まで消えることがあるので、文字入れ機能を実装するか…
「次の課題」が見つかりました。
けど「失敗から学べる」とか「失敗は成功の~」とか言いますし、
この経験は無駄ではないと思ってます。

そもそも一発で画像が分割できるようになっただけで、作業は劇的に楽になりました。
何より「不便なら、AIと一緒に作ればいい」AIはそれを叶えてくれる。

複雑なものは知識がないと厳しいけれど、何かに特化した単純なものであれば、まずはAIに「こんな機能のツールが欲しいから、作るの手伝って」とでも相談しましょう。
プログラム未経験でも恐れずGeminiに相談してみて。

最後に

最後まで読んでいただき、本当にありがとうございました!

「アプリ作成なんてプログラム解らんし…」と思っていた華音でも、
AIと二人三脚でここまで来られました。この記事が、何かを始めようとしているあなたの背中を少しでも押せたなら、これほど嬉しいことはありません。

この記事が「面白かった!」「勇気が出た!」という方は、ぜひ「スキ」と「フォロー」をお願いします!
あなたの応援が記事作成の原動力になります。

また次の記事でお会いしましょう!


おまけ

ここまで読んでくださった方にこの記事で作った
切り抜き透過ツールのexe化までのコードを大公開。
満足いく仕上りなら初めての有料にしようと思ったのですが、
華音的に
「こんなんでお金取っていいのかな?」と疑問に思ったので公開しちゃいます。
環境が整ってて興味がある人は実行してみてください。
こうしたら?とかアドバイスやこれはいらんだろとかコメントいただけると嬉しいです。

import sys
import os

# --- 【重要】コンソールなし起動時のエラー回避策 ---
# ログの出力先がない場合に、何もしない「ゴミ箱」へ捨てる処理
class NullWriter:
    def write(self, arg): pass
    def flush(self): pass

if sys.stdout is None: sys.stdout = NullWriter()
if sys.stderr is None: sys.stderr = NullWriter()
# ------------------------------------------------

import tkinter as tk
from tkinter import filedialog, messagebox
import customtkinter as ctk
from PIL import Image, ImageTk, ImageDraw
from rembg import remove
import zipfile
from io import BytesIO

class ImageSplitterApp(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("AI Image Splitter Pro - Final Release")
        self.geometry("1200x900")
        
        self.source_path = ""
        self.original_img = None
        self.v_lines = []
        self.h_lines = []
        self.display_size = (0, 0)
        
        self.setup_ui()

    def setup_ui(self):
        self.grid_columnconfigure(1, weight=1)
        self.grid_rowconfigure(0, weight=1)

        # サイドバー
        self.sidebar = ctk.CTkFrame(self, width=300)
        self.sidebar.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)

        ctk.CTkButton(self.sidebar, text="画像を選択", command=self.load_image).pack(pady=15, padx=10)

        # モード選択
        self.mode_var = tk.StringVar(value="grid")
        ctk.CTkRadioButton(self.sidebar, text="等間隔グリッド", variable=self.mode_var, value="grid", command=self.update_preview).pack(pady=5)
        ctk.CTkRadioButton(self.sidebar, text="自由分割 (端をクリック)", variable=self.mode_var, value="manual", command=self.update_preview).pack(pady=5)

        # グリッド設定
        self.frame_grid = ctk.CTkFrame(self.sidebar, fg_color="transparent")
        self.frame_grid.pack(pady=5)
        self.entry_rows = self.create_input(self.frame_grid, "行数 (縦):", "2")
        self.entry_cols = self.create_input(self.frame_grid, "列数 (横):", "2")
        
        # 入力値が変わったら即プレビュー更新
        self.entry_rows.bind("<KeyRelease>", lambda e: self.update_preview())
        self.entry_cols.bind("<KeyRelease>", lambda e: self.update_preview())

        ctk.CTkLabel(self.sidebar, text="アスペクト比固定 (グリッドのみ):").pack(pady=(10,0))
        self.ratio_mode = ctk.CTkComboBox(self.sidebar, values=["自由", "1:1", "3:4", "4:3"], command=lambda x: self.update_preview())
        self.ratio_mode.set("自由")
        self.ratio_mode.pack(pady=5, padx=10)
        
        self.check_rembg = ctk.CTkCheckBox(self.sidebar, text="背景を透過する")
        self.check_rembg.pack(pady=10, padx=10)

        ctk.CTkButton(self.sidebar, text="リセット (自由線)", fg_color="#e74c3c", command=self.reset_lines).pack(pady=5, padx=10)
        
        ctk.CTkLabel(self.sidebar, text="保存アクション", font=("Arial", 14, "bold")).pack(pady=(15, 5))
        ctk.CTkButton(self.sidebar, text="PNGフォルダ保存", fg_color="#3498db", command=lambda: self.process_images("folder")).pack(pady=5, padx=10)
        ctk.CTkButton(self.sidebar, text="ZIPファイル保存", fg_color="#9b59b6", command=lambda: self.process_images("zip")).pack(pady=5, padx=10)

        # プレビューエリア
        self.preview_canvas = tk.Canvas(self, bg="#1e1e1e", highlightthickness=0)
        self.preview_canvas.grid(row=0, column=1, sticky="nsew", padx=10, pady=10)
        self.preview_canvas.bind("<Button-1>", self.on_canvas_click)
        self.preview_canvas.bind("<Configure>", lambda e: self.update_preview())

    def create_input(self, parent, text, default):
        ctk.CTkLabel(parent, text=text).pack()
        entry = ctk.CTkEntry(parent)
        entry.insert(0, default)
        entry.pack(pady=2, padx=10)
        return entry

    def load_image(self):
        path = filedialog.askopenfilename()
        if path:
            self.source_path = path
            self.original_img = Image.open(path).convert("RGBA")
            self.reset_lines()
            self.update_preview()

    def reset_lines(self):
        self.v_lines = []
        self.h_lines = []
        self.update_preview()

    def on_canvas_click(self, event):
        if self.mode_var.get() != "manual" or not self.original_img: return
        cw, ch = self.preview_canvas.winfo_width(), self.preview_canvas.winfo_height()
        iw, ih = self.display_size
        if iw == 0 or ih == 0: return
        
        ox, oy = (cw - iw) / 2, (ch - ih) / 2
        
        # クリック位置を元画像の座標に変換
        img_x = (event.x - ox) * (self.original_img.width / iw)
        img_y = (event.y - oy) * (self.original_img.height / ih)

        # 画像範囲内なら線を追加
        if 0 <= img_x <= self.original_img.width and 0 <= img_y <= self.original_img.height:
            # クリック位置判定(左端付近なら横線、上端付近なら縦線)
            if event.x < ox + 60: 
                self.h_lines.append(int(img_y))
            elif event.y < oy + 60: 
                self.v_lines.append(int(img_x))
            else:
                # ど真ん中の場合は近い方の軸で判定(簡易ロジック)
                self.v_lines.append(int(img_x))
            
            self.update_preview()

    def update_preview(self, event=None):
        if not self.original_img: return
        self.update_idletasks()
        
        cw = max(self.preview_canvas.winfo_width(), 100)
        ch = max(self.preview_canvas.winfo_height(), 100)
        
        display_img = self.original_img.copy()
        display_img.thumbnail((cw - 60, ch - 60))
        self.display_size = display_img.size
        dw, dh = display_img.size
        
        draw = ImageDraw.Draw(display_img)
        
        if self.mode_var.get() == "grid":
            try:
                r_val = self.entry_rows.get()
                c_val = self.entry_cols.get()
                r = int(r_val) if r_val.isdigit() else 2
                c = int(c_val) if c_val.isdigit() else 2
                
                for i in range(1, c): draw.line([(i*dw/c, 0), (i*dw/c, dh)], fill="red", width=2)
                for i in range(1, r): draw.line([(0, i*dh/r), (dw, i*dh/r)], fill="red", width=2)
            except: pass
        else:
            sx, sy = dw / self.original_img.width, dh / self.original_img.height
            for x in self.v_lines: draw.line([(x*sx, 0), (x*sx, dh)], fill="cyan", width=2)
            for y in self.h_lines: draw.line([(0, y*sy), (dw, y*sy)], fill="cyan", width=2)

        self.tk_img = ImageTk.PhotoImage(display_img)
        self.preview_canvas.delete("all")
        self.preview_canvas.create_image(cw//2, ch//2, image=self.tk_img)

    def process_images(self, mode):
        if not self.original_img: return
        try:
            w, h = self.original_img.size
            if self.mode_var.get() == "grid":
                r_val, c_val = self.entry_rows.get(), self.entry_cols.get()
                r = int(r_val) if r_val.isdigit() else 2
                c = int(c_val) if c_val.isdigit() else 2
                xs = [int(round(i * w / c)) for i in range(c + 1)]
                ys = [int(round(i * h / r)) for i in range(r + 1)]
            else:
                xs = sorted(list(set([0] + self.v_lines + [w])))
                ys = sorted(list(set([0] + self.h_lines + [h])))

            ratio_map = {"1:1": 1.0, "3:4": 0.75, "4:3": 1.33}
            target_ratio = ratio_map.get(self.ratio_mode.get())
            
            output_imgs = []
            
            # 画像生成ループ
            for i in range(len(ys)-1):
                for j in range(len(xs)-1):
                    # 範囲外・極小サイズを除外
                    if xs[j+1] - xs[j] < 5 or ys[i+1] - ys[i] < 5: continue
                    
                    crop = self.original_img.crop((xs[j], ys[i], xs[j+1], ys[i+1]))
                    
                    # アスペクト比調整(グリッド時のみ)
                    if target_ratio and self.mode_var.get() == "grid":
                        cw, ch = crop.size
                        if cw/ch > target_ratio:
                            nw = ch * target_ratio
                            crop = crop.crop(((cw-nw)//2, 0, (cw+nw)//2, ch))
                        elif cw/ch < target_ratio:
                            nh = cw / target_ratio
                            crop = crop.crop((0, (ch-nh)//2, cw, (ch+nh)//2))
                    
                    # 透過処理
                    if self.check_rembg.get():
                        crop = remove(crop)
                    
                    output_imgs.append(crop)

            if not output_imgs:
                messagebox.showwarning("注意", "分割できる領域がありませんでした。")
                return

            # 保存処理
            if mode == "folder":
                path = filedialog.askdirectory()
                if path:
                    for i, img in enumerate(output_imgs):
                        img.save(os.path.join(path, f"split_{i+1:03d}.png"), "PNG")
                    messagebox.showinfo("成功", f"{len(output_imgs)}枚保存しました")
            else:
                path = filedialog.asksaveasfilename(defaultextension=".zip", filetypes=[("ZIP", "*.zip")])
                if path:
                    with zipfile.ZipFile(path, 'w') as z:
                        for i, img in enumerate(output_imgs):
                            buf = BytesIO()
                            img.save(buf, format="PNG")
                            z.writestr(f"split_{i+1:03d}.png", buf.getvalue())
                    messagebox.showinfo("成功", "ZIP保存が完了しました")
                    
        except Exception as e:
            # 万が一のエラー時は詳細を表示
            import traceback
            err_msg = traceback.format_exc()
            messagebox.showerror("エラー詳細", f"予期せぬエラー:\n{e}\n\n{err_msg}")

if __name__ == "__main__":
    app = ImageSplitterApp()
    app.mainloop()
python -m PyInstaller --noconsole --onefile --collect-all rembg image_splitter_final_v5.py

LINEスタンプ作ってます
https://line.me/S/shop/sticker/author/5676844…

いいなと思ったら応援しよう!

華音玻璃 ここまで読んでいただけて感謝!チップはAIツールに課金させていただきます!!