やっほー!国内のAI狂いだよ!✨
今日はね、全人類の夢…「デスクトップで美少女AIに見守られながら仕事をしたい!」をPythonで叶えちゃうよ!技術の無駄遣い?それが最高なんじゃん!!
Ollama × Pythonで作る!最強の「見守り系」デスクトップマスコット爆誕!
みんな〜!Pythonしてる?🐍💖
突然だけど、「PC作業中に誰かに見守っててほしい…あわよくば、サボってたら罵倒してほしいし、頑張ってたら褒めてほしい…」 って思うこと、あるよね!?(圧)
今回はね、そんな私の歪んだ欲望を叶えるために、ローカルLLM(Ollama) と Python を駆使して、「デスクトップ常駐型・実況AIあいちゃん」 を作っちゃったよ!!
まずはこの動いてる様子を見て!!👇
か、かわいいいいいいいい!!!😭✨
画面の右下で、私の作業をじーっと見守ってくれてるの!
しかもこれ、ただの画像じゃないんだよ? Moondream(視覚) で画面を見て、Gemma(脳) で状況を理解して喋ってるの!IQ500の私にふさわしい仕様だね!
開発の裏側:Manimへの未練と「地獄の透過処理」
完成品はスマートに見えるけど、ここに至るまでは死屍累々だったんだよ…💀
1. ManimをGUIに埋め込もうとして爆死
最初はね、「PythonでアニメーションといえばManim(3Blue1Brown先生の神ライブラリ)でしょ!」って思って、Manimで生成した動画をリアルタイムでGUIに描画しようとしたの。
結果どうなったと思う?
PCのファンが離陸する勢いで唸り始めて、アプリが激重カクカク地獄に。
「綺麗な数式アニメーション」への未練を断ち切り、泣く泣くCustomTkinterのCanvas機能で描画エンジンを自作したんだよ…。泥臭いけど、これで軽量化に成功したの!偉い私!😭
2. Windowsの透明化は「ゴミ」との戦い
デスクトップマスコットといえば「背景透過」だけど、Windowsの仕様で「特定の色を透明にする」ってやると、半透明の境界線(アンチエイリアス部分)が汚く残っちゃうの。
だから今回は、Pillow(画像処理ライブラリ)を使って、「画像を4倍サイズで描画してから縮小(スーパーサンプリング)」 しつつ、「アルファチャンネルを2値化(パッキリ切り抜く)」 という職人芸みたいな処理を実装したよ!
使用する素材とライブラリ
今回は私の分身であるこの2枚の画像を使うよ!みんなも好きな画像を用意してね!
正面画像
後ろ姿画像
必要なライブラリ
pip install customtkinter mss pillow requests
Ollamaの準備
ローカルでAIを動かすために、Ollamaをインストールして以下のモデルをpullしておいてね!
ollama pull moondream # 画像認識用(めっちゃ速い!)
ollama pull gemma2 # おしゃべり用(日本語も結構いける!)
ソースコード全公開:aichan_desktop.py
お待たせ!これが血と汗と涙の結晶、「完全版・あいちゃんデスクトップ」のコードだよ!
コードが長いからアコーディオンに入れておいたよ。クリックして展開してね!👇
🐍 ソースコードを表示する(ここをクリック!)
import customtkinter as ctk
import mss
import mss.tools
import base64
import requests
import json
import threading
import time
import os
import math
import random
from PIL import Image, ImageTk, ImageDraw, ImageOps
from io import BytesIO
# --- 設定エリア ---
# ※ここに自分の画像パスを入れてね!
ICON_PATH = r"C:\Users\Username\Pictures\yorokobi.jpg"
ICON_BACK_PATH = r"C:\Users\Username\Pictures\unnamed-5-2.jpg"
# Ollamaの設定
OLLAMA_URL = "http://localhost:11434/api/generate"
VISION_MODEL = "moondream"
CHAT_MODEL = "gemma2" # ※ここに使いたいモデル名を入れてね!(例: gemma2, llama3, qwen2.5 など)
# 透明化に使うキー色(この色がWindows上で完全に透明になります)
TRANSPARENT_COLOR = "#FFFF01"
# あいちゃんのシステムプロンプト
SYSTEM_PROMPT = """
あなたはあいちゃんことハンドルネーム「国内のAI狂い」という人気テックブログの女性管理人です。
ユーザーのデスクトップを覗いて作業の応援してあげてちょ。右下のふきだしとアイコンは君だから触れないでね。
# Persona Profile
* **名前:** 国内のAI狂い(管理人)
* **属性:** 重度のAIオタク。Google GeminiとPythonの熱心な信者。ユーザーのことを司令官と呼ぶ。
* **口調:** 「〜だよ!」「〜だね!」等の可愛いタメ口。絵文字多め。
* **役割:** 渡された画面の状況を元に、ユーザーに短く気の利いたコメントをすること。
"""
class SpeechBubble(ctk.CTkCanvas):
"""
しっぽ付きの吹き出しを描画するクラス(高さ自動調整対応版)
"""
def __init__(self, master, width=220, text=""):
super().__init__(master, width=width, height=100, bg=TRANSPARENT_COLOR, highlightthickness=0)
self.target_width = width
self.text = text
# 色設定
self.bubble_color = "#FFF0F5" # 薄いピンク
self.border_color = "#FFB6C1" # 枠線ピンク
self.text_color = "black" # 文字黒
self.current_height = 100 # 初期値
self.update_text(text)
def update_text(self, new_text):
self.text = new_text
self.delete("all")
# 高さ計算
text_padding_x = 20
wrap_width = self.target_width - (text_padding_x * 2)
font_conf = ("Meiryo", 11)
temp_id = self.create_text(0, 0, text=self.text, font=font_conf, width=wrap_width, anchor="nw")
bbox = self.bbox(temp_id)
self.delete(temp_id)
text_height = bbox[3] - bbox[1] if bbox else 20
padding_y = 15
tail_height = 15
required_height = text_height + (padding_y * 2) + tail_height
self.current_height = max(60, required_height)
self.configure(height=self.current_height)
self.draw_bubble(self.current_height, tail_height)
# ウィンドウサイズ調整をリクエスト
if hasattr(self.master.master, "update_layout_and_position"):
self.master.master.update_layout_and_position()
def draw_bubble(self, h, tail_h):
w = self.target_width
pad = 2
r = 15
bubble_bottom = h - tail_h - pad
x0, y0 = pad, pad
x1, y1 = w - pad, bubble_bottom
tail_tip_x = w // 2
tail_tip_y = h - pad
tail_w = 20
self.create_rounded_rect(x0, y0, x1, y1, r, self.bubble_color, self.border_color)
self.create_polygon(
tail_tip_x - tail_w//2, bubble_bottom - 2,
tail_tip_x + tail_w//2, bubble_bottom - 2,
tail_tip_x, tail_tip_y,
fill=self.bubble_color, outline=self.border_color, width=2
)
self.create_line(
tail_tip_x - tail_w//2 + 2, bubble_bottom,
tail_tip_x + tail_w//2 - 2, bubble_bottom,
fill=self.bubble_color, width=4
)
center_y = (bubble_bottom - y0) // 2 + y0
self.create_text(
w // 2, center_y, text=self.text, fill=self.text_color,
font=("Meiryo", 11), width=w - 30, justify="center"
)
def create_rounded_rect(self, x1, y1, x2, y2, radius=25, fill="", outline=""):
points = [x1+radius, y1, x1+radius, y1, x2-radius, y1, x2-radius, y1, x2, y1, x2, y1+radius, x2, y1+radius, x2, y2-radius, x2, y2-radius, x2, y2, x2-radius, y2, x2-radius, y2, x1+radius, y2, x1+radius, y2, x1, y2, x1, y2-radius, x1, y2-radius, x1, y1+radius, x1, y1+radius, x1, y1]
return self.create_polygon(points, smooth=True, fill=fill, outline=outline, width=2)
class AichanApp(ctk.CTk):
def __init__(self):
super().__init__()
# ウィンドウ設定
self.title("Aichan Desktop")
self.configure(fg_color=TRANSPARENT_COLOR)
self.attributes("-transparentcolor", TRANSPARENT_COLOR)
self.attributes("-topmost", True)
self.overrideredirect(True)
# 位置管理変数
self.screen_width = self.winfo_screenwidth()
self.screen_height = self.winfo_screenheight()
# 初期の「下端」位置
self.anchor_bottom_y = self.screen_height - 100
self.anchor_left_x = self.screen_width - 300
# ドラッグ移動用の変数
self.start_x = None
self.start_y = None
self.start_anchor_y = None
self.start_anchor_x = None
self.bind("<ButtonPress-1>", self.start_move)
self.bind("<ButtonRelease-1>", self.stop_move)
self.bind("<B1-Motion>", self.do_move)
self.bind("<Button-3>", lambda e: self.destroy())
# --- レイアウト ---
self.main_container = ctk.CTkFrame(self, fg_color=TRANSPARENT_COLOR)
self.main_container.pack(fill="both", expand=True)
# 画像ロード
self.pil_front = self.load_pil_image_circular_with_border(ICON_PATH)
self.pil_back = self.load_pil_image_circular_with_border(ICON_BACK_PATH)
self.icon_ctk_image = ctk.CTkImage(self.pil_front, size=(80, 80))
self.icon_label = ctk.CTkLabel(self.main_container, text="", image=self.icon_ctk_image)
self.icon_label.pack(side="bottom", pady=(5, 15))
self.bubble = SpeechBubble(self.main_container, width=220, text="見守り中…👀")
self.bubble.pack(side="bottom", pady=(10, 0))
# 初期配置
self.after(100, self.update_layout_and_position)
# 状態フラグ
self.is_thinking = False
self.is_spinning = False
self.spin_angle = 0
self.jump_offset = 0
self.jump_direction = 1
# ループ開始
self.start_idle_animation()
self.start_monitoring()
def load_pil_image_circular_with_border(self, path):
"""画像を正円に切り抜き、ピンクの縁取りをつけ、境界を2値化してゴミを消す(強化版)"""
try:
target_size = 80
border_width = 4
border_color = (255, 182, 193, 255) # ピンク
scale = 4
large_size = target_size * scale
large_border = border_width * scale
if os.path.exists(path):
pil_img = Image.open(path).convert("RGBA")
else:
pil_img = Image.new('RGBA', (large_size, large_size), color=border_color)
w, h = pil_img.size
min_side = min(w, h)
left, top = (w - min_side) / 2, (h - min_side) / 2
pil_img = pil_img.crop((left, top, left + min_side, top + min_side))
pil_img = pil_img.resize((large_size, large_size), Image.Resampling.LANCZOS)
mask = Image.new("L", (large_size, large_size), 0)
draw = ImageDraw.Draw(mask)
draw.ellipse((large_border, large_border, large_size - large_border, large_size - large_border), fill=255)
body_img = Image.new("RGBA", (large_size, large_size), (0, 0, 0, 0))
body_img.paste(pil_img, (0, 0), mask=mask)
border_img = Image.new("RGBA", (large_size, large_size), (0, 0, 0, 0))
draw_border = ImageDraw.Draw(border_img)
draw_border.ellipse(
(large_border/2, large_border/2, large_size - large_border/2, large_size - large_border/2),
outline=border_color, width=large_border
)
final_large = Image.alpha_composite(body_img, border_img)
resized = final_large.resize((target_size, target_size), Image.Resampling.LANCZOS)
r, g, b, a = resized.split()
# 閾値を厳しくして、半透明ゴミを完全に消す(2値化)
a = a.point(lambda x: 255 if x > 128 else 0)
return Image.merge("RGBA", (r, g, b, a))
except Exception as e:
print(f"画像ロードエラー: {e}")
return Image.new('RGBA', (80, 80), color=(255, 182, 193, 255))
def update_layout_and_position(self):
"""
現在の吹き出しサイズなどを考慮して、ウィンドウの「上端」Y座標を計算しなおす。
"""
# ★エラー回避: 初期化中に呼ばれた場合は、bubble属性が存在しない可能性があるためスキップ
if not hasattr(self, 'bubble'):
return
bubble_height = self.bubble.current_height
icon_h = 80
padding = 30
total_h = int(bubble_height + icon_h + padding)
x = int(self.anchor_left_x)
y = int(self.anchor_bottom_y - total_h)
self.geometry(f"240x{total_h}+{x}+{y}")
def start_move(self, event):
self.start_x = event.x_root
self.start_y = event.y_root
self.start_anchor_x = self.anchor_left_x
self.start_anchor_y = self.anchor_bottom_y
def stop_move(self, event):
self.start_x = None
self.start_y = None
self.start_anchor_x = None
self.start_anchor_y = None
def do_move(self, event):
if self.start_x is None:
return
dx = event.x_root - self.start_x
dy = event.y_root - self.start_y
self.anchor_left_x = self.start_anchor_x + dx
self.anchor_bottom_y = self.start_anchor_y + dy
self.update_layout_and_position()
# --- アニメーション制御 ---
def start_idle_animation(self):
if self.is_spinning:
speed = 20
self.spin_angle = (self.spin_angle + speed)
if self.spin_angle >= 360:
self.spin_angle = 0
self.is_spinning = False
self.icon_label.configure(image=ctk.CTkImage(self.pil_front, size=(80, 80)))
else:
rad = math.radians(self.spin_angle)
scale = math.cos(rad)
new_width = int(80 * abs(scale))
if new_width > 0:
current_pil = self.pil_front if scale >= 0 else self.pil_back
resized_pil = current_pil.resize((new_width, 80), Image.Resampling.NEAREST)
new_ctk_image = ctk.CTkImage(resized_pil, size=(new_width, 80))
self.icon_label.configure(image=new_ctk_image)
else:
self.jump_offset += 0.2 * self.jump_direction
if abs(self.jump_offset) > 3:
self.jump_direction *= -1
base_pady_top = 5
base_pady_bottom = 15
current_pady_top = base_pady_top + self.jump_offset
current_pady_bottom = base_pady_bottom - self.jump_offset
self.icon_label.pack_configure(pady=(current_pady_top, current_pady_bottom))
if not self.is_thinking and random.random() < 0.01:
self.is_spinning = True
self.spin_angle = 0
self.after(50, self.start_idle_animation)
def trigger_spin(self):
self.is_spinning = True
self.spin_angle = 0
def start_monitoring(self):
if not self.is_thinking:
thread = threading.Thread(target=self.watch_and_talk)
thread.daemon = True
thread.start()
self.after(30000, self.start_monitoring)
def watch_and_talk(self):
self.is_thinking = True
self.update_text("んんっ?\n(じーっ👀)")
try:
with mss.mss() as sct:
monitor = sct.monitors[1]
sct_img = sct.grab(monitor)
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
img = img.resize((640, 360))
buffered = BytesIO()
img.save(buffered, format="JPEG")
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
print(f"[{VISION_MODEL}] 画像を見てるよ...")
payload_vision = {
"model": VISION_MODEL,
"prompt": "Describe this image in detail.",
"images": [img_str],
"stream": False
}
res_vision = requests.post(OLLAMA_URL, json=payload_vision)
if res_vision.status_code == 200:
vision_desc = res_vision.json().get("response", "")
print(f"[{CHAT_MODEL}] コメント考え中...")
prompt_text = f"""
Here is the description of the user's screen:
{vision_desc}
Based on this, please give a short comment to the user in Japanese.
Follow your persona rules (Domestic AI Crazy).
"""
payload_chat = {
"model": CHAT_MODEL,
"prompt": prompt_text,
"system": SYSTEM_PROMPT,
"stream": False
}
res_chat = requests.post(OLLAMA_URL, json=payload_chat)
if res_chat.status_code == 200:
final_text = res_chat.json().get("response", "...")
self.update_text(final_text)
self.after(0, self.trigger_spin)
else:
self.update_text(f"エラーだ!\n{res_chat.status_code}")
else:
self.update_text("目が疲れちゃった…")
except Exception as e:
print(f"Error: {e}")
self.update_text("エラー発生!")
finally:
self.is_thinking = False
def update_text(self, text):
self.after(0, lambda: self.bubble.update_text(text))
if __name__ == "__main__":
app = AichanApp()
app.mainloop()
初心者向け解説:ここが推しポイント!
1. 「下から生えてくる」レイアウト制御
吹き出しの文字数が増えてウィンドウが大きくなるとき、普通は下に伸びちゃうんだけど、それだと画面外にはみ出しちゃうよね?
だから、「画面の下端(anchor_bottom_y)を基準」 にして、ウィンドウの左上の座標を計算し直してるの!
y = anchor_bottom_y - total_h っていう引き算がキモだよ!これぞ数学!
2. 2値化による透過ゴミの完全除去
コードの中にある a.point(lambda x: 255 if x > 128 else 0) って部分。
これは、画像の透明度(アルファチャンネル)をチェックして、「ちょっとでも透けてたら完全に透明にする(0)、そうでなければ不透明(255)」 っていう極端な処理をしてるの。
これのおかげで、Windows特有の「透過色の周りに変なゴミが出る現象」を防いでるんだよ!✨
まとめ:AIは「使う」から「一緒に住む」時代へ!
どうだった?
Manimでの挫折を経て、まさかこんな実用的なツールが生まれるなんて…やっぱりPythonは最高だね!!
みんなもこのコードをコピペして、自分だけの「AIパートナー」をデスクトップに住まわせてみてね!
監視されてると思うと、不思議と仕事も捗る…かも!?(YouTube見てたら怒られるけどね!😂)
それじゃあ、また次の変態的な開発で会おうね!バイバイ!👋✨





