以前PyQt5で簡単なお絵描きアプリを作りましたが、「写真画像の顔とポーズを検知してその結果を3Dデータに書き出す機能があれば、便利だろうなあ」と思い、実装してみました。ついでにPyQt5からPyQt6に移行済み。いずれGitHubにアップロードしようと思っています。
写真画像から3Dデータを取得
① スタート画面
② 画像を読込み
③ 「検知」メニュー
④ ヒント
⑤ 結果を上書き
⑥ PLY形式で書出し
⑦ 未修正のPLYをBlenderに読み込んだ結果
⑧ mediapipeの各部のつながり情報
⑦のままではキャラクタ用スケルトンとしては使いにくいので、頂点を増やし、11-23と12-24の線の代わりにHip-Stomach-Chestのつながり情報を追加するといった、ちょっとした工夫が必要です。
与えられた頂点情報からスケルトンを作るbpyスクリプトは既に作ってあるので、poseMyArtに依存しない検知エンジン付きお絵描きアプリができそうです。
コード
① 本体
# forTrace.py 2025.09.14
# 手頃なアプリがないので自分で作ることにした。
#
# 2025.09.29
# 2点をつなげて線を引くパス機能を追加
#
# 2026.02.18
# 読み込んだ画像のポーズを検知する機能を追加
# mediaPipe検知: 顔と2D骨格の重ね描き / 3D座標をPLYに出力してBlenderへ渡す
import sys
from PyQt6.QtCore import Qt, QPoint, QSize, QEvent
from PyQt6.QtGui import (QPainter, QImage, QPen, QColor, QIcon, QBrush,
QKeySequence, QPixmap, QMouseEvent,QClipboard,
QWheelEvent, QPainterPath, QAction, QShortcut,
QCursor, QGuiApplication)
from PyQt6.QtWidgets import (QMainWindow, QApplication, QMenu, QMenuBar,
QFileDialog, QWidget, QVBoxLayout,
QHBoxLayout, QPushButton, QLabel,
QMessageBox, QGraphicsView, QGraphicsScene)
import os, subprocess, shlex
from face_pose_detect import detect_pose_on_canvas, export_to_file
# 色リスト
COLORS = [
# 17 undertones https://lospec.com/palette-list/17undertones
'#000000', '#ffffff', '#35e3e3', '#141923', '#414168', '#3a7fa7', '#8fd970',
'#5ebb49', '#458352', '#dcd37b', '#fffee5', '#ffd035', '#cc9245', '#a15c3e',
'#a42f3b', '#f45b7a', '#c24998', '#81588d', '#bcb0c2'
]
# View: QGraphicsViewを継承するクラス。ズームとパンのロジックを実装できる。
class CustomGraphicsView(QGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
#self.setRenderHint(QPainter.Antialiasing) # アンチエイリアシングを有効にして描画を滑らかにする
self.setDragMode(QGraphicsView.DragMode.NoDrag) #Qt6
self.panning = False
self.pan_start_point = QPoint()
# マウスホィールイベントwheelEvent()をオーバーライドしてズーム機能を実装する
# このメソッドはマウスホイールが回転したときに呼び出される。
def wheelEvent(self, event):
# ズーム係数を設定
zoom_in_factor = 1.25
zoom_out_factor = 1 / zoom_in_factor
# アンカーを設定(これだけでマウス位置を中心にズームしてくれる)
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
# ズーム処理
if event.angleDelta().y() > 0:
self.scale(zoom_in_factor, zoom_in_factor)
else:
self.scale(zoom_out_factor, zoom_out_factor)
# mousePressEvent()、mouseMoveEvent()、mouseReleaseEvent() をオーバーライド
def mousePressEvent(self, event):
# 中ボタンでのパン機能
if event.button() == Qt.MouseButton.MiddleButton:
self.panning = True
self.pan_start_point = event.pos()
# カーソルを掴んだ手に変更
QApplication.setOverrideCursor(Qt.CursorShape.ClosedHandCursor)
# 右ボタンでリセット
elif event.button() == Qt.MouseButton.RightButton:
self.resetTransform()
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.panning:
# 視点の移動量を計算
delta = self.mapToScene(event.pos()) - self.mapToScene(self.pan_start_point)
# ビューを移動
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.NoAnchor)
self.translate(delta.x(), delta.y())
self.pan_start_point = event.pos()
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.MiddleButton:
self.panning = False
QApplication.restoreOverrideCursor()
super().mouseReleaseEvent(event)
# Canvas: QPainterで描画機能を持つカスタムQLabelクラス
class Canvas(QLabel):
# 初期化
def __init__(self):
super().__init__()
width = 1196
height = 830
self.setGeometry(0, 0, width, height)
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.ArrowCursor))
# 描画用のQPixmapを初期化
self.image = QPixmap(width, height)
self.image.fill(QColor("white"))
self.setPixmap(self.image)
# モード設定
# "paint", "fill", "draw"
self.mode = "paint" # デフォルトはペイントモード
# 初期値
self.last_x, self.last_y = None, None
self.pen_color = QColor('#000000')
self.pen_size = 10
self.drawing = False
# Undo履歴
self.undo_stack = []
self.max_undo = 256
# クリップボード
self.clip = QApplication.clipboard()
# ショートカット登録
QShortcut(QKeySequence("Ctrl+Z"), self, activated=self.undo)
QShortcut(QKeySequence("Ctrl+V"), self, activated=self.pasted)
QShortcut(QKeySequence("Ctrl+C"), self, activated=self.copied)
# ブラシ、フィルの色を変える
def set_pen_color(self, c):
self.pen_color = QColor(c)
# ブラシの太さ
def set_pen_size(self, pix):
self.mode = "paint"
self.pen_size = pix
QApplication.setOverrideCursor(Qt.CursorShape.ArrowCursor)
# フィルモードのフラグ
def setFillMode(self):
self.mode = "fill"
QApplication.setOverrideCursor(Qt.CursorShape.CrossCursor)
# ドローモードのフラグ
def setDrawMode(self):
self.mode = "draw"
QApplication.setOverrideCursor(Qt.CursorShape.DragCopyCursor)
# 描画するすべてのQPainterPathを保持するリスト
self.paths = []
# 最後にクリックされた点 (新しい線の始点となる)
self.last_point = None
# ブラシの初期値に戻す(色と幅はそのままの方がいいかな)
def brushAgain(self):
self.mode = "paint"
QApplication.setOverrideCursor(Qt.CursorShape.ArrowCursor)
#self.set_pen_size(5)
#self.set_pen_color(QColor('#000000'))
# マウスボタンを押した時...モードによってツールを使い分ける
def mousePressEvent(self, e):
# モードが"fill"なら
if e.buttons() == Qt.MouseButton.LeftButton and self.mode == "fill":
self.flood_fill(e.pos(), self.pen_color)
# モードが"draw"なら
if e.buttons() == Qt.MouseButton.LeftButton and self.mode == "draw":
self.draw_path(e.pos())
# Ctrlが押されているか確認 (クリックした点の色を拾う)
if e.modifiers() == Qt.KeyboardModifier.ControlModifier:
x = e.position().x()
y = e.position().y()
# QPixmapをQImageに変換
img = self.image.toImage()
# 座標が有効な範囲内かチェック
if 0 <= x < img.width() and 0 <= y < img.height():
# pixelColor()メソッドで色を取得
color = img.pixelColor(int(x), int(y))
# ペンの色を変更
self.set_pen_color(color)
# 画面を再描画して、新しいペンの色を反映させる
self.update()
# 線を引く処理(ペイント、パス)
def mouseMoveEvent(self, e):
# 中ボタン移動時は無視
if e.buttons() & Qt.MouseButton.MiddleButton:
return
# QPainterをwith構文で安全に開く
# self.image (QPixmap) に対して描画を行う
painter = QPainter(self.image)
try:
p = painter.pen()
p.setWidth(self.pen_size)
p.setColor(self.pen_color)
p.setCapStyle(Qt.PenCapStyle.RoundCap)
p.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(p)
# ブラシで描線
if self.mode == "paint" and not e.modifiers() == Qt.KeyboardModifier.ControlModifier:
if self.last_x is None:
self.saveUndo()
self.last_x = int(e.position().x())
self.last_y = int(e.position().y())
return
try:
painter.drawLine(self.last_x, self.last_y, int(e.position().x()), int(e.position().y()))
except TypeError:
print(self.last_x, self.last_y, e.position().x(), e.position().y())
self.last_x = int(e.position().x())
self.last_y = int(e.position().y())
# パスで描線
elif self.mode == "draw":
for path in self.paths:
painter.drawPath(path)
finally:
painter.end() # 確実に終了させる
self.setPixmap(self.image) # 描画結果を反映
self.update()
# マウスボタンをリリースしたらペイントの描線を終了
def mouseReleaseEvent(self, e):
if self.mode == "draw":
pass
else:
self.last_x = None
self.last_y = None
# エンターキーを押したらパス描線を終了
def keyPressEvent(self, e):
if e.key() == Qt.Key.Key_Escape or e.key() == Qt.Key.Key_Enter:
self.paths = []
self.last_point = None
self.last_x = None # 新しいペイント位置
self.last_y = None
if e.key() == Qt.Key.Key_Escape:
self.brushAgain()
# Undo保存
def saveUndo(self):
if len(self.undo_stack) >= self.max_undo:
self.undo_stack.pop(0) # 最大数を超えたら古いのから削除
self.undo_stack.append(self.pixmap().copy()) # QPixmap.copy()でOK
# Undo実行
def undo(self):
if self.undo_stack:
prev_pixmap = self.undo_stack.pop()
self.image = prev_pixmap
self.setPixmap(self.image)
self.update()
# ペーストフラグ
def pasted(self):
self.mode = "paint"
QApplication.setOverrideCursor(Qt.CursorShape.ArrowCursor)
self.load_image_to_canvas("pasted")
# コピーフラグ
def copied(self):
self.mode = "paint"
QApplication.setOverrideCursor(Qt.CursorShape.ArrowCursor)
self.copy_image_to_clipboard("copied")
# 画像をクリップボードへ
def copy_image_to_clipboard(self, path):
if path == "copied":
clipboard = QGuiApplication.clipboard()
image = self.image.toImage()
clipboard.setImage(image)
# 外部画像をキャンバスに設定する
def load_image_to_canvas(self, path):
self.mode = "paint"
QApplication.setOverrideCursor(Qt.CursorShape.ArrowCursor)
if path == "pasted":
mime_data = self.clip.mimeData()
# クリップボードに画像データがない時は白紙を表示
loaded_pixmap = QPixmap(self.size())
loaded_pixmap.fill(QColor("white")) # 背景を白で塗りつぶす
if mime_data.hasImage():
loaded_pixmap = QPixmap.fromImage(mime_data.imageData())
else:
loaded_pixmap = QPixmap(path)
if not loaded_pixmap.isNull():
# 新しいQPixmapを作成し、キャンバスのサイズに合わせる
new_image = QPixmap(self.size())
new_image.fill(QColor("white")) # 背景を白で塗りつぶす
# QPainterを使用して、読み込んだ画像を新しいQPixmapに描画
painter = QPainter(new_image)
# 画像をキャンバスのサイズに合わせてスケール
scaled_pixmap = loaded_pixmap.scaled(self.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.FastTransformation)
# 描画開始位置を計算して中央に配置
x_offset = (new_image.width() - scaled_pixmap.width()) // 2
y_offset = (new_image.height() - scaled_pixmap.height()) // 2
painter.drawPixmap(x_offset, y_offset, scaled_pixmap)
painter.end()
self.image = new_image
self.setPixmap(self.image)
self.update()
return True
return False
# キャンバスをクリア
def clear_canvas(self):
# QPixmapオブジェクトを新しく作成して、白で塗りつぶす
self.image = QPixmap(self.size())
self.image.fill(QColor("white"))
self.setPixmap(self.image)
self.update()
self.brushAgain()
# クリック位置から同じ色の領域を塗りつぶす
def flood_fill(self, start_pos, new_color):
if self.mode == 'draw':
self.paths = []
self.last_point = None
#1 開始ピクセル、new_color、および空のリスト「have_seen」と「queue」を用意する
image = self.pixmap().toImage()
w, h = image.width(), image.height()
x, y = start_pos.x(), start_pos.y()
w, h = image.width(), image.height()
have_seen = set()
queue = [(x, y)] # 初期位置
if not (0 <= x < w and 0 <= y < h):
# クリック位置がキャンバス内になければ何もしない
return
#2 現在のピクセルの色を確認。これが塗りつぶしの対象となる。
target_color = image.pixelColor(x, y)
if target_color == new_color:
# 塗り色(new_color)と同色なら何もしない
return
while queue:
#3 キューから直近のアイテムを取り出す(初期状態では開始位置 x, y)。その位置を囲む4つのピクセル(四方位)を取得
cx, cy = queue.pop() # popでキュー・リストから抜き出される
neighbors = [(cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)] # 右・左・上・下
#4 四方位のピクセルについて確認
# 未確認であって、nxが0以上w未満、nyが0以上h未満の時に次の処理をする
# そのピクセルの色とtarget_colorを比較
for nx, ny in neighbors:
if (nx, ny) not in have_seen and 0 <= nx < w and 0 <= ny < h:
current_color = image.pixelColor(nx, ny)
if current_color == target_color:
#5 target_colorと同色であれば(x,y)の位置をキューに追加し、そのピクセルをnew_colorで更新
queue.append((nx, ny))
image.setPixelColor(nx, ny, new_color)
#6 (x,y)の位置を「have_seen」に追加し、確認済みの場所を記録 再度確認するオーバーヘッドを回避
have_seen.add((nx, ny))
#7 ステップ3から繰り返し、キューが空になるまで続ける
self.image = QPixmap.fromImage(image)
self.setPixmap(self.image)
self.update()
self.saveUndo() # Undoスタックにコピーを追加
# クリックごとに線を引く
def draw_path(self, start_pos):
#1 開始ピクセル、空のリスト「have_seen」と「queue」を用意する
image = self.pixmap().toImage()
w, h = image.width(), image.height()
x, y = start_pos.x(), start_pos.y()
w, h = image.width(), image.height()
have_seen = set()
queue = [(x, y)] # 初期位置
if not (0 <= x < w and 0 <= y < h):
# クリック位置がキャンバス内になければ何もしない
return
current_point = start_pos # 現在のクリック位置
# まだ点が打たれていない (=最初のクリック)
if self.last_point is None:
# 現在の点を始点として記憶するだけで、線は引かない
#print(f"最初の点: {current_point.x()}, {current_point.y()} を始点に設定")
pass
# 既に前の点がある場合 (2回目以降のクリック)
else:
start_point = self.last_point # 1つ前の点が新しい線の始点
end_point = current_point # 現在のクリック位置が終点
# QPainterPathを作成
path = QPainterPath(start_point.toPointF())
# moveTo() で始点に移動した後、lineTo() で終点まで線を追加
path.lineTo(end_point.toPointF())
# 描画対象のリストに追加
self.paths.append(path)
#print(f"線を追加: ({start_point.x()}, {start_point.y()}) -> ({end_point.x()}, {end_point.y()})")
# 再描画を要求 (paintEventが呼ばれる)
self.update()
self.saveUndo() # Undoスタックにコピーを追加
# 現在の点を「最後の点」として保存し、次回のクリックの始点にする
self.last_point = current_point
# Paletteクラス
class QPaletteButton(QPushButton):
def __init__(self, color):
super().__init__()
self.setFixedSize(QSize(32,32))
self.color = color
self.setStyleSheet("background-color: %s;" % color)
# メインウィンドウ
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
# ウィンドウの初期サイズを指定
self.resize(1234, 920)
# タイトルとアイコン
title = "forTrace"
icon = "icons/face-button.png"
self.setWindowTitle(title)
self.setWindowIcon(QIcon(icon))
# QGraphicsViewとQGraphicsSceneのセットアップ
self.view = CustomGraphicsView()
self.scene = QGraphicsScene()
# シーンのサイズをセット (Canvasのサイズに合わせて設定)
canvas_width = 1196
canvas_height = 830
self.scene.setSceneRect(0, 0, canvas_width, canvas_height)
# ビューにシーンを設定
self.view.setScene(self.scene)
# キャンバスを作成
self.canvas = Canvas()
self.canvas.setFixedSize(canvas_width, canvas_height)
# QGraphicsSceneにCanvasウィジェットを追加
self.proxy_widget = self.scene.addWidget(self.canvas)
# ウィジェット配置
w = QWidget()
l = QVBoxLayout()
# パレット
palette = QHBoxLayout()
self.add_palette_buttons(palette)
l.addLayout(palette)
# QGraphicsViewをレイアウトに追加
l.addWidget(self.view)
w.setLayout(l)
self.setCentralWidget(w)
# ズームとパンの機能を有効にするために、フォーカスを設定
self.view.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
# メニュー
mainMenu = self.menuBar()
fileMenu = mainMenu.addMenu("画面")
brushes = mainMenu.addMenu("ツール")
poseMenu = mainMenu.addMenu("検知")
# アクション登録
loadAction = QAction(QIcon("icons/load-button.png"), "読込",self)
loadAction.setShortcut("L")
fileMenu.addAction(loadAction)
loadAction.triggered.connect(self.load)
saveAction = QAction(QIcon("icons/save-button.png"), "保存",self)
saveAction.setShortcut("S")
fileMenu.addAction(saveAction)
saveAction.triggered.connect(self.save)
clearAction = QAction(QIcon("icons/delete-button.png"), "クリア", self)
clearAction.setShortcut("C")
fileMenu.addAction(clearAction)
clearAction.triggered.connect(self.clear)
pix1Action = QAction( QIcon("icons/pixel-1.svg"), "1px", self)
pix1Action.setShortcut("1")
brushes.addAction(pix1Action)
pix3Action = QAction( QIcon("icons/pixel-3.svg"), "3px", self)
pix3Action.setShortcut("3")
brushes.addAction(pix3Action)
pix5Action = QAction( QIcon("icons/pixel-5.svg"), "5px", self)
pix5Action.setShortcut("Q")
brushes.addAction(pix5Action)
pix10Action = QAction( QIcon("icons/pixel-10.svg"), "10px", self)
pix10Action.setShortcut("W")
brushes.addAction(pix10Action)
pix20Action = QAction( QIcon("icons/pixel-20.svg"), "20px", self)
pix20Action.setShortcut("E")
brushes.addAction(pix20Action)
fillAction = QAction( QIcon("icons/fill.svg"), "フィル", self)
fillAction.setShortcut("F")
brushes.addAction(fillAction)
drawAction = QAction( QIcon("icons/path.svg"), "パス", self)
drawAction.setShortcut("D")
brushes.addAction(drawAction)
pix1Action.triggered.connect(lambda: self.canvas.set_pen_size(1)) # 極細
pix3Action.triggered.connect(lambda: self.canvas.set_pen_size(3)) # 細1
pix5Action.triggered.connect(lambda: self.canvas.set_pen_size(5)) # 細2
pix10Action.triggered.connect(lambda: self.canvas.set_pen_size(10)) # 太
pix20Action.triggered.connect(lambda: self.canvas.set_pen_size(20)) # 極太
fillAction.triggered.connect(self.canvas.setFillMode)
drawAction.triggered.connect(self.canvas.setDrawMode)
# ポーズ検知メニュー
# 機能1: 2D骨格・顔メッシュ・手をキャンバスに重ね描き
pose2dAction = QAction( QIcon("icons/poseDetect.svg"), "画像に重ね描き", self)
pose2dAction.setShortcut("A")
poseMenu.addAction(pose2dAction)
pose2dAction.triggered.connect(self.detect_pose_2d)
# 機能2: 3D座標をPLYに出力
pose3dAction = QAction(QIcon("icons/save-button.png"), "3D座標をPLY出力", self)
pose3dAction.setShortcut("X")
poseMenu.addAction(pose3dAction)
pose3dAction.triggered.connect(self.export_to_file)
# ヒントアクション
hintAction = QAction("ヒント", self)
hintAction.triggered.connect(self.showHint)
mainMenu.addAction(hintAction)
# 読込
def load(self, file_path):
# 直前のブラシ/フィルにかかわらずブラシを初期化
QApplication.setOverrideCursor(Qt.CursorShape.ArrowCursor)
self.canvas.brushAgain()
if file_path:
filePath = file_path
else:
# ファイルダイアログを開き、画像ファイルを選択
filePath, _ = QFileDialog.getOpenFileName(
self,
"画像を読み込む",
"",
"Image Files (*.png *.jpg *.jpeg *.bmp);;All Files (*.*)"
)
if not filePath:
return
if not self.canvas.load_image_to_canvas(filePath):
QMessageBox.warning(self, "エラー", "画像の読み込みに失敗しました。")
# 保存
def save(self):
# 直前のブラシ/フィルにかかわらずブラシを初期化
QApplication.setOverrideCursor(Qt.CursorShape.ArrowCursor)
self.canvas.brushAgain()
# ファイルダイアログが出るのを回避(上書き保存もどき。下の外部コマンド実行時に便利)
# filePath = 'trace.bmp'
filePath, _ = QFileDialog.getSaveFileName(self, "Save Image", "", "BMP(*.bmp);;PNG(*.png);;JPEG(*.jpg *.jpeg);;All Files(*.*) ")
if filePath == "":
return
# キャンバスからQPixmapを取得
pixmap = self.canvas.pixmap()
if pixmap.isNull():
# pixmapが存在しない場合は警告を表示して終了
QMessageBox.warning(self, "エラー", "保存する画像がありません。")
return
pixmapSmall = pixmap.scaled(598, 415, Qt.AspectRatioMode.KeepAspectRatio)
pixmapSmall.save(filePath) # 元サイズはトレースした時ギザギザが多すぎるので縮小して保存
'''
外部コマンド(potrace, sed, autotrace)でトレース実行
# potrace 実行
cmd = 'potrace --svg --output trace1.svg trace.bmp'
subprocess.call(shlex.split(cmd))
# サイズ単位をpxに変更
subprocess.call(['sed', '-i', 's/pt"/px"/g', 'trace1.svg'])
# jpg...ペン入れ後のビットマップ
cmd = 'magick trace.bmp trace.jpg'
subprocess.call(shlex.split(cmd))
# autotrace
cmd = 'autotrace -centerline -output-format svg -output-file trace2.svg trace.bmp'
subprocess.call(shlex.split(cmd))
'''
# クリア
def clear(self):
self.canvas.clear_canvas()
# パレット
def add_palette_buttons(self, layout):
for c in COLORS:
b = QPaletteButton(c)
b.pressed.connect(lambda c=c: self.canvas.set_pen_color(c))
layout.addWidget(b)
# ── ポーズ検知: 2D骨格をキャンバスに重ね描き ──────────────
def detect_pose_2d(self):
"""現在のキャンバス画像に2D骨格を重ねて表示する(P キー)"""
pixmap = self.canvas.pixmap()
if pixmap.isNull():
QMessageBox.warning(self, "エラー", "キャンバスに画像がありません。")
return
# 実行中はカーソルを待機状態に(MediaPipeは数秒かかる場合がある)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents() # カーソル変更をすぐに反映
try:
result_pixmap = detect_pose_on_canvas(pixmap)
finally:
QApplication.restoreOverrideCursor()
if result_pixmap is not None:
self.canvas.saveUndo() # 骨格描画前の状態を Undo 履歴に保存
self.canvas.image = result_pixmap
self.canvas.setPixmap(result_pixmap)
self.canvas.update()
else:
QMessageBox.information(self, "ポーズ検知", "人物が検出されませんでした。")
# ── ポーズ検知: 3D座標をFileに出力 → Blender 用 ────────────
def export_to_file(self):
"""pose_world_landmarks の3D座標をPLYファイルに出力する(X キー)"""
pixmap = self.canvas.pixmap()
if pixmap.isNull():
QMessageBox.warning(self, "エラー", "キャンバスに画像がありません。")
return
# 保存先を選択
file_path, _ = QFileDialog.getSaveFileName(
self, "PLY保存先を選択", "mmTool.ply",
"PLY Files (*.ply);;All Files (*.*)"
)
if not file_path:
return
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
success = export_to_file(pixmap, file_path)
finally:
QApplication.restoreOverrideCursor()
if success:
QMessageBox.information(
self, "PLY出力完了",
f"保存しました:\n{file_path}\n\nBlenderで auto_skeleton.py を実行してください。"
)
else:
QMessageBox.warning(self, "検知", "人物が検出されませんでした。")
def showHint(self):
QMessageBox.information(
self,
"ヒント", # タイトル
"Ctrlキーとの組合せで以下の操作ができます。\n"
"・Ctrl + C クリップボードへ画像をコピー\n"
"・Ctrl + V クリップボードから画像貼付け\n"
"・Ctrl + Z 描画を取り消して直前に戻る\n\n"
"※ 検知結果を重き描きした画像の認識はうまく行きません。重ねる前の画像で検知結果をエクスポートして下さい。"
)
#.......................................................................#
# アプリケーションのエントリーポイント
if __name__ == "__main__":
# コマンドライン引数にファイルパスが指定されているか確認
app = QApplication(sys.argv)
window = MainWindow()
if len(sys.argv) > 1:
file_path = sys.argv[1]
window.load(file_path)
window.show()
#sys.exit(app.exec_())
sys.exit(app.exec())
② mediapipeサブモジュール
# poseDetect.py
# forTrace.py から呼び出されるポーズ検知モジュール
# 機能1: キャンバスに2D骨格を重ね描き
# 機能2: 3D座標をPLYファイルに出力
'''
「mediaPipeのモデル:解析の正確さが最優先の場合(スポーツフォームのチェックなど)Heavyを推奨」
Posetronの画像で試したところ、Heavyでは顔の位置が不正確。Fullでよい ← 首と頭のボーンを計算で求めるため。
PyQt6の QPixmap をMediaPipeで扱うには、少し工夫が必要。
MediaPipe(Tasks API)は内部で Numpy配列(正確にはRGB形式)を期待しているが、QPixmap はQt専用の形式だからです。
実装のポイント
QPixmap → QImage: 変換の仲介役として QImage に変換。
QImage → Numpy: メモリをコピーしてNumpy配列(RGB)。
Numpy → mp.Image: MediaPipe専用のオブジェクトに変換。
'''
import numpy as np
import mediapipe as mp
from mediapipe.tasks import python
from mediapipe.tasks.python import vision
from mediapipe.tasks.python.vision import PoseLandmarksConnections
from mediapipe.tasks.python.vision import FaceLandmarksConnections
from PyQt6.QtGui import QPixmap, QImage, QPainter, QPen, QColor
# 描画用のユーティリティを準備...QPainterを使えば不要?
#mp_drawing = mp.tasks.vision.drawing_utils
# 網目状につなぐための定義
#drawing_spec = mp_drawing.DrawingSpec(thickness=1, circle_radius=1, color=(0, 255, 0))
# 接続リストの使い分け
# 顔全体の網目
tesselation = FaceLandmarksConnections.FACE_LANDMARKS_TESSELATION
# 目や口の輪郭
contours = FaceLandmarksConnections.FACE_LANDMARKS_CONTOURS
connections = FaceLandmarksConnections
# 検知(顔とポーズ)───────────────────────────────────────────────────────
def _run_mediapipe(pixmap: QPixmap):
# --- STEP 1: QPixmap を MediaPipe 用の画像形式に変換 ---
q_image = pixmap.toImage().convertToFormat(QImage.Format.Format_RGB888)
width, height = q_image.width(), q_image.height()
ptr = q_image.bits()
ptr.setsize(height * width * 3)
arr = np.frombuffer(ptr, np.uint8).reshape((height, width, 3))
# MediaPipe専用のImageオブジェクト
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
# --- STEP 2: 推論エンジン(Landmarker)の設定 ---
# ポーズ
base_options = python.BaseOptions(model_asset_path='mpModels/pose_landmarker_full.task')
options = vision.PoseLandmarkerOptions(
base_options=base_options,
running_mode=vision.RunningMode.IMAGE ) # 静止画モード
# 顔
base_optionsFace = python.BaseOptions(model_asset_path='mpModels/face_landmarker.task')
optionsFace = vision.FaceLandmarkerOptions(
base_options=base_optionsFace,
num_faces=1,
running_mode=vision.RunningMode.IMAGE )
# --- STEP 3: 実行して detection_result, detection_resultFace を取得 ---
with vision.PoseLandmarker.create_from_options(options) as landmarker:
detection_result = landmarker.detect(mp_image)
with vision.FaceLandmarker.create_from_options(optionsFace) as landmarkerFace:
detection_resultFace = landmarkerFace.detect(mp_image)
return detection_result, detection_resultFace, width, height
# ─────────────────────────────────────────
# 機能1: コネクションをキャンバスに重ね描き
# ─────────────────────────────────────────
def detect_pose_on_canvas(pixmap: QPixmap) -> QPixmap | None:
'''
PoseLandmarkerResultは、MediaPipeの姿勢ランドマーク検出タスクから返される結果オブジェクトで、以下の主な要素を含みます。
landmarks: 画像座標系におけるランドマークの正規化座標(x, y, z, visibility, presence)
x, y: 画像の幅と高さで正規化された座標(0.0~1.0)
z: ランドマークの奥行き(相対的な距離)
visibility: ランドマークが検出された確信度(0.0~1.0)
presence: ランドマークが存在する確信度(0.0~1.0)
world_landmarks: 3D空間におけるワールド座標(x, y, z, visibility, presence)
3D空間での絶対位置を表し、カメラからの距離情報も含む。
segmentation_masks(オプション): 人物のセグメンテーションマスク(画像)を含み、人物領域を抽出する際に利用
具体的な座標取得には、results.landmarks[mp_pose.PoseLandmark.NOSE].x のようにインデックスでアクセスします。
'''
# 顔とポーズのランドマークを取得
results = _run_mediapipe(pixmap)[0]
resultsFace = _run_mediapipe(pixmap)[1]
w = _run_mediapipe(pixmap)[2]
h = _run_mediapipe(pixmap)[3]
if not results.pose_landmarks:
return None
#print(results.pose_landmarks) # NormalizedLandmark 2D画像座標系
#print(results.pose_world_landmarks) # LandMark 3D用
landmarks = results.pose_landmarks[0]
connections = PoseLandmarksConnections.POSE_LANDMARKS
result_pixmap = pixmap.copy()
painter = QPainter(result_pixmap)
# ペンの設定
line_pen = QPen(QColor(0, 0, 0), 5) # black
point_pen = QPen(QColor(240, 128, 128), 3) # lightcoral
face_outline_pen1 = QPen(QColor(128, 128, 128), 1) # gray, width=1
face_outline_pen2 = QPen(QColor(0, 0, 0), 3) # black, width=3
# --- 1. 骨(線)を描く ---
painter.setPen(line_pen)
for c in connections:
start_idx = c.start
end_idx = c.end
lm_start = landmarks[start_idx]
lm_end = landmarks[end_idx]
# 座標をピクセルに変換
x1, y1 = int(lm_start.x * w), int(lm_start.y * h)
x2, y2 = int(lm_end.x * w), int(lm_end.y * h)
# 信頼度(visibility)チェック
if lm_start.presence > 0.5 and lm_end.presence > 0.5:
painter.drawLine(x1, y1, x2, y2)
# --- 2. 関節(点)を描く ---
painter.setPen(point_pen)
for lm in landmarks:
if lm.presence > 0.5:
cx, cy = int(lm.x * w), int(lm.y * h)
painter.drawEllipse(cx - 3, cy - 3, 6, 6)
# --- 3. 顔メッシュを描く ---
if resultsFace.face_landmarks:
faceLandmarks = resultsFace.face_landmarks[0]
faceConnTese = FaceLandmarksConnections.FACE_LANDMARKS_TESSELATION
faceConnContr = FaceLandmarksConnections.FACE_LANDMARKS_CONTOURS
# 1. 顔全体の網目 (TESSELATION)
painter.setPen(face_outline_pen1)
for fc in faceConnTese:
start_idx = fc.start
end_idx = fc.end
try:
lm_s = faceLandmarks[start_idx]
lm_e = faceLandmarks[end_idx]
# 座標をピクセルに変換
x1, y1 = int(lm_s.x * w), int(lm_s.y * h)
x2, y2 = int(lm_e.x * w), int(lm_e.y * h)
except IndexError:
pass
painter.drawLine(x1, y1, x2, y2)
# 2. 目や口などの主要な輪郭 (CONTOURS)
painter.setPen(face_outline_pen2)
for fc in faceConnContr:
start_idx = fc.start
end_idx = fc.end
try:
lm_s = faceLandmarks[start_idx]
lm_e = faceLandmarks[end_idx]
# 座標をピクセルに変換
x1, y1 = int(lm_s.x * w), int(lm_s.y * h)
x2, y2 = int(lm_e.x * w), int(lm_e.y * h)
except IndexError:
pass
painter.drawLine(x1, y1, x2, y2)
painter.end()
return result_pixmap
# ─────────────────────────────────────────
# 機能2: 3D座標からPLYファイルの作成保存
# ─────────────────────────────────────────
def export_to_file(pixmap, file_path):
# ポーズのランドマークを取得
results = _run_mediapipe(pixmap)[0]
#resultsFace = _run_mediapipe(pixmap)[1]
#w = _run_mediapipe(pixmap)[2]
#h = _run_mediapipe(pixmap)[3]
'''
PLYヘッダの書き出し例 element vertex 複数持てる
comment pose
element vertex 頂点の数
property float x
property float y
property float z
comment face
element vertex 頂点の数
property float x
property float y
property float z
'''
poseDataYes = True
try:
# 3Dワールド座標。ルートが常にワールド原点に来る。w, hによる正規化不要
poseData = results.pose_world_landmarks[0]
connections = PoseLandmarksConnections.POSE_LANDMARKS
except IndexError:
poseDataYes = False
'''
faceDataYes = True
try:
faceData = resultsFace.face_landmarks[0]
except IndexError:
faceDataYes = False
'''
# PLYヘッダ部分
writeMe = ["ply\n", "format ascii 1.0\n", "comment www.moonlight-lullaby.info\n"]
# ポーズ
if poseDataYes:
comment = "comment pose\n"
writeMe.append(comment)
dataLength = str(len(poseData))
elmVertex = "element vertex " + dataLength + "\n" # 定数33
writeMe.append(elmVertex)
properties = "property float x\n" + \
"property float y\n" + \
"property float z\n"
writeMe.append(properties)
elmEdge = "element edge " + str(len(connections)) + "\n" # 定数35
writeMe.append(elmEdge)
properties = "property int vertex1\n" + "property int vertex2\n"
writeMe.append(properties)
'''
# 顔
if faceDataYes:
comment = "comment face\n"
writeMe.append(comment)
dataLength = str(len(faceData))
elmVertex = "element vertex " + dataLength + "\n"
writeMe.append(elmVertex)
properties = "property float x\n" + \
"property float y\n" + \
"property float z\n"
writeMe.append(properties)
'''
writeMe.append("end_header\n")
# x y z 座標
if poseDataYes:
for i in poseData:
bx = i.x
by = i.z
bz = i.y * -1
writeMeLine = '{:.6f}'.format(bx) + ' ' + \
'{:.6f}'.format(by) + ' ' + \
'{:.6f}'.format(bz) + '\n'
writeMe.append(writeMeLine)
'''
if faceDataYes:
for i in faceData:
bx = i.x * w * 0.001
by = i.z * w * 0.001
bz = i.y * h * 0.001 * -1
writeMeLine = '{:.6f}'.format(bx) + ' ' + \
'{:.6f}'.format(by) + ' ' + \
'{:.6f}'.format(bz) + '\n'
writeMe.append(writeMeLine)
'''
# インデックスのペアで線を描く
for j in connections:
#print(j.start, j.end)
writeMe.append(str(j.start) + " " + str(j.end) + "\n")
# PLYテキストファイル書き出し
with open(file_path, "w", encoding="utf-8") as file:
file.writelines(writeMe)
return True