Python으로 화면 인식 AI 어시스턴트를 만든 방법 – 전체 스택 분석 (PyQt6 + Whisper + Ollama)
Source: Dev.to
개요
3개월 전, 나는 Clicky를 만들기 시작했습니다 — 화면을 읽고 음성으로 답변하는 Windows AI 어시스턴트입니다. 아래는 각 구성 요소에 대한 전체 기술 분석입니다.
TL;DR: PyQt6 시스템 트레이 → Ctrl + Alt + Space 단축키 → 스크린샷 + Whisper STT → Ollama / OpenAI / Claude → edge‑tts가 답변을 음성으로 전달합니다. 오픈 소스, 무료, API 키 불필요.
아키텍처 개요
User presses Ctrl+Alt+Space
↓
GlobalHotkey listener (pynput)
↓
Screenshot all monitors (mss)
↓
Whisper.cpp transcribes audio
↓
CompanionManager routes to AI provider
↓
Ollama (local) / OpenAI / Claude / Copilot
↓
edge-tts speaks answer + arrow overlay on screen
1. System Tray + Hotkey (PyQt6 + pynput)
앱은 시스템 트레이에 존재합니다 — 창이 없고, 마찰이 없습니다.
from pynput import keyboard
from PyQt5.QtCore import QMetaObject, Qt
def on_activate():
QMetaObject.invokeMethod(companion, "start_listening", Qt.QueuedConnection)
hotkey = keyboard.GlobalHotKeys({'++': on_activate})
hotkey.start()
핵심 트릭: QMetaObject.invokeMethod와 Qt.QueuedConnection을 사용하면 pynput의 백그라운드 스레드에서 Qt의 메인 스레드로 안전하게 경계를 넘을 수 있습니다.
2. 화면 캡처 (mss)
import io
import base64
import mss
from PIL import Image
def capture_all_screens():
with mss.mss() as sct:
for monitor in sct.monitors[1:]: # skip monitor[0] (all combined)
shot = sct.grab(monitor)
img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
# Encode as JPEG base64 for vision API
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=75)
yield base64.b64encode(buffer.getvalue()).decode()
Quality 75 JPEG은 페이로드를 API 제한 이하로 유지하면서 가독성을 보존합니다.
3. Speech‑to‑Text (Whisper.cpp)
I use the whispercpp Python bindings — runs on CPU, no GPU needed.
from whispercpp import Whisper
w = Whisper.from_pretrained("base.en")
def transcribe(audio_path: str) -> str:
result = w.transcribe(audio_path)
return w.extract_text(result)[0].strip()
저는 whispercpp 파이썬 바인딩을 사용합니다 — CPU에서 실행되며 GPU가 필요 없습니다.
base.en 모델은 약 142 MB이며 중급 CPU에서 약 10 초 분량의 오디오를 약 2 초에 전사합니다. 즉시 처리되는 느낌일 정도로 빠릅니다.
4. AI 제공자 라우팅
하나의 인터페이스로 네 개의 제공자를 지원합니다:
class CompanionManager:
def get_provider(self):
match self.config["provider"]:
case "ollama": return OllamaProvider()
case "openai": return OpenAIProvider()
case "claude": return ClaudeProvider()
case "copilot": return GitHubCopilotProvider()
async def ask(self, question: str, screenshots: list[str]) -> str:
provider = self.get_provider()
# Identity questions skip screenshots (avoids vision API refusals)
if is_identity_question(question):
screenshots = []
return await provider.complete(question, screenshots, self.system_prompt)
is_identity_question() 필터는 재미있는 도전 과제였습니다 — 비전 API는 이미지에서 사람을 식별하는 것을 거부합니다. 저는 정규식을 사용해 “who is X” 패턴을 감지하고, 전송하기 전에 스크린샷을 제거합니다.
5. Ollama와 로컬 AI
import httpx
async def complete(self, question, images, system_prompt):
payload = {
"model": "qwen2.5vl:3b", # vision model
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": question, "images": images}
],
"stream": False
}
async with httpx.AsyncClient(timeout=60) as client:
r = await client.post("http://localhost:11434/api/chat", json=payload)
return r.json()["message"]["content"]
6. 텍스트‑음성 변환 (edge‑tts)
Microsoft의 신경망 TTS — 무료, API 키 필요 없으며, 음질이 뛰어납니다:
import edge_tts
import asyncio
import pygame
async def speak(text: str):
communicate = edge_tts.Communicate(text, voice="en-US-AriaNeural")
await communicate.save("/tmp/response.mp3")
# Play with pygame
pygame.mixer.music.load("/tmp/response.mp3")
pygame.mixer.music.play()
7. 패키징 (PyInstaller + Inno Setup)
# build.bat
pyinstaller clicky.spec --noconfirm
# Then Inno Setup builds Setup-Clicky.exe
iscc installer.iss
.spec 파일에는 동적으로 로드되는 모든 모듈에 대한 명시적인 hidden imports가 필요합니다:
hiddenimports = [
"ai.ollama_bootstrap",
"ui.setup_wizard",
"whispercpp",
# …
]
Lessons Learned
- Thread safety with PyQt6 is non‑negotiable – never call Qt UI methods from background threads. Use
QMetaObject.invokeMethodor signals. - Whisper
base.enis the sweet spot – tiny is too inaccurate, small is too slow on CPU. - Vision APIs hate face identification – filter such requests early, not after your first support ticket.
- PyInstaller + Ollama = packaging nightmare – Ollama runs as a separate process, not bundled. The setup wizard that auto‑installs it saved countless support issues.
교훈
- PyQt6와의 스레드 안전은 절대 양보할 수 없습니다 – 백그라운드 스레드에서 Qt UI 메서드를 호출하지 마세요.
QMetaObject.invokeMethod또는 시그널을 사용하세요. - Whisper
base.en이 최적의 선택 – tiny는 정확도가 낮고, small은 CPU에서 너무 느립니다. - Vision API는 얼굴 인식을 싫어합니다 – 첫 번째 지원 티켓이 오기 전에 이런 요청을 미리 필터링하세요.
- PyInstaller + Ollama = 패키징 악몽 – Ollama는 별도의 프로세스로 실행되며 번들되지 않습니다. 자동 설치해 주는 설정 마법사가 수많은 지원 문제를 해결했습니다.
다음 단계
- macOS 포트 (핫키 시스템이 주요 장애물)
- 플러그인 시스템
맞춤형 AI 스킬
- 200 ms 미만 응답 시간 최적화
전체 소스는 GitHub에 있으며 MIT 라이선스입니다. 이를 기반으로 무언가를 구축하면 알려 주세요.
📥 다운로드:
⭐ GitHub: