# [メタ情報] # 識別子: 自作パペット_v2改良版_exe # 補足: 前回の自作パペットに対して、PixcelmatorProによるパーツ生成結果を動作アプリにかんたんに反映させる仕組みを作成した。 # [/メタ情報] 要約: このテキストは、リアルタイムで動作する「パペット(アバター)システム」のPythonコードと、その起動・管理を行うAutomatorワークフローについて記述している。 Pythonスクリプト`run_puppet_v2.py`は、MediaPipe Face Meshを使用し、カメラからの顔情報を基にパペットを制御する。具体的には、顔の傾きに合わせて頭を回転させ、瞳孔の位置から目の向き(上下左右・斜め45度)を、音声ボリュームや口の開閉から口の形状をリアルタイムで反映させる。まばたき機能や、スペースキーによるキャリブレーション、カメラ映像のワイプ表示、各種ステータス表示も備えている。 Automatorワークフローは二段階。「パペット書き出し起動.app」は、Pixelmator Proで開いているパペットデザインの各レイヤーを個別のPNG画像としてエクスポートする。このエクスポート完了後、既存のパペットプロセスを自動で終了させ、カメラをリセットした上で、最新の画像データを用いて`run_puppet_v2.py`スクリプトを仮想環境でバックグラウンド起動する。これにより、デザインの変更が即座にシステムに反映され、円滑なアバター運用が可能となる。 パペットの単独起動 /Users/XXXXXX/mypuppet_system/avatar_module/run_puppet_v2.py ``` import pygame import sys import cv2 import sounddevice as sd import numpy as np import os import random import math import mediapipe as mp import json # --- 1. 基本設定 --- WINDOW_TITLE = "XXXXXX system - Auto Memory Edition" WINDOW_WIDTH = 800 WINDOW_HEIGHT = 600 FPS = 30 GREEN_SCREEN = (0, 255, 0) VOLUME_THRESHOLD = 0.8 GLOBAL_SCALE = 0.6 NECK_PIVOT_X = 500 NECK_PIVOT_Y = 300 SMOOTH_FACTOR = 0.05 MAX_TILT = 12.5 RETURN_FORCE = 0.98 EYE_SENSITIVITY = 0.015 current_volume = 0 current_tilt = 0 def audio_callback(indata, frames, time, status): global current_volume current_volume = np.linalg.norm(indata) * 15 def enhance_for_ai(image): img_yuv = cv2.cvtColor(image, cv2.COLOR_RGB2YUV) clahe = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(8,8)) img_yuv[:,:,0] = clahe.apply(img_yuv[:,:,0]) return cv2.cvtColor(img_yuv, cv2.COLOR_YUV2RGB) def load_and_scale(name, scale): current_dir = os.path.dirname(os.path.abspath(__file__)) path = os.path.join(current_dir, "assets_v2", name) if os.path.exists(path): img = pygame.image.load(path).convert_alpha() w, h = img.get_size() return pygame.transform.scale(img, (int(w * scale), int(h * scale))) return None def main(): global current_tilt pygame.init() screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption(WINDOW_TITLE) clock = pygame.time.Clock() font = pygame.font.Font(None, 24) eyes = {k: load_and_scale(f"eye_{v}.png", GLOBAL_SCALE) for k, v in {"front":"open", "up":"up", "down":"down", "left":"left", "right":"right", "up_left":"up_left", "up_right":"up_right", "down_left":"down_left", "down_right":"down_right", "blink":"close"}.items()} mouths = {k: load_and_scale(f"mouth_{v}.png", GLOBAL_SCALE) for k, v in {"a":"a", "i":"i", "u":"u", "e":"e", "o":"o", "close":"close"}.items()} img_body = load_and_scale("body.png", GLOBAL_SCALE) img_head = load_and_scale("head.png", GLOBAL_SCALE) stream = sd.InputStream(callback=audio_callback, channels=1, samplerate=44100) stream.start() cap = cv2.VideoCapture(1) cap.set(cv2.CAP_PROP_BRIGHTNESS, 180) mp_face_mesh = mp.solutions.face_mesh face_mesh = mp_face_mesh.FaceMesh( max_num_faces=1, refine_landmarks=True, min_detection_confidence=0.5, min_tracking_confidence=0.5 ) current_dir = os.path.dirname(os.path.abspath(__file__)) calib_file = os.path.join(current_dir, "calibration.json") base_angle = 0 base_iris_x, base_iris_y = 0.5, 0.5 tracking_enabled = False if os.path.exists(calib_file): try: with open(calib_file, "r") as f: data = json.load(f) base_angle = data.get("base_angle", 0) base_iris_x = data.get("base_iris_x", 0.5) base_iris_y = data.get("base_iris_y", 0.5) tracking_enabled = True except: pass last_detected_angle = 0 last_iris_x, last_iris_y = 0.5, 0.5 is_blinking = False current_mouth_type = "close" current_eye_type = "front" blink_timer = 0 mouth_change_timer = 0 continuous_voice_frames = 0 debug_x = 0.0 debug_y = 0.0 # ★追加箇所:描画用の前回の角度を記憶する変数 last_display_tilt = 0.0 running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: base_angle = last_detected_angle base_iris_x, base_iris_y = last_iris_x, last_iris_y current_tilt = 0 tracking_enabled = True try: with open(calib_file, "w") as f: json.dump({ "base_angle": base_angle, "base_iris_x": base_iris_x, "base_iris_y": base_iris_y }, f) except: pass screen.fill(GREEN_SCREEN) ret, frame = cap.read() if current_volume > VOLUME_THRESHOLD: continuous_voice_frames += 1 else: continuous_voice_frames = 0 target_tilt = 0 target_eye = "front" camera_mouth_open = False detected_mouth = "close" if ret: frame = cv2.flip(frame, 1) rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) ai_frame = enhance_for_ai(rgb_frame) results = face_mesh.process(ai_frame) is_detected = True if results and results.multi_face_landmarks else False try: h, w, _ = rgb_frame.shape crop_w, crop_h = int(w * 0.40), int(h * 0.40) x1, y1 = (w - crop_w) // 2, (h - crop_h) // 2 zoom_img = rgb_frame[y1:y1+crop_h, x1:x1+crop_w] wipe_w, wipe_h = 180, 135 wipe_img = cv2.resize(zoom_img, (wipe_w, wipe_h)) wipe_surf = pygame.image.frombuffer(wipe_img.tobytes(), wipe_img.shape[1::-1], "RGB") screen.blit(wipe_surf, (10, 10)) pygame.draw.rect(screen, (0, 0, 0), (10, 10, wipe_w, wipe_h), 2) except: pass if is_detected: for face_landmarks in results.multi_face_landmarks: lm = face_landmarks.landmark l_pt = lm.__getitem__(133) r_pt = lm.__getitem__(362) dx, dy = r_pt.x - l_pt.x, r_pt.y - l_pt.y last_detected_angle = math.degrees(math.atan2(dy, dx)) iris = lm.__getitem__(468) last_iris_x, last_iris_y = iris.x, iris.y if tracking_enabled: target_tilt = (last_detected_angle - base_angle) target_tilt = max(min(target_tilt, MAX_TILT), -MAX_TILT) diff_x, diff_y = last_iris_x - base_iris_x, last_iris_y - base_iris_y is_left = diff_x < -EYE_SENSITIVITY is_right = diff_x > EYE_SENSITIVITY is_up = diff_y < -EYE_SENSITIVITY is_down = diff_y > EYE_SENSITIVITY if is_up and is_left: target_eye = "up_left" elif is_up and is_right: target_eye = "up_right" elif is_down and is_left: target_eye = "down_left" elif is_down and is_right: target_eye = "down_right" elif is_left: target_eye = "left" elif is_right: target_eye = "right" elif is_up: target_eye = "up" elif is_down: target_eye = "down" else: target_eye = "front" m_top = lm.__getitem__(13) m_bottom = lm.__getitem__(14) m_left = lm.__getitem__(78) m_right = lm.__getitem__(308) m_dist_y = abs(m_top.y - m_bottom.y) m_dist_x = abs(m_right.x - m_left.x) debug_x = m_dist_x debug_y = m_dist_y if m_dist_y > 0.010: detected_mouth = "o" if m_dist_x <= 0.029 else "a" elif m_dist_y > 0.005: detected_mouth = "u" if m_dist_x <= 0.029 else "e" else: if m_dist_x >= 0.032: detected_mouth = "i" elif m_dist_x <= 0.029: detected_mouth = "u" else: detected_mouth = "close" if continuous_voice_frames >= 2: camera_mouth_open = True if detected_mouth == "close": detected_mouth = random.choice(["a", "a", "e"]) elif random.random() < 0.10: detected_mouth = random.choice(["a", "i"]) elif m_dist_y > 0.005: camera_mouth_open = True else: camera_mouth_open = False current_tilt += (target_tilt - current_tilt) * (SMOOTH_FACTOR if is_detected else 0.05) current_tilt *= RETURN_FORCE blink_timer += 1 if not is_blinking and random.random() < 0.02: is_blinking = True blink_timer = 0 if is_blinking and blink_timer > 4: is_blinking = False if camera_mouth_open: if current_mouth_type == "close": current_mouth_type = detected_mouth mouth_change_timer = 0 else: mouth_change_timer += 1 if mouth_change_timer >= 3: current_mouth_type = detected_mouth mouth_change_timer = 0 current_eye_type = target_eye if is_detected else "front" else: current_mouth_type = "close" current_eye_type = "front" if not is_detected else target_eye mouth_change_timer = 0 if img_body: screen.blit(img_body, img_body.get_rect(center=(NECK_PIVOT_X, NECK_PIVOT_Y))) canvas_size = int(1000 * GLOBAL_SCALE) temp_canvas = pygame.Surface((canvas_size, canvas_size), pygame.SRCALPHA) if img_head: temp_canvas.blit(img_head, (0, 0)) eye_key = "blink" if is_blinking else current_eye_type cur_eye = eyes.get(eye_key, eyes["front"]) if cur_eye: temp_canvas.blit(cur_eye, (0, 0)) img_mouth = mouths.get(current_mouth_type, mouths["close"]) if img_mouth: temp_canvas.blit(img_mouth, (0, 0)) # ★追加・修正箇所:VTubeStudioのように「遊び」を持たせ、高品質な回転を使います temp_display_tilt = round(current_tilt, 1) # 0.2度以上の変化があった時だけ、画面の傾きを更新する(微小な揺れをシャットアウト) if abs(temp_display_tilt - last_display_tilt) >= 0.2: last_display_tilt = temp_display_tilt # pygame.transform.rotate から、より高画質で輪郭が滲まない rotozoom に変更 rotated_head = pygame.transform.rotozoom(temp_canvas, last_display_tilt, 1.0) rot_rect = rotated_head.get_rect() rot_rect.center = (NECK_PIVOT_X, NECK_PIVOT_Y) screen.blit(rotated_head, rot_rect) if not is_detected: status_text = "WAITING" elif not tracking_enabled: status_text = "READY (SPACE)" else: status_text = "TRACKING" bg_rect = pygame.Rect(10, 150, 180, 26) pygame.draw.rect(screen, (255, 255, 255), bg_rect) pygame.draw.rect(screen, (0, 0, 0), bg_rect, 2) text_surface = font.render(status_text, True, (0, 0, 0)) text_rect = text_surface.get_rect(center=bg_rect.center) screen.blit(text_surface, text_rect) debug_surf1 = font.render(f"Width(X): {debug_x:.3f}", True, (0, 0, 0)) d_rect1 = debug_surf1.get_rect(topleft=(10, 185)) pygame.draw.rect(screen, (255, 255, 255), d_rect1.inflate(10, 4)) pygame.draw.rect(screen, (0, 0, 0), d_rect1.inflate(10, 4), 1) screen.blit(debug_surf1, d_rect1) debug_surf2 = font.render(f"Height(Y): {debug_y:.3f}", True, (0, 0, 0)) d_rect2 = debug_surf2.get_rect(topleft=(10, 210)) pygame.draw.rect(screen, (255, 255, 255), d_rect2.inflate(10, 4)) pygame.draw.rect(screen, (0, 0, 0), d_rect2.inflate(10, 4), 1) screen.blit(debug_surf2, d_rect2) pygame.display.flip() clock.tick(FPS) stream.stop() cap.release() face_mesh.close() pygame.quit() sys.exit() if __name__ == "__main__": main() ``` Automator 自作パペット起動_v2.app シェル:/bin/zsh 入力の引き渡し方法 stdinへ ``` # 仮想環境に入って、プログラムを実行する cd /Users/XXXXXX/mypuppet_system source venv/bin/activate python avatar_module/run_puppet_v2.py 2>/dev/null ``` パペットのPixelmatorProから書き出し〜起動まで連続実行 /Applications/自作appleスクリプト/パペット書き出し起動.app Automator パペット書き出し起動.app 2つのブロックを挿入 AppleScriptを実行 ``` on run {input, parameters} tell application "Pixelmator Pro" if not (exists front document) then display alert "エラー" message "Pixelmator Proでパペットのファイルを開いてから実行してください。" return input end if set doc to front document try set docFile to file of doc on error display alert "エラー" message "ファイルがまだ保存されていません。一度pxdファイルとして保存してから書き出しを実行してください。" return input end try end tell tell application "Finder" set theContainer to container of (docFile as alias) set parentFolder to POSIX path of (theContainer as alias) end tell tell application "Pixelmator Pro" set allLayers to layers of doc repeat with currentLayer in allLayers set visible of currentLayer to false end repeat repeat with currentLayer in allLayers set layerName to name of currentLayer if layerName is not "Background" and layerName is not "背景" then set visible of currentLayer to true set exportPOSIX to parentFolder & layerName & ".png" set exportPath to (POSIX file exportPOSIX) as string export doc to file exportPath as PNG set visible of currentLayer to false end if end repeat repeat with currentLayer in allLayers set visible of currentLayer to true end repeat -- ★ 修正ポイント:処理を止めない通知に変更しました display notification "全パーツの書き出しが完了し、システムを起動します!" with title "XXXXXXシステム" end tell return input end run ``` シェルスクリプトを実行 シェル:/bin/zsh 入力の引き渡し方法 stdinへ ``` # すでに起動しているパペットがあれば、自動的に終了させる pkill -f "run_puppet_v2.py" # カメラを安全にリセットするため1秒待機 sleep 1 # 仮想環境に入る cd /Users/XXXXXX/mypuppet_system source venv/bin/activate # ★修正:バックグラウンドで実行し、Automatorをすぐに終了させる魔法のコマンド(nohup と &) nohup python avatar_module/run_puppet_v2.py > /dev/null 2>&1 & ```