Python으로 화면 인식 AI 어시스턴트를 만든 방법 – 전체 스택 분석 (PyQt6 + Whisper + Ollama)

발행: (2026년 5월 6일 PM 08:35 GMT+9)
6 분 소요
원문: Dev.to

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.invokeMethodQt.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.invokeMethod or signals.
  • Whisper base.en is 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:

0 조회
Back to Blog

관련 글

더 보기 »

시스템 설계 트레이드오프

스케일링 - 수직 스케일링 vs 수평 스케일링 - 확장성 vs 성능 일관성 및 가용성 - 일관성 vs 가용성 CAP - 강한 일관성 vs 최종 일관성