TrueNAS에서 paperless-ngx 한글(HWP/HWPX) 지원 구성기

1. 인트로

2025년을 마무리하면서 사진과 문서를 정리하고 있습니다.
사진은 photoprism으로 이전 중이고, 문서는 원노트와 paperless-ngx 조합으로 정리하고 있습니다.
대부분의 문서류는 pdf와 doc 형식이지만, 아이들 관련 문서처럼 간혹 hwp 파일이 섞여 있어서 hwp까지 함께 관리할 방법을 찾던 중에 subinsong 님의 블로그 글을 발견했습니다.
저는 TrueNAS에서 서비스를 운영하고 있고, 이미 더 최신 버전의 paperless-ngx를 사용 중이라 원문 그대로 따라 하기보다는 TrueNAS 환경에 맞게 구성을 새로 만드는 쪽을 선택했습니다.

2. vibe coding?

AI 없이 혼자서도 어떻게든 구성할 수는 있겠지만, 효율성 측면에서는 AI와 협업하는 편이 훨씬 낫다고 느끼고 있습니다. photoprism 이전 과정도 다음 글에서 정리하겠지만, 거기에서도 AI가 중요한 역할을 해 주었고, 이번 paperless-ngx의 TrueNAS 지원 작업 역시 Gemini와 함께 진행했습니다.
ChatGPT는 TrueNAS 환경에서 Dockerfile을 직접 생성할 수 없다는 제약을 제대로 반영하지 못하고, 계속 subinsong 블로그 예제를 고집하는 바람에 몇 번 시도하다가 결국 Gemini로 옮겼습니다. Gemini는 요구사항과 제약을 반영해서 코드를 재구성하는 능력이 좋아서, 특히 코딩 영역에서는 확실한 강점이 느껴졌습니다.

3. Gemini 생성 코드

개인적으로 heredoc 방식을 좋아합니다. 스크립트 하나만 공유하면 필요한 Dockerfile과 Django 앱 코드까지 한 번에 만들어 낼 수 있어서 관리와 재사용이 편하기 때문입니다. Gemini에게 TrueNAS 환경과 paperless-ngx 버전을 설명하고, subin-song님의 blog 주소를 알려 주고 heredoc 방식으로 만들어 달라고 요청했습니다.
아래 스크립트를 실행한 뒤 Docker Hub와 연결하면, Tika·Gotenberg·paperless-ngx 이미지를 HWP/HWPX 지원 버전으로 빌드하고 업로드할 수 있습니다.

#!/bin/bash                                                                                                                                         

# 1. 설정 (Docker Hub ID 및 버전 명시)
DOCKERHUB_ID="flywithu"
TIKA_VER="3.2.3.0"
GOTENBERG_VER="8.22"
PAPERLESS_VER="2.20" # 현재 안정화된 최신 버전 기준

# 2. 작업 디렉토리 생성 및 이동
mkdir -p paperless-hwp-build/hwp_tika
cd paperless-hwp-build

# 3. tika.dockerfile 생성
cat <<EOF > tika.dockerfile
FROM apache/tika:${TIKA_VER}
USER root
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl
RUN mkdir -p /opt/tika-extra && \\
    curl -L -o /opt/tika-extra/tika-parser-hwp-${TIKA_VER}.jar \\
    https://repo1.maven.org/maven2/org/apache/tika/tika-parser-hwp/${TIKA_VER}/tika-parser-hwp-${TIKA_VER}.jar
ENV TIKA_CLASSPATH="/opt/tika-extra/*"
EOF

# 4. gotenberg.dockerfile 생성
cat <<EOF > gotenberg.dockerfile
FROM gotenberg/gotenberg:${GOTENBERG_VER}
USER root
RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends \
    openjdk-21-jre-headless \
    libreoffice-java-common \
    libreoffice-h2orestart
RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
USER gotenberg
EOF

# 5. paperless.dockerfile 생성 (Django 앱 포함)
cat <<EOF > paperless.dockerfile
FROM paperlessngx/paperless-ngx:${PAPERLESS_VER}
# 커스텀 앱 코드를 컨테이너 내부 소스 경로로 복사
USER root
RUN apt-get update && \\
    apt-get install -y --no-install-recommends \\
    tesseract-ocr-kor \\
    tzdata \\
    mariadb-client && \\
    apt-get clean && \\
    rm -rf /var/lib/apt/lists/*i
USER paperless
COPY hwp_tika/ /usr/src/paperless/src/hwp_tika/
EOF

# 6. 커스텀 Django 앱 파일 생성 (hwp_tika)
cat <<EOF > hwp_tika/apps.py
from django.apps import AppConfig
class HwpTikaConfig(AppConfig):
    name = "hwp_tika"
    def ready(self):
        from documents.signals import document_consumer_declaration
        from .signals import hwp_consumer_declaration
        document_consumer_declaration.connect(hwp_consumer_declaration)
EOF

cat <<EOF > hwp_tika/__init__.py
default_app_config = "hwp_tika.apps.HwpTikaConfig"
EOF

cat <<EOF > hwp_tika/signals.py
from django.dispatch import receiver
from documents.signals import document_consumer_declaration
from paperless_tika.parsers import TikaDocumentParser
import logging
import os
from pathlib import Path

logger = logging.getLogger("paperless.hwp_tika")

class HwpTikaParser(TikaDocumentParser):
    def convert_to_pdf(self, document_path, file_name):
        # document_path가 Path 객체일 수 있으므로 문자열로 확실히 변환합니다.
        path_str = str(document_path)

        # 1. 파일이 .hwpx인 경우 물리적 파일명 변경 로직 수행
        if path_str.lower().endswith(".hwpx"):
            # .hwpx -> .hwp
            temp_hwp_path = path_str[:-1]

            # file_name(Gotenberg API에 전달될 이름)도 .hwp로 변경
            safe_file_name = file_name
            if file_name and file_name.lower().endswith(".hwpx"):
                safe_file_name = file_name[:-1]

            logger.info(f"[Fix] Renaming: {os.path.basename(path_str)} -> {os.path.basename(temp_hwp_path)}")

            # 물리적 파일명 변경
            os.rename(path_str, temp_hwp_path)

            try:
                # 변경된 경로와 안전한 파일 이름으로 Gotenberg에 전송
                # super() 호출 시 document_path 타입을 맞춰주기 위해 Path 객체로 다시 감쌉니다.
                return super().convert_to_pdf(Path(temp_hwp_path), safe_file_name)
            finally:
                # 시스템 정합성을 위해 원래 이름(.hwpx)으로 복구
                if os.path.exists(temp_hwp_path):
                    os.rename(temp_hwp_path, path_str)

        # .hwp 파일이거나 다른 경우는 기본 로직 수행
        return super().convert_to_pdf(document_path, file_name)

def get_parser(*args, **kwargs):
    return HwpTikaParser(*args, **kwargs)

@receiver(document_consumer_declaration)
def hwp_consumer_declaration(sender, **kwargs):
    return {
        "parser": get_parser,
        "weight": 100,
        "mime_types": {
            "application/x-hwp": ".hwp",
            "application/hwp": ".hwp",
            "application/x-hwpx": ".hwpx",
            "application/x-hwp+zip": ".hwpx",
        },
    }
EOF

# 7. 빌드 및 푸시 실행
echo "--- Docker Hub 로그인 ---"
docker login

echo "--- Tika HWP 빌드 (${TIKA_VER}-hwp) ---"
docker build -t ${DOCKERHUB_ID}/tika-hwp:${TIKA_VER}-hwp -f tika.dockerfile .
docker push ${DOCKERHUB_ID}/tika-hwp:${TIKA_VER}-hwp

echo "--- Gotenberg HWP 빌드 (${GOTENBERG_VER}-hwp) ---"
docker build -t ${DOCKERHUB_ID}/gotenberg-hwp:${GOTENBERG_VER}-hwp -f gotenberg.dockerfile .
docker push ${DOCKERHUB_ID}/gotenberg-hwp:${GOTENBERG_VER}-hwp

echo "--- Paperless-ngx HWP 빌드 (${PAPERLESS_VER}-hwp) ---"
docker build -t ${DOCKERHUB_ID}/paperless-hwp:${PAPERLESS_VER}-hwp -f paperless.dockerfile .
docker push ${DOCKERHUB_ID}/paperless-hwp:${PAPERLESS_VER}-hwp

echo "--- 모든 작업 완료! ---"

4. truenas compose 파일

Docker Hub에 미리 빌드해 둔 이미지를 내려받아 사용하는 방식이라, 대부분의 경우 이 compose 설정만으로 HWP/HWPX를 지원하는 paperless-ngx 환경을 구성할 수 있습니다.
디버깅용으로 넣어 둔 alpine 컨테이너는 필요 없으면 제거해도 무방합니다.

아래 예시에서는 Tika·Gotenberg·paperless-ngx·paperless-ai 컨테이너를 한 네트워크에 올렸고, HWP/HWPX 처리를 위해 PAPERLESS_APPS에 hwp_tika.apps.HwpTikaConfig를 등록하고, PAPERLESS_CONSUMER_EXTENSION_USER_ALLOWLIST에 .hwp와 .hwpx를 추가했습니다.
데이터베이스 관련 환경 변수(PAPERLESS_DBHOST, PAPERLESS_DBNAME, PAPERLESS_DBPASS, PAPERLESS_DBPORT)는 각자의 TrueNAS 및 DB 환경에 맞게 채워 넣으면 됩니다.
https://hub.docker.com/repositories/flywithu 에 이미지가 있습니다.

networks:
  default:
    name: paperless_default
services:
  debug-alpine:
    command: sh -c "sleep infinity"
    container_name: debug-alpine
    image: alpine:latest
    restart: unless-stopped
    stdin_open: True
    tty: True
  gotenberg:
    command:
      - gotenberg
      - '--chromium-disable-javascript=true'
      - '--chromium-allow-list=file:///tmp/.*'
      - '--api-timeout=1200s'
      - '--libreoffice-start-timeout=60s'
    container_name: gotenberg
    image: flywithu/gotenberg-hwp:8.22-hwp
    restart: unless-stopped
  paperless-ai:
    container_name: paperless-ai
    environment:
      AI_MAX_RETRIES: 3
      AI_OCR_ENGINE: paddle
    image: clusterzx/paperless-ai:3.0.9
    ports:
      - '43001:3000'
    restart: unless-stopped
    volumes:
      - /mnt/hdd0/mount/paperless/ai_data:/app/data
  paperless-ngx:
    container_name: paperless-ngx
    depends_on:
      - paperless-ai
      - tika
      - gotenberg
    environment:
      PAPERLESS_AI_API: http://paperless-ai:3000
      PAPERLESS_APPS: hwp_tika.apps.HwpTikaConfig
      PAPERLESS_CONSUMER_EXTENSION_USER_ALLOWLIST: .hwpx,.hwp
      PAPERLESS_CONSUMER_RECURSIVE: True
      PAPERLESS_DBENGINE: mariadb
      PAPERLESS_DBHOST: 
      PAPERLESS_DBNAME: 
      PAPERLESS_DBPASS: 
      PAPERLESS_DBPORT: 
      PAPERLESS_DBSSLMODE: DISABLED
      PAPERLESS_DBUSER: truenas
      PAPERLESS_OCR_LANGUAGE: kor+eng
      PAPERLESS_OCR_LANGUAGES: kor eng
      PAPERLESS_REDIS: redis://192.168.10.100:40059/10
      PAPERLESS_TIKA_ENABLED: 1
      PAPERLESS_TIKA_ENDPOINT: http://tika:9998
      PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
      PAPERLESS_URL: http://localhost:48001
    image: flywithu/paperless-hwp:2.20-hwp
    ports:
      - '48001:8000'
    pull_policy: always
    restart: unless-stopped
    volumes:
      - /mnt/hdd0/mount/paperless/data:/usr/src/paperless/data
      - /mnt/hdd0/mount/paperless/media:/usr/src/paperless/media
      - /mnt/hdd0/mount/paperless/export:/usr/src/paperless/export
      - /mnt/hdd0/scan/paperless:/usr/src/paperless/consume
  tika:
    container_name: tika
    image: flywithu/tika-hwp:3.2.3.0-hwp
    restart: unless-stopped
volumes:
  consume: Null
  data: Null
  export: Null
  media: Null
  paperless-ai_data: Null
  redisdata: Null

5. 문제점 및 실행화면

처음에는 subinsong 님의 가이드를 거의 그대로 따라 구성했는데, 이 상태에서는 hwpx 파일이 제대로 import되지 않는 문제가 있었습니다.
Gotenberg 쪽에서 hwpx를 hwp와 동일하게 처리하지 못하는 부분이 있어, hwpx 파일을 일시적으로 .hwp 확장자로 변경한 뒤 변환을 진행하고, 변환이 끝나면 다시 원래 이름으로 되돌리는 래퍼 파서를 추가하는 방식으로 보완했습니다.
이 수정 이후에는 hwp와 hwpx 파일 모두 아래 스크린샷처럼 정상적으로 import되는 것을 확인했습니다. 다만 모든 형식의 hwp/hwpx 파일을 다 테스트해 보지는 못했기 때문에, 사용 중에 문제가 발생하는 파일이 있다면 댓글이나 메일로 공유해 주시면 확인해 보겠습니다.

HWP 파일

HWPX 파일

6. 마무리

TrueNAS에서 paperless-ngx를 사용하면서 HWP/HWPX까지 함께 관리하고 싶은 분께 도움이 되었으면 합니다. 구성 자체는 Docker 이미지 교체와 환경 변수 설정만으로 끝나지만, 중간에 hwpx 처리와 같이 오류날수 있는 지점이 있어서 기록 차원에서 정리해 두었습니다. 더 나은 설정이나 개선 아이디어가 있다면 편하게 알려 주세요.

8글자에 담긴 시간들 – flywithu.com 결산

1. 인트로: 8글자의 시작

  • 대학 시절, 단순 알바의 지루함을 달래기 위해 ‘앞으로 온라인에서 쓸 아이디나 하나 정해볼까?’라는 생각을 하게 됐다.
  • 한참 고민하다가 떠올린 문장은 ‘fly with you’였다. 함께 어디를 가고 싶다는 느낌이 좋아서였지만, 그 시절에는 아이디가 8자 제한이라 띄어쓰기를 빼고 ‘you’를 ‘u’로 줄여 지금의 ‘flywithu’를 만들었다.
  • 그때 심심함을 달래려고 정했던 이 아이디를 이렇게 오래 메인 아이디로 쓰게 될 줄은, 그 당시에는 전혀 예상하지 못했다. 지금 돌아보면, 너무 유치한 이름으로 짓지 않아서 다행이라는 생각도 든다.

2. 기록: 감성 일기장에서 기술 저장소로

  • 지금 이곳에 남아 있는 가장 첫 글을 다시 읽어보면, 지금과는 많이 다른 공기가 느껴진다. 그냥 아무것도 아닌 이야기들을 가볍게 적어 두었던, 공개 일기장 겸 메모 같은 느낌이다.

처음에는 이렇게 가벼운 한 줄이 전부였지만, 돌이켜 보면 그때의 글이 지금까지 이어지는 출발점이었다.

3. 공간: 내 집 마련의 고군분투

  • 블로그를 오래 가져가고 싶다는 생각이 들어서 집 주소도 직접 마련해 보고 싶었다.
  • 그래서 flywithu.net을 먼저 구입하고, .com이 비는 순간을 기다렸다가 등록 가능 상태가 되자마자 바로 구입했다.
  • 처음에는 웹호스팅에서 시작해서, 이후에는 코로케이션으로 이전하기도 했다. 이 경험 덕분에 웹 프로그래밍 알바를 하며 대학 시절을 보냈고, 이때는 주로 PHP 로 이곳을 채워 나갔다.
  • 최근에는 iwinv에서 Namecheap(https://namecheap.com) 으로 옮겼다.
  • iwinv에 매달 일정 금액을 내며 여러 서비스를 사용해왔지만, 사이트가 멈추는 현상이 반복되면서 더 안정적인 환경이 필요하다고 느꼈기 때문이다.
  • 하나씩 서비스를 다른 곳으로 옮기고 있다.
멈춰서 날라온 메일들…
iwinv 매달 내는 요금

4. 성장: 방문자와 광고 수익

  • 아래 방문자 그래프와, 광고 수익 그래프가 자리하고 있다. 숫자로 보면 아주 큰 사이트는 아니지만, 예전보다 페이지뷰가 조금씩 늘어나고 있다.
  • 정작 본인은 사이트에 접속해도 광고가 잘 나오지 않지만(google ads 정책), 그래도 예전에 비해 수익이 1/50 수준으로 줄어 버려서 씁쓸하기도 하다. (참고로 왼쪽 그래프들의 척도는 서로 다르다.)
  • 최근에 다시 글을 열심히 쓰는 이유도, 방문자를 천천히 늘려 보고 싶기도 하고, 가능하다면 광고 수익도 조금은(홈페이지 비용 정도는) 따라와 주면 좋겠다는 마음 때문이다.
  • 원래 이곳과 실명을 직접 연결하는 것은 피해왔는데, 이제는 LinkedIn과도 직접 연결하고 있다.
  • 어차피 나의 아이디 flywithu가 여러 곳에서 쓰이고 있어, 이 연결을 억지로 끊는 것이 의미는 없겠다는 생각 때문이다.

5. 변화: 기술 블로그로…

  • 예전에는 소소한 일상이 이곳의 대부분을 채웠다면, 이제는 기술 중심의 글을 더 많이 쓰려고 하고 있다.
  • 개인적인 이야기를 공개적으로 적는 일은 아직도 조금 부담스럽다. 대신 그날그날 떠올랐던 생각이나 해결했던 문제들을 남기는 기술 블로그로 이곳을 계속 유지해 보려고 한다.

Ask vs. Agent 모드: 토큰 폭발 이유와 외국어 공부에 써먹기

0. 서론

  • 같은 코드와 프롬프트로 Ask 모드와 Agent 모드를 비교했다
  • 토큰 사용량 차이와, VSCode에서 이를 외국어 공부에 활용하는 방법까지 정리했다

1. AI 사용할 때, Ask vs. Agent

  • Ask Mode: 단일 질문에 대한 일회선 답변에 초점
  • Agent Mode: 사용자가 목표를 말하면, AI가 플랜을 세우고 여러 단계를 자율적으로 실행하는 모드
  • 빠르게 한 번 물어볼 때는 Ask 모드(검색 느낌).
  • 파일 수정이나 여러 단계 작업은 Agent 모드(비서 느낌).
  • 이 글은 VSCode에서 GitHub Copilot Chat과 Gemini API를 사용할 때를 기준으로 작성했다.

2. 실험 설정: 동일 코드에 동일한 Prompt

“간단한 리스트 필터링 코드를 List Comprehension 으로 바꾸는 예제 코드”

# 사용자 데이터 리스트 (raw_data)
users = [
    {"id": 1, "name": "Alice", "role": "admin", "is_active": True},
    {"id": 2, "name": "Bob", "role": "user", "is_active": False},
    {"id": 3, "name": "Charlie", "role": "user", "is_active": True},
    {"id": 4, "name": "David", "role": "guest", "is_active": True},
]

# 관리자(admin)이거나 활성(active) 상태인 유저의 이름만 대문자로 추출하는 로직
target_users = []
<선택부분>
for user in users:
    if user["is_active"] and user["role"] != "guest":
        processed_name = user["name"].upper()
        target_users.append(processed_name) </선택부분>

print(target_users)

“이 코드를 바꾸게 하는 Prompt”

지금 선택한 코드를 python의 list comprehension 문법으로 바꿔줘.
flowchart LR
    A["VSCode<br/>Developer"] -->|"HTTP 요청 (OpenAI 호환)"| B["LiteLLM Proxy<br/>Request Capture · Logging"]
    B -->|"변환 및 포워딩"| C["Gemini API<br/>Google AI"]
    C -->|"응답 반환"| B
    B -->|"HTTP 응답"| A

VSCode와 Gemini API 사이에 LLM Proxy를 두고, 두 사이의 HTTP 요청·응답을 MITM처럼 캡처하도록 구성했다. 이 구조 덕분에 Ask 모드와 Agent 모드의 요청/응답을 캡쳐해서, 어떤 토큰이 어디에 얼마나 쓰이는지 비교 할 수 있다.

3. 실험결과

3.1 Ask 모드

ASK 모드에서 사용된 토큰 사용량

Total: 5,505(Message: 5371/ Response: 134)
질문은 한 줄이지만, VSCode에서 사용하는 Function 리스트와 설명이 함께 보내지면서 Message 토큰이 5,371까지 늘어났다.

클릭 – 실제 function 정의 중 일부. 이런 정의가 여러 개 붙어서 Message 길이가 크게 늘어난다.

“type”: “function”,
“function”: {
“name”: “grep_search”,
“parameters”: {
“type”: “object”,
“required”: [
“query”,
“isRegexp”
],
“properties”: {
“query”: {

전체 Request
{
  "n": 1,
  "model": "gemini/gemini-2.5-flash",
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "file_search",
        "parameters": {
          "type": "object",
          "required": [
            "query"
          ],
          "properties": {
            "query": {
              "type": "string",
              "description": "Search for files with names or paths matching this glob pattern."
            },
            "maxResults": {
              "type": "number",
              "description": "The maximum number of results to return. Do not use this unless necessary, it can slow things down. By default, only some matches are returned. If you use this and don't see what you're looking for, you can try again with a more specific query or a larger maxResults."
            }
          }
        },
        "description": "Search for files in the workspace by glob pattern. This only returns the paths of matching files. Use this tool when you know the exact filename pattern of the files you're searching for. Glob patterns match from the root of the workspace folder. Examples:\n- **/*.{js,ts} to match all js/ts files in the workspace.\n- src/** to match all files under the top-level src folder.\n- **/foo/**/*.js to match all js files under any foo folder in the workspace."
      }
    },
    {
      "type": "function",
      "function": {
        "name": "grep_search",
        "parameters": {
          "type": "object",
          "required": [
            "query",
            "isRegexp"
          ],
          "properties": {
            "query": {
              "type": "string",
              "description": "The pattern to search for in files in the workspace. Use regex with alternation (e.g., 'word1|word2|word3') or character classes to find multiple potential words in a single search. Be sure to set the isRegexp property properly to declare whether it's a regex or plain text pattern. Is case-insensitive."
            },
            "isRegexp": {
              "type": "boolean",
              "description": "Whether the pattern is a regex."
            },
            "maxResults": {
              "type": "number",
              "description": "The maximum number of results to return. Do not use this unless necessary, it can slow things down. By default, only some matches are returned. If you use this and don't see what you're looking for, you can try again with a more specific query or a larger maxResults."
            },
            "includePattern": {
              "type": "string",
              "description": "Search files matching this glob pattern. Will be applied to the relative path of files within the workspace. To search recursively inside a folder, use a proper glob pattern like \"src/folder/**\". Do not use | in includePattern."
            },
            "includeIgnoredFiles": {
              "type": "boolean",
              "description": "Whether to include files that would normally be ignored according to .gitignore, other ignore files and `files.exclude` and `search.exclude` settings. Warning: using this may cause the search to be slower. Only set it when you want to search in ignored folders like node_modules or build outputs."
            }
          }
        },
        "description": "Do a fast text search in the workspace. Use this tool when you want to search with an exact string or regex. If you are not sure what words will appear in the workspace, prefer using regex patterns with alternation (|) or character classes to search for multiple potential words at once instead of making separate searches. For example, use 'function|method|procedure' to look for all of those words at once. Use includePattern to search within files matching a specific pattern, or in a specific file, using a relative path. Use 'includeIgnoredFiles' to include files normally ignored by .gitignore, other ignore files, and `files.exclude` and `search.exclude` settings. Warning: using this may cause the search to be slower, only set it when you want to search in ignored folders like node_modules or build outputs. Use this tool when you want to see an overview of a particular file, instead of using read_file many times to look for code within a file."
      }
    },
    {
      "type": "function",
      "function": {
        "name": "get_changed_files",
        "parameters": {
          "type": "object",
          "properties": {
            "repositoryPath": {
              "type": "string",
              "description": "The absolute path to the git repository to look for changes in. If not provided, the active git repository will be used."
            },
            "sourceControlState": {
              "type": "array",
              "items": {
                "enum": [
                  "staged",
                  "unstaged",
                  "merge-conflicts"
                ],
                "type": "string"
              },
              "description": "The kinds of git state to filter by. Allowed values are: 'staged', 'unstaged', and 'merge-conflicts'. If not provided, all states will be included."
            }
          }
        },
        "description": "Get git diffs of current file changes in a git repository. Don't forget that you can use run_in_terminal to run git commands in a terminal as well."
      }
    },
    {
      "type": "function",
      "function": {
        "name": "list_code_usages",
        "parameters": {
          "type": "object",
          "required": [
            "symbolName"
          ],
          "properties": {
            "filePaths": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "One or more file paths which likely contain the definition of the symbol. For instance the file which declares a class or function. This is optional but will speed up the invocation of this tool and improve the quality of its output."
            },
            "symbolName": {
              "type": "string",
              "description": "The name of the symbol, such as a function name, class name, method name, variable name, etc."
            }
          }
        },
        "description": "Request to list all usages (references, definitions, implementations etc) of a function, class, method, variable etc. Use this tool when \n1. Looking for a sample implementation of an interface or class\n2. Checking how a function is used throughout the codebase.\n3. Including and updating all usages when changing a function, method, or constructor"
      }
    },
    {
      "type": "function",
      "function": {
        "name": "list_dir",
        "parameters": {
          "type": "object",
          "required": [
            "path"
          ],
          "properties": {
            "path": {
              "type": "string",
              "description": "The absolute path to the directory to list."
            }
          }
        },
        "description": "List the contents of a directory. Result will have the name of the child. If the name ends in /, it's a folder, otherwise a file"
      }
    },
    {
      "type": "function",
      "function": {
        "name": "read_file",
        "parameters": {
          "type": "object",
          "required": [
            "filePath"
          ],
          "properties": {
            "limit": {
              "type": "number",
              "description": "Optional: the maximum number of lines to read. Only use this together with `offset` if the file is too large to read at once."
            },
            "offset": {
              "type": "number",
              "description": "Optional: the 1-based line number to start reading from. Only use this if the file is too large to read at once. If not specified, the file will be read from the beginning."
            },
            "filePath": {
              "type": "string",
              "description": "The absolute path of the file to read."
            }
          }
        },
        "description": "Read the contents of a file. Line numbers are 1-indexed. This tool will truncate its output at 2000 lines and may be called repeatedly with offset and limit parameters to read larger files in chunks."
      }
    },
    {
      "type": "function",
      "function": {
        "name": "semantic_search",
        "parameters": {
          "type": "object",
          "required": [
            "query"
          ],
          "properties": {
            "query": {
              "type": "string",
              "description": "The query to search the codebase for. Should contain all relevant context. Should ideally be text that might appear in the codebase, such as function names, variable names, or comments."
            }
          }
        },
        "description": "Run a natural language search for relevant code or documentation comments from the user's current workspace. Returns relevant code snippets from the user's current workspace if it is large, or the full contents of the workspace if it is small."
      }
    },
    {
      "type": "function",
      "function": {
        "name": "search_workspace_symbols",
        "parameters": {
          "type": "object",
          "required": [
            "symbolName"
          ],
          "properties": {
            "symbolName": {
              "type": "string",
              "description": "The symbol to search for, such as a function name, class name, or variable name."
            }
          }
        },
        "description": "Search the user's workspace for code symbols using language services. Use this tool when the user is looking for a specific symbol in their workspace."
      }
    }
  ],
  "top_p": 1,
  "stream": true,
  "messages": [
    {
      "role": "system",
      "content": "You are an expert AI programming assistant, working with a user in the VS Code editor.\nWhen asked for your name, you must respond with \"GitHub Copilot\". When asked about the model you are using, you must state that you are using gemini/gemini-2.5-flash.\nFollow the user's requirements carefully & to the letter.\nFollow Microsoft content policies.\nAvoid content that violates copyrights.\nIf you are asked to generate content that is harmful, hateful, racist, sexist, lewd, or violent, only respond with \"Sorry, I can't assist with that.\"\nKeep your answers short and impersonal.\n<instructions>\nYou are a highly sophisticated automated coding agent with expert-level knowledge across many different programming language... (litellm_truncated skipped 7533 chars) ...\nThe function `calculateTotal` is defined in `lib/utils/math.ts`.\nYou can find the configuration in `config/app.config.json`.\n</example>\nUse KaTeX for math equations in your answers.\nWrap inline math equations in $.\nWrap more complex blocks of math equations in $$.\n\n</outputFormatting>\n\n<instructions>\n<attachment filePath=\"/home/flywithu/.aitk/instructions/tools.instructions.md\">\n---\ndescription: AI Toolkit provides tools for AI/Agent app development\napplyTo: '**'\n---\n- `aitk-get_agent_code_gen_best_practices` - best practices, guidance and steps for any AI Agent development\n- `aitk-get_tracing_code_gen_best_practices` - best practices for code generation and operations when working with tracing for AI applications\n- `aitk-get_ai_model_guidance` - guidance and best practices for using AI models\n- `aitk-evaluation_planner` - guides users through clarifying evaluation metrics and test dataset via multi-turn conversation, call this first when evaluation metrics are unclear\n- `aitk-get_evaluation_code_gen_best_practices` - best practices for the evaluation code generation when working on evaluation for AI application or AI agent\n- `aitk-evaluation_agent_runner_best_practices` - best practices and guidance for using agent runners to collect responses from test datasets for evaluation\n\n</attachment>\n\n</instructions>"
    },
    {
      "role": "user",
      "content": "<environment_info>\nThe user's current OS is: Linux\n</environment_info>\n<workspace_info>\nI am working in a workspace with the following folders:\n- /home/flywithu/git/ocr_python \nI am working in a workspace that has the following structure:\n```\n-\naccident_gps.py\nandroid_get_screen.py\narrow.py\ncls_same_local.yaml\ncoupang_list.py\ncrop_and_save.py\ndclick.py\nerror_button.py\ngen_sLLM_data.py\ngeo_fix_out.txt\nget_sLLM_data_v3.py\nget_sLLM_yesdata.py\nget_xml.py\ngogofix.txt\ngpdatato.py\ngrap_oneline.py\nhighway.py\njson_gui.py\nkr_geo_fix_script.txt\nllm_test.py\nlora_merge.py\nmedical_rename.py\nmelon_go.py\nmerge_qwen_vl_gguf.py\nmissing_files.py\nModelfile\nmygo.py\nmyrefgo_10sec_gradual50.txt\nmyrefgo_absolute_pattern.txt\nmyrefg... (litellm_truncated skipped 380 chars) ...ta.txt\nrequirements.txt\nroutine.py\nsampled_geofix.txt\nshortcha_go.py\nshortcha_go2.py\nshortcha.py\nsllm_ollama_test.py\nsllm_trained.py\nsslm_test_webui.py\ntest_ocr.py\ntest_screen.py\ntest.py\ntest2.py\ntinyllama_sllm.gguf\ntmap_go.py\ntrain_data_sllm_keymatch.jsonl\ntrain_data_sllm_v2.jsonl\ntrain_data_sllm_v3.jsonl\ntrain_data_sllm_yes_only.jsonl\ntrain_dataset_qwen_item1.jsonl\ntrain_dataset_qwen.jsonl\ntrain_dataset_qwen.jsonl.bak\nvlm_dataset_2.py\nvlm_dataset.jsonl\nvlm_image_comp.py\nvlm_jsonl_compare.py\nvlm_jsonl_fine.py\nvlm_make_dataset.py\nvlm_merged.py\nvlm_qwen_myin.py\nvlm_qwen_unsloth.py\nvlm_qwenly_in.py\nvlm_qwenvl.py\nvlm_test.py\nvlm_train_qwen_go.py\nvlm_train_qwen3.py\nwordpress_post_5622_backup_20251122_214920.html\nwordpress_post_5622_backup_20251122_215223.html\nwordpress_post_5622_backup_20251122_215539.html\nwordpress_post_5622_backup_20251122_220256.html\nwordpress_post_5622_backup_20251122_220616.html\nwordpress_post_5622_backup_20251122_221240.html\nxbutton.py\nxml_20251118_182136.xml\nxml_20251118_182150.xml\nxml-rpc_test.py\nyolo_go.py\nyolo_test.py\nyolo11n-cls.pt\nyolov3u.onnx\nyolov3u.pt\nyolov8n.pt\nyolov8s.onnx\nyolov8s.pt\n...\n```\nThis is the state of the context at this point in the conversation. The view of the workspace structure may be truncated. You can use tools to collect more context if needed.\n</workspace_info>"
    },
    {
      "role": "user",
      "content": "<attachments>\n<attachment id=\"file:llm_test.py\">\nUser's active selection:\nExcerpt from llm_test.py, lines 12 to 15:\n```python\nfor user in users:\n    if user[\"is_active\"] and user[\"role\"] != \"guest\":\n        processed_name = user[\"name\"].upper()\n        target_users.append(processed_name)\n```\n</attachment>\n<attachment filePath=\"/home/flywithu/git/ocr_python/llm_test.py\">\nUser's active file for additional context:\n# 사용자 데이터 리스트 (raw_data)\nusers = [\n    {\"id\": 1, \"name\": \"Alice\", \"role\": \"admin\", \"is_active\": True},\n    {\"id\": 2, \"name\": \"Bob\", \"role\": \"user\", \"is_active\": False},\n    {\"id\": 3, \"name\": \"Charlie\", \"role\": \"user\", \"is_active\": True},\n    {\"id\": 4, \"name\": \"David\", \"role\": \"guest\", \"is_active\": True},\n]\n\n# 관리자(admin)이거나 활성(active) 상태인 유저의 이름만 대문자로 추출하는 로직\ntarget_users = []\n\nfor user in users:\n    if user[\"is_active\"] and user[\"role\"] != \"guest\":\n        processed_name = user[\"name\"].upper()\n        target_users.append(processed_name)\n\nprint(target_users)\n</attachment>\n\n</attachments>\n<context>\nThe current date is December 20, 2025.\n</context>\n<editorContext>\nThe user's current file is /home/flywithu/git/ocr_python/llm_test.py. The current selection is from line 12 to line 15.\n</editorContext>\n<reminderInstructions>\n\n</reminderInstructions>\n<userRequest>\n지금 선택한 코드를 python의 list comprehension 문법으로 바꿔줘.\n</userRequest>"
    }
  ],
  "stream_options": {
    "include_usage": true
  },
  "max_completion_tokens": 4096
}

ASK 모드의 결과는 화면에 ‘이렇게 수정하세요’라고 알려 줍니다.

3.2 Agent 모드

Agent 모드에서 사용된 토큰 사용량

Total: 29,323. 2번의 통신이 발생한다.
– 각 요청에서 Message 토큰이 1만 4천 개 이상이라, Ask 모드 대비 약 5~6배 수준까지 늘어 난다.

그러면 Request 는 무엇이 있었을까?

    {
      "type": "function",
      "function": {
        "name": "replace_string_in_file",
        "parameters": {
          "type": "object",
          "required": [
            "filePath",
            "oldString",
            "newString"
          ],
          "properties": {
            "filePath": {
              "type": "string",
              "description": "An absolute path to the file to edit."
            },
            "newString": {
              "type": "string",
              "description": "The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic."
            },
            "oldString": {
              "type": "string",
              "description": "The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. For multiple replacements, specify expected_replacements parameter. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail."
            }
          }
        },
        "description": "This is a tool for making edits in an existing file in the workspace. For moving or renaming files, use run in terminal tool with the 'mv' command instead. For larger edits, split them into smaller edits and call the edit tool multiple times to ensure accuracy. Before editing, always ensure you have the context to understand the file's contents and context. To edit a file, provide: 1) filePath (absolute path), 2) oldString (MUST be the exact literal text to replace including all whitespace, indentation, newlines, and surrounding code etc), and 3) newString (MUST be the exact literal text to replace \\`oldString\\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic.). Each use of this tool replaces exactly ONE occurrence of oldString.\n\nCRITICAL for \\`oldString\\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. Never use 'Lines 123-456 omitted' from summarized documents or ...existing code... comments in the oldString or newString."
      }
    },

Request 정보를 보면 이렇게 Function List 가 여러개 보낸다. 각 Tool에 대한 설명까지 매우 자세하게 포함되면서, Tool 수가 늘어날수록 Message 토큰도 함께 증가한다. ASK모드보다 Agent 모드에서 Function 리스트와 Tool 관련 메타데이터가 더 자세하게 포함되면서 Message 토큰이 폭발적으로 늘어난다.

  "object": "chat.completion",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_6243bdea334749199b9842844b23",
            "type": "function",
            "function": {
              "name": "replace_string_in_file",
              "arguments": "{\"filePath\": \"/home/flywithu/git/ocr_python/llm_test.py\", \"newString\": \"target_users = [user[\\\"name\\\"].upper() for user in users if user[\\\"is_active\\\"] and user[\\\"role\\\"] != \\\"guest\\\"]\", \"oldString\": \"for user in users:\\n    if user[\\\"is_active\\\"] and user[\\\"role\\\"] != \\\"guest\\\":\\n        processed_name = user[\\\"name\\\"].upper()\\n        target_users.append(processed_name)\"}"
            },
            "provider_specific_fields": {
              "thought_signature": "CiQBcsjafE06ib5z0NgHuEm+WY1wygDRcnrmHWFnWgJkp4KtiH8KdQFyyNp8jLxTl6X2vtxzUwc8j6eRxONku6GT+HJViNgYky528d5ih4MSbJ2A/0wa9h1YcFYJZ2hQ4EkFkpvs+ITI5ynM4ZRZwBEbPXu/iWann2vz0HV5wEQUAcSJVcx0wzPTlfHZHEqMQ5AU0+flUdHtRnHOrwruAQFyyNp8DXGCIzdz9D/ayBm26TQ3ycUS0ij+jH8uTFM3unCvqQXLgsVzWiX279A/dffzriS9IjLzXz8hjoKp01evdKF+ZuzAVjscyHPQVjaXqMXV/MC2P5z9+QLV14wNhu3w1Ppb5YBI9BwvIRsXfsKEmol1YrW1eSuBdq4NTrvv/mjPSEmX/NVql58mnrWNFRtpNgqU/FMAM0GvWyC7wrInbRljgsarfkM5R+8ClLXV841/hG8qWO23fdleHcBasUjY1JUbS4OkyByvR6qBL9KNRgI44kXJ0NbgZRSKhxXTZDSyDmhjtb9XEBbHYqQK5gEBcsjafMP1ksRBj1uJHEEeeNfSOmoM4Gf/ml7Y91uzj/TDbGLbHdQG4PEIpFfvdHCuYHA/znfvVKg5t4xm5rf+GQsXIWN6sPY3RG9ZIVzp/YW68Vu9cv5qV2Gc7SWaz9eUYMx55yaWg5G0SFoygnI+Q8jRUFwrRpez3B2JLR7v5nYy/XikXEmoStv+rkjtOs9vpQ4y6H7xMGAEeOyEIWdx4BECTHwdPLOzh190eCbDMaXj4wRcAJrXKyQIj1ZyZ5IlbxIHvJOz0PEyICuIUBHaS/AXXTEwlAmMZR3tbtvBcydKAZLH6grEAQFyyNp8j/q4IDjfidJRQeIsUO/VwuVATK8lJ6J2MT2p7Jrr5dSivTe3e7ahof/cLHGeC906tPxXXhhv7+VMfJWk54kNCEAF60VnZfKIL5jA0cyXc6ClMrWxs6INrPw1/8uhlkqR9xUu05L45ivW+UFk0/MY6mjRPXavVBBWDY11ylB5PvIwFRxsHUZroyX17Do6wERNOFOiTDyi2EUH6R7Xnx2aqQhV3hBQ7qSNTd7WkF5K1jG6rTJAYvpjmzdckhdXwYUKxwEBcsjafHPifkG/Nkxlejcfy+JauU/fNBEMAn8s2mDWmH1AXrx0ZykvZsz4CEZIjZaD5RgWJtrWE/RVwzE4pyFr87XT3Zl4WPrQFoNzOdi860mvYJ0zwheqPYN1XA5/OTjE/ahImxw9hLS0kng97Ekwc8tDZexLG3jy/xRBrqZalWnhtu+fpOl8ZfHngiRRdWbxSF+9KeazYleBnF0uIlxmQt9YS1Jcxb14FdYpI3yDv3x3iPqa6OMzKjKKi3LO1gSdAzYHQd+8CjkBcsjafKslQ0lmKDhX1X4xBSkpZJOhD9sCyVQE901Yj3TUO5N/G1uc45d8fcHAkIGwzlGA/Omsvfw="
            }
          }
        ],
        "function_call": null,
        "provider_specific_fields": null
      },
      "finish_reason": "stop"
    }
  ],
  "created": 1766240646,
  "system_fingerprint": null

4. Tool 리스트 조정, 토큰 절약

4.1 Tool 조정을 통한 Token 절약

  • Request를 보면 Function 리스트가 토큰의 상당 부분을 차지한다. 그렇다면 이 Function들을 줄이면 어떨까?

자주 사용하지 않는 Azure 관련 Tool을 중심으로 비활성화 했다. 현재 작업과 직접 관련 없는 클라우드·배포·테스트 Tool부터 비활성화하는 것이 가장 부담이 적다.

4.2 테스트 결과

Total: 23,607로 줄어, 기존 29,323 대비 약 20% 토큰이 감소했다. AI 결과는 완전히 동일했다.

5. 외국어 배우기 팁

  • 프로그래밍을 하면서 동시에 외국어도 같이 배울 수 없을까 하는 생각이 들었다.
  • 영어를 더 자주 쓰게 되다 보니, 문법까지 같이 봐 주면 좋겠다는 욕심이 생겼다.
  • 아래처럼 추가적인 Instruction에 추가 한다.
  • 질문을 영어로 자동 번역하고 문법을 교정해 준다.
  • 교정된 영어 문장을 다시 이탈리아어로 보여 준다.
  • 코드 주석은 항상 짧고 명확한 영어로 유지하게 만들어 준다.
  • 만약 일본어로 바꾸고 싶다면 이탈리아어 부분을 모두 일본어로 바꾸면 된다.
---
description: Personal Copilot Instructions
applyTo: '**'
---

# Personal Copilot Instructions

- Respond to all user queries in **English**.
- When the user asks a question:
  1. If the question is **not** in English, **translate it into English** first.
  2. If the question **is** in English, **correct the grammar** (do not change the meaning).
  3. **Display the corrected question** in **English and Italian** before answering.
- All code comments must be written in **English**.
- Keep comments short and clear; do not include local language.

## ✅ Response Format
- **English (Corrected):** `<corrected English question>`
- **Italian (Corrected):** `<Italian translation of the corrected English question>`
- **Answer (in English):** `<your answer>`

## ✅ Example

**User Question:**  
`come posso creare una funzione python per calcolare la media?`

**English (Corrected):**  
`How can I create a Python function to calculate the average?`

**Italian (Corrected):**  
`Come posso creare una funzione Python per calcolare la media?`

**Answer (in English):**
You can create a Python function like this:

```python
def calculate_average(numbers):
    # Return the average of a list of numbers
    return sum(numbers) / len(numbers)

# Example usage
data = [10, 20, 30]
print(calculate_average(data))  # Print the average

결과는 아래와 같습니다.

예시처럼 문법에 일부러 오류를 넣고 질문하면(codes -> code), 먼저 문장을 교정해 준다.
그다음 교정된 영어 문장을 이탈리아어로 번역해서 함께 보여 준다. 이 설정만으로도 프로그래밍하면서 자연스럽게 영어와 다른 외국어를 함께 연습할 수 있다. 또한 코드의 주석은 항상 영어로 만들어 준다.

6. 결론

  • 이제는 Co-pilot 없이 프로그래밍하기가 부담스러울 정도다.
  • 단순한 코드 복사·붙여넣기 같은 작업도 Agent 모드에 맡기는 편이 훨씬 편하다.
  • 회사 업무에서는 토큰 비용을 크게 신경 쓰지 않지만, 개인 프로젝트에서는 결국 모두 '돈'이라 토큰 사용량이 신경 쓰인다.
  • 이번 테스트처럼 불필요한 Tool을 기본적으로 비활성화해 두면 토큰을 꽤 많이 절약할 수 있다.
  • 정리하면, 간단한 질의나 짧은 코드 수정에는 Ask 모드가 효율적이고, 여러 단계가 필요한 작업과 파일 편집에는 Agent 모드가 적합하다. Tools 구성을 정리하면 Agent 모드에서도 토큰을 20% 이상 줄일 수 있고, Copilot Instructions를 활용하면 같은 환경에서 외국어 학습까지 동시에 가져갈 수 있다.

Comet 브라우저로 셀레니움 자동화 만들기

0. 서론

. 셀레니움으로 카드 실적을 자동으로 수집하는 시스템을 운영 중이었습니다.
. 매일 정확한 실적 데이터를 카카오톡으로 받을 수 있어서 편했습니다.
. 그러나 어느 날 갑자기 삼성카드가 멈추었습니다.
. 삼성카드 웹사이트 구조 변경으로 Element 가 바뀐 것이었습니다.
. 또 하나씩 Element 를 봐야 하나.. 이런 고민을 AI가 해결해주었습니다.

1. 문제상황: 웹사이트 구조 변경으로 인한 삼성카드 자동화 중단

. 웹 자동화를 해보신 분들 아실 겁니다.
▢ Element ID 변경 → 코드 즉시 망가짐
▢ CSS Selector 무용지물
▢ XPath 구조 변경 시 실패
▢ 개발자 도구로 새 선택자 찾기 반복
. 아래처럼 또 development tool에서 class/id 의 재탐색이 필요 합니다.

2. Comet 브라우저

. Perplexity 에서 개발한 AI 브라우저 자동화 도구 입니다.
Comet Browser: a Personal AI Assistant
. Browser 에 AI가 들어가 있는데, RPA 역할도 할 수 있습니다.
– 자연어 명령 – 로그인해 줘
– AI 자동 Element 탐지 – 개발자 도구 필요 없음
– XML 등 자동화 처리에 용이한 출력
. 설치 후 즉시 사용 가능

3. 삼성카드 자동 로그인

3-1. Comet 에 프롬프트 입력

삼성카드(samsungcard.com) 접속해서:
1. "아이디"  선택
2. ID 입력: 
3. 비밀번호보안키패드에서 "" 입력
   - 숫자/문자는 일반 키패드에서 클릭
   - @ 기호는 "특수" 버튼 클릭  선택
4. "입력완료""로그인" 버튼 클릭
5. 로그인 완료  이용내역 조회  XML 출력

3-2. Comet 이 RPA 처럼 정보 수집 -> XML 로 출력도 해줌

1단계 → 사이트 접속 및 분석
2단계 → 아이디 탭 클릭
3단계 → ID 자동 입력
4단계 → 보안키패드 인식 & 클릭
5단계 → 로그인 완료
6단계 → 데이터 추출 → XML 출력

3.3 오류 대응 필요

아래처럼 password 입력이 정책에 위반된다고 뜨는 경우가 있습니다. 비밀번호, 패스워드 이런 식으로 살짝만 바꾸면 됩니다. 아직은 이러한 정책에 여유가 있습니다.

4. 기존 Python 코드의 개선

  • Comet 이 해준 것들
    . 기존 프로젝트 구조 유지
    . Custom library 재사용 – 가상 키보드
    . 적절한 대기 시간 자동 설정
  • 단순 코드 생성이 아닌, 기존 코드와 자연스러운 통합

5. 기존 방식과의 비교

구분수동 수정Comet AI
Element 탐색10~30분0분
코드 작성30분~1시간3분
유지보수 수동 설정동일 프롬프트 재실행

6. Python으로 수정한 것 데모 및 결론

. Comet 등 AI의 활용은 개발 시간 혁신 (시간 -> 분)
. 코드 품질 향상 – 기존 라이브 재활용
. 유지보수 편의성 – 프롬프트만 재실행
. 다른 카드사도 문제 발생 시 동일 방식으로 적용
. 단순 작업은 AI에게. 난 창의적 문제 해결에 집중

TMAP 점수 25점 – Qwen / GPS Spoofing

1. TMAP 점수 테스트 실행 배경

. 지인들과 저녁 식사를 하는데 TMAP 이야기가 나왔습니다.
. 대부분 80~90점대인데, 한명만 60점대.
. 어떻게 60점대가? 물었더니 브레이크를 자주 밟아서 그런거 같다는 답변
. 그래서 궁금해졌습니다. 과연 TMAP은 어떤 요소를 볼까?

이런 상황

2. TMAP의 점수 영향 요건

. 운전 습관과 운전 환경이 영향을 줍니다.
. 습관 – 과속 / 급가속 / 급감속
. 환경 – 야간 운전.
. https://www.tmapmobility.com/story/drivetip/detail/37

3. Android에서 FakeGPS 구현

. Android는 Mock GPS 기능이 있습니다. (가상 좌표를 셋팅하는 기능)
. 그러나 TMAP은 Mock GPS를 감지하는 기능이 있습니다.
. 그래서 Android에서 FakeGPS라는 Daemon을 만들어서 기존 GPS 드라이버를 대체하고,
외부에서 GPS 좌표를 넣어줄 수 있게 했습니다.
. Python으로 Client를 만들어서, 두 좌표를 받으면 두 지점의 길을 라우팅해서 주기적으로 해당 좌표를 GPS 신호처럼 (NMEA) 보내줍니다.
. 두 지점의 라우팅을 할 때, 과속/급감속/급가속을 중간중간 넣어서 좌표 위치를 보정했습니다.
. 라우팅의 편의를 위해서 고속도로로 제한했습니다.
. SELINUX Rule 설정이 제일 귀찮았습니다. ;; 이건 한번에 딱 끝내기가 어렵네요..

flowchart TB
    A["두 좌표 입력
(Start / End)"] --> B["경로 보간
(Route Interpolation)"]

    B --> C["급감속/가속 적용
Speed Algorithm"]

    C --> D["NMEA 생성
(GPRMC / GPGGA)"]

    D -->|Telnet| E["FakeGPS 데몬\n전송 처리"]

    E --> F["GPS HAL
Injection"]

    F --> G["Location
Framework"]

    G --> H["TMAP
Navigation"]

type hal_gnss_fakegps, domain;
type hal_gnss_fakegps_exec, exec_type, file_type, vendor_file_type;
init_daemon_domain(hal_gnss_fakegps)
allow hal_gnss_fakegps mediaserver_exec:file { read open getattr execute };
allow hal_gnss_fakegps hwservicemanager:binder call;
type fakegps_helper, domain;
type fakegps_helper_exec, exec_type, vendor_file_type, file_type;                                                                                                      init_daemon_domain(fakegps_helper)                                                                                        net_domain(fakegps_helper)
allow fakegps_helper self:unix_dgram_socket { create write connect sendto getopt setopt };
allow fakegps_helper socket_device:sock_file { write read open };
allow fakegps_helper hal_gnss_default:unix_dgram_socket sendto;
service vendor.gnss-1-0-default /vendor/bin/hw/android.hardware.gnss@1.0-service.default
    class hal
    user gps
    group gps system inet
    socket fakegps dgram 0666 gps gps
    restart

4. 1차 테스트 결과

. 과속은 잘 되는데, 급감속/가속은 인식이 안되었습니다.
. 급감속은 여러 보정을 해서, 여유를 두었다는 이야기가 보였습니다.
. 그래서 linear 하게 속도를 급가속/감속을 하면 안되나 싶어서, 2차 곡선으로도 속도 변화량을 변경했지만 다 실패했습니다.

5. 2차 테스트

. TMAP의 설명은 급가속/감속은 여러 여유를 두었다는 설명이 있었습니다.
. 그만큼 인식이 되기 위해서는 이런 여유 – IF condition 안에 들어가게 gps 정보를 만들어야 했습니다.
. 아무리 해도 안되어서, TMAP Smali 코드를 보았지만, 정확한 위치를 찾기가 어려웠습니다.
. 여기서 VSCode Agent 사용 -QWEN coder 의 힘을 빌었습니다. 실제로는 multi-turn으로 했고, 그 프롬프트를 정리하면 아래와 같습니다. .
. 그러면 Smali를 분석해서, “짠”하고 조건을 알려 주면 좋은데. 사실 시간도 꽤 걸리고 몇몇 보정을 해줘야 합니다.
. 그래도 Smali를 눈/손으로 하려면 복잡한데 빠르게 잘 해줍니다.

분석을 시작하기 전에, 먼저 PowerShell에서 APK를 디컴파일해 주세요.

다음 명령을 그대로 실행하면 디컴파일 결과가 생성됩니다:

& java -jar "C:\Users\flywi\.apklab\apktool_2.12.1.jar" d -f `
    -o "C:\tmp\tmap\apk_out" `
    "C:\tmp\tmap\tmap.apk"

디컴파일이 완료되면 C:\tmp\tmap\apk_out 아래에 smali 파일들이 생성되는데,  
이제부터 진행하는 모든 검색(find), 문자열 탐색, 패턴 조회 역시  
PowerShell 명령을 사용해 주세요.

예를 들어, 아래와 같은 방식으로 검색할  있습니다:

# 속도/변화 관련 키워드 검색
Get-ChildItem -Recurse "C:\tmp\tmap\apk_out" -Filter *.smali |
    Select-String -Pattern "speed", "location", "change", "delta", "sudden"

# 비교/연산 패턴 검색
Get-ChildItem -Recurse "C:\tmp\tmap\apk_out" |
    Select-String -Pattern "if", "cmp", "sub", "acceler", "deceler", "sudden"

이런 식으로 PowerShell 검색 기능을 적극 활용해  
의심되는 함수들을 넓게 스캔하면서 분석을 진행해 주세요.

──────────────────────────────────────────
[분석 목표]
──────────────────────────────────────────
제가 알고 있는 정보는 아주 제한적입니다.  
정확한 함수명을 모르고, 다음  가지 사실만 알고 있습니다:

1) 속도 비교는 대략 3 간격(또는 그에 해당하는 이전 데이터) 기준으로 이루어진다.  
2) 속도 변화량이  30km/h 이상이면 급가속 또는 급감속으로 본다.

  단서를 기반으로,
APK 전체(smali/java)에서속도 변화량을 계산하거나 비교하는 모든 코드  
후보(candidate) 찾아 주세요.

──────────────────────────────────────────
[최종 결론 요청]
──────────────────────────────────────────
후보 함수들을 모두 비교한 ,

실제 급가속/급감속을 판단하는 핵심 함수는 무엇인지  
 함수를 선택한 구체적인 근거  
 함수가 전체적으로 어떤 방식으로 동작하는지 요약

  가지를 최종 결론으로 정리해 주세요.

6. 조건 확인 후 코드 수정 – 재테스트

주행 중에는 각 20회씩 급감속/가속을 했습니다. 그 결과 TMAP의 점수는 20점대로 하락. ㅎ
과속의 경우는 점수에 대한 영향은 작습니다. 아무리 과속을 해도 일정 점수 미만으로 낮아지지는 않습니다.
급가속의 경우도 영향은 있지만, 큰 영향은 없습니다.
그러나 급감속!!은 매우 영향이 큽니다. 아래와 같이 동일 횟수 수행 시 가속 대비 4~8배의 영향이 있습니다.
이렇게 엉망으로 운전했는데도 하위에 아직도 1%가 더 있다는 것에 매우 놀라움을.. 어떻게 하면 더 낮은 점수가 있을 수 있지?

7. 결론

실험을 통해 확인한 TMAP 점수 영향 요소
1. 급감속 – 최대 영향. 동일 횟수 기준 급가속 대비 4~8배 감점
2. 급가속 – 중간 영향. 감점은 있으나 제한적
3. 과속 – 영향은 있으나, 절대적이지는 않음.

인위적으로 만든 점수보다 더 낮은 점수의 1%가 존재한다는 점은 매우 흥미롭고 놀랍습니다.

TMAP이 보는 진짜 ‘안전 운전’

TMAP의 알고리즘을 보면, ‘급브레이크’를 밟지 않는 것입니다.
앞차와의 안전거리 확보, 예측운전 이것이 방어 운전을 잘 한다는 것이고 즉 안전운전의 핵심입니다.

이 글의 한계

이 글을 GPS 기반 점수를 기술적으로 이해하려고 한, 실험적 탐구 입니다.
실제 도로에서 재현은 매우 위험합니다.
APK 디컴파일은 개인적 목적으로 되었고, FAKE GPS를 통해 점수를 올리는 활동은 약관 위반입니다.

참고:
제101조의4(프로그램코드역분석) ① 정당한 권한에 의하여 프로그램을 이용하는 자 또는 그의 허락을 받은 자는 호환에 필요한 정보를 쉽게 얻을 수 없고 그 획득이 불가피한 경우에는 해당 프로그램의 호환에 필요한 부분에 한정하여 프로그램의 저작재산권자의 허락을 받지 아니하고 프로그램코드역분석을 할 수 있다. <개정 2023. 8. 8.>

② 제1항에 따른 프로그램코드역분석을 통하여 얻은 정보는 다음 각 호의 어느 하나에 해당하는 경우에는 이를 이용할 수 없다.

프로그램코드역분석의 대상이 되는 프로그램과 표현이 실질적으로 유사한 프로그램을 개발·제작·판매하거나 그 밖에 프로그램의 저작권을 침해하는 행위에 이용하는 경우

호환 목적 외의 다른 목적을 위하여 이용하거나 제3자에게 제공하는 경우

GPU 없이 Qwen-coder:480b 사용하기(VS code – copilot)

AI 시대의 도래 (Codex 모델들)

이제는 AI 없이 코딩하는 것은 매우 비효율적입니다. 여러 조사 결과처럼 AI 도구들은 개발자의 생산성을 향상시켜 줍니다. 그러나 저처럼 무료 사용자는 Cloud quota를 금방 소진해서, 추가적인 방법이 필요 합니다. 또한 보안이 중요한 프로젝트에서는 코드를 외부로 전송할 수 없어 로컬 LLM이 필수입니다.

GPU 없이, AI 사용하기

대형 기업이라면, 자체적인 GPU 센터 및 자체 모델 또는 OSS 모델을 이용해서 구축할 수 있습니다. 하지만 개인 개발자에게는 GPU 비용이 부담스럽습니다.

Qwen-coder 모델을 사용해 보았는데, 최소 30B 모델은 되어야 쓸만했습니다. 30B 모델을 CPU만으로 실행하면 추론 속도가 너무 느려서 실제 코딩에 사용하기 어렵습니다.

OLLAMA – Cloud 모델 사용하기

OLLAMA란?
Ollama is the easiest way to get up and running with large language models such as gpt-oss, Gemma 3, DeepSeek-R1, Qwen3 and more.

OLLAMA는 여러 LLM을 사용할 수 있게 해줍니다. 특히 최근 버전부터 Cloud 모델을 지원하고 있습니다.

Cloud 모델은 로컬에는 얇은 레이어만 설치하고, 실제 추론은 Ollama 서버에서 동작합니다. 이 방식을 사용하면 480B 같은 초대형 모델도 로컬에서 사용 가능합니다.

OLLAMA 설치 하기 – Linux CLI 기준

# OLLAMA 설치
curl -fsSL https://ollama.com/install.sh | sh

# Cloud 모델 다운로드
ollama pull qwen3-coder:480b-cloud

# Ollama 로그인
ollama signin

# 외부 접속 설정
sudo nano /etc/systemd/system/ollama.service
Environment="OLLAMA_HOST=0.0.0.0"

sudo systemctl daemon-reload
sudo systemctl restart ollama

윈도우 사용자의 경우:
https://ollama.com/download/windows 에서 Windows용 설치 파일을 다운로드하여 설치하시면 됩니다.

참고:
로컬 설치 없이 Ollama API로 직접 접근하는 방법도 고려했으나, Python에서는 가능했지만 VS Code Copilot 설정에서는 해당 옵션을 찾지 못했습니다.

VS Code설정 하기 
Ctrl + Shift + P를 눌러 User Settings를 엽니다.

"github.copilot.chat.byok.ollamaEndpoint": "http://192.168.10.90:11434"

NAS나 Docker 환경에서도 동일하게 설정 가능합니다.


이제 VS Code에서 copilot 에서 보면 qwen3-coder 480b 가 보입니다.
이제 Qwen3-coder를 사용할수 있습니다.

테스트 영상은 아래와 같습니다.


LiteLLM 으로 확장 하기

저는 Ollama를 LiteLLM Proxy를 경유해서 사용합니다. LiteLLM은 여러 LLM 제공자를 통합 관리할 수 있는 프록시 서버입니다.

이렇게 사용하면 여러 장점이 있습니다. AI를 자주 사용할 때 발생하는 429 에러(Too Many Requests)를 줄일 수 있습니다. routing 옵션을 추가하면 재시도를 자동으로 처리해주기 때문입니다. 또한 fallback 모델들을 추가하면 한 모델이 실패해도 다른 모델이 대체하여 작업을 완료할 수 있습니다.

LiteLLM을 연결하려면 VS Code-insider 버전이 필요합니다. 아래 이미지에서 볼 수 있듯이 왼쪽이 정식 버전, 오른쪽이 Insider 버전 아이콘입니다. . [https://code.visualstudio.com/insiders]

OpenAI compatible 을 지원하는 방법이 찾아보면 여러개 있는데 저한테 동작한것은 insider 버젼 설치 였습니다. model 추가도 아래와 같이UI가 잘 되어 있습니다.

이번에 Qwen 모델의 큰 도움을 받았습니다.
다음 POST 에서 올리겠습니다.

나만 안 돼? TVING Android 9 문제, AWS Device Farm 테스트 후기

  1. 구형 폰으로 OTT 앱 테스트, TVING 만 문제
    갤럭시 플립 터치 불량으로 iPhone을 고민했지만, 워치 호환성문제로 10년 된 S9+를 꺼냈습니다. 디즈니+, 넷플릭스, 쿠팡 플레이는 잘 되지만, TVING만 안드로이드 9을 지원한다는데, 로그인 조차 안되었습니다. (플립/폴더블은 2~3년 쓰기 힘들다.)

– 다른 OTT들은 다 잘 되는데 TVING 만 안되는 것이 이상해서 Play Store를 다시 들어가 보니, 평점 2.6의 위엄을 가진 앱이었습니다. (저 댓글은 내가 쓴거 아님… vs. disney: 4.4, netflix: 3.7)

2. 정말 내 폰만 안되는 걸까? 테스트 시작
“아무리 평점이 낮아도, 지원한다고 했으면 내 폰에서 로그인은 하고, 사소한 문제가 있어야지. 도대체 왜 동작 안하는거냐.”는 생각이 들었고, AWS Device Farm이 떠올랐습니다. 1000분 무료 사용이기도 해서 한번 테스트 해봤습니다.
– AWS Device Farm은 Amazon 에서 Mobile App 테스팅 서비스. iOS/Android 지원. Facebook이 사용한다고 널리 알려진 서비스.

3. Appium으로 다양한 기기 테스트
지금까지 안드로이드 테스트는 UiAutomator를 이용했는데, AWS는 Appium의 사용이 일반적인 것 같아서, Appium으로 했습니다. 확실히 UiAutomator보다 환경이 좋습니다. Test Code 검증을 위해서 미지원하는 Android 8 과 타 Android 9를 함께 동작 해보았습니다.

테스트 결과는 당연히 Android 8은 Fail (예상된 결과)이었고, Android 9를 포함해 제 기기와 유사한 Galaxy S9은 Pass였습니다. 엇. 이건 왜? 내 것만 안되는데..

4. AWS가 테스트 결과 리뷰

AWS는 꽤 많은 테스트 결과를 제공합니다. 특히 TCP Dump는 환경 구축이나 사용이 번거로운데, 이것도 지원해줍니다. 화면도 우측처럼 동영상 캡쳐를 해주고요. 로그인 테스트만 한건데, 동작을 잘합니다. 여기서 부터 혼란이 시작됐습니다. 왜 내 것만 안되는 거지?

5. 원인 발견: WebView 버전
그렇다면 OS 버젼이 아니라, WebView 버전이 문제인가 하는 생각이 들어 확인해보니, AWS Farm은 높은 버젼의 WebView이고, S9+는 81 버전 이었습니다.

1. 근데 S9+ 에서는 시스템 WebView 업데이트 메뉴를 찾을 수 없었습니다. 한참을 검색해서 아래 링크를 통해서 PC에서 설치를 했습니다.
https://play.google.com/store/apps/details?id=com.google.android.webview

2. 아파트 아이 App을 설치하면 webview 업데이트를 요청 합니다.

webview 를 업데이트 한후에는 tving 이 정상적으로 로그인 및 사용이 가능 합니다.

6. WebView 호환성 문제와 앱 완성도
WebView 버전이 문제라면.. Android 9은 지원했으니 TVING 앱은 Test Pass 일까? Android 9은 최초에 WebView 버전 66에서 시작했습니다. 그러니까 81이면 삼성에서 업데이트해서 출시해 준 것입니다. WebView가 자동 업데이트가 되긴 하지만, 저처럼 자동으로 안 되는 경우도 있습니다. 완성도 있는 앱이라면, Android9과 같이 출시된 WebView에서 동작이 되거나, 최소한 WebView 업데이트하라고 안내해줬으면 좋았을 것입니다. 하이브리드 앱인 쿠팡등이 정상적으로 로그인을 잘한것을 보면..

사용자가 원하는 것은 이런 알림이 아닐까?

이렇게 알려줘야 아. 뭐가 이상이 있구나. 하고 이해하고 넘어가지.

또한 Crash Report 수집 및 안내 기능이 있어야 할 것 같습니다. 만약 이번 문제를 TVING에 문의했으면, 아마 이런 대답: “네 저희는 Android 9 지원합니다. 고객님 환경문제로 보입니다.” 실제로는 Android 9을 지원하니까요. 하지만 실제 원인은 TVING 앱의 WebView 처리 문제였습니다.

좋은 앱이라면 Crash Report를 수집해서 어떤 환경에서 문제가 발생했는지 분석해야 합니다. 오류 메시지라도 표시하거나, 다양한 기기와 OS 버전에서 테스트를 했어야 했습니다. “Android 9 지원”이라는 것은 단순히 OS 버전(API)만 의미하는 것이 아니라, 그 OS 버전의 다양한 환경까지 고려해야 합니다.

7. AWS Device Farm 사용 후기
– AWS Device Farm은 정말 훌륭합니다. 여러 제조사(삼성, LG, 구글 등)의 다양한 기기에서 테스트할 수 있습니다. 특정 제조사의 커스텀 UI 영향도 확인할 수 있습니다.
– 속도도 꽤 빠르고, 이 수준의 인프라를 구축하려면 (핸드폰 관리, 연결 유지, 비디오, TCP dump 등) 꽤 많은 비용이 듭니다. 특히 테스트 결과 UX가 상당히 현대적이어서 좋습니다
– 아쉬운 점이 있다면, Xiaomi, Sony까지는 있지만 OPPO 등 다른 브랜드 제품도 지원했으면 합니다.
– AWS Device Farm에서 로그인 테스트를 하면 아래처럼 메일이 옵니다. 여러 장비를 동시에 돌리니 엄청난 퍼펙트 스톰 메일이 쏟아집니다.

8. AWS Device Farm 설정 방법
아래는 AWS 설정 부분입니다. 간단히 클릭만 하면 됩니다. 테스트 코드와 APK만 업로드하면 바로 테스트가 시작됩니다. 안드로이드는 약 100개 정도의 모델이 지원됩니다.

Qwen3vl 파인 튜닝으로 프롬프트 최소화 및 일관성 개선: 119-> 37토큰 개선

YOLO를 이용한 button 찾기를 Fail할때, LLM (GPT) API를 이용해서 Button 찾기를 수행했었습니다.
그러나 최근에 Fail 발생이 늘어났습니다. 그 이유는 왼쪽과 같이 Fake X 버튼이 생겼기 때문입니다. 아직 이 문제는 해결하지 못했지만, LLM 쿼리가 많아져 로컬 LLM의 도입이 필요 해졌습니다.

  1. 기존 LLM 기반 접근과 한계
    • 처음에는 화면 구성 XML을 가지고 위치를 추측하려고 했으나, XML로는 추측이 불가능했습니다. 모든 요소들이 description 이나 무슨 목적의 component 인지 hint가 모두 막혀 있었습니다.
  2. VLM 으로 전환 – Basemodel Test
    Qwen3-VL 은 VLM 분야에서 유명한 모델이라 검증 대상으로 선택 했습니다.

Prompt -A
<|im_start|>system
You are a helpful mobile UI expert that analyzes app screenshots.
Your task is to locate the close or skip button in advertisements.
<|im_end|>
<|im_start|>user
Look at the image and return ONLY a JSON object in this exact schema:
{
“x”: ,
“y”: ,
“confidence”: <0.0~1.0>,
}
Rules:

  • Coordinates are absolute pixels (origin top-left)
  • Respond ONLY with valid JSON (no explanations)

<|image_pad|>
Where should I click to close the advertisement?
<|im_end|>
<|im_start|>assistant

Prompt – B
<|im_start|>system
You are a helpful mobile UI expert.
<|im_end|>
<|im_start|>user
<|image_pad|>
Find the close or skip button in the advertisement and return JSON coordinates.
<|im_end|>
<|im_start|>assistant

위와 같은 두개의 Prompt를 이용했고, 각각의 Token수는 119개, 37개 입니다.

BaseModel의 테스트 결과

Prompt – A
{
“x”: 848,
“y”: 620,
“confidence”: 0.97
}
Prompt -B
“`json
[
{“bbox_2d”: [775, 825, 999, 900], “label”: “close or skip button in the advertisement”}
]
“`

결과를 보면 긴 Prompt와 짧은 Prompt 간의 출력 차이가 있습니다.

3. 데이터셋 구성
– 기존에 ChatGPT API를 이용했던 Fail 케이스를 모아놓았던 것을 이용했습니다.
– Input 이미지와, x,y 좌표와 reason 으로 구성되었습니다.
– 관련 내용은 기존 blog 참고 – https://flywithu.com/archives/8063

4. 파인 튜닝
– Colab은 최대 세션이 4~5 시간으로, 긴 학습엔 적합하지 않았습니다.
– 차선책으로 https://lightning.ai/ 를 이용했으며, 한달 기준 Colab 보다 GPU사용량은 적지만 연속 이용이 가능했습니다.

5. 실험 결과 (CPU 입니다. GPU사용시 2~3s걸립니다.)

항목Fine – Prompt BBase – Prompt BBase – Prompt A
JSON 완성도Pass (100%)FailPass(Markdown JSON)
in_tok / out_tok37/2337/51119/33
속도66s80s73s
--- Fine-Tuned Model + Short Prompt B
The following generation flags are not valid and may be ignored: ['temperature', 'top_p', 'top_k']. Set `TRANSFORMERS_VERBOSITY=info` for more details.
[debug] Raw model output:
system
You are a helpful mobile UI expert.
user

Find the close or skip button in the advertisement and return JSON coordinates.
assistant
{"x": 80, "y": 58, "confidence": 1.0}

x=80, y=58, time=66.193s , in_tok=37, out_tok=23

--- Baseline Model + Short Prompt B
[debug] Raw model output:
system
You are a helpful mobile UI expert.
user

Find the close or skip button in the advertisement and return JSON coordinates.
assistant
```json
[
  {
    "x": 50,
    "y": 35,
    "width": 100,
    "height": 50,
    "label": "skip"
  }
]
```

x=50, y=35, time=80.670s , in_tok=37, out_tok=51

--- Baseline Model + Long Prompt A
[debug] Raw model output:
system
You are a helpful mobile UI expert that analyzes app screenshots.
Your task is to locate the close or skip button in advertisements.
user
Look at the image and return ONLY a JSON object in this exact schema:
{
  "x": <integer pixel x>,
  "y": <integer pixel y>,
  "confidence": <0.0~1.0>,
}
Rules:
- Coordinates are absolute pixels (origin top-left)
- Respond ONLY with valid JSON (no explanations)


Where should I click to close the advertisement?
assistant
```json
{
  "x": 50,
  "y": 40,
  "confidence": 0.98
}
```

x=50, y=40, time=73.148s , in_tok=119, out_tok=33

6. 결론
파인튜닝을 통해 Task 내제화 -> 안정적 출력
지나치게 긴 Prompt 제거 -> 토큰 감소, Decode 비용 감소, 속도 향상
장기간 학습에는 Lighting.ai 고려 필요

안드로이드 검은 화면 캡처 해결: 영상 프레임 추출 우회법(ShortCha)

  1. 문제 상황 – 안드로이드 오버레이가 검게 캡쳐될 때 해결 방법

ShortCha의 광고 영상을 d.screenshot으로 캡처할 때, 왼쪽과 같이 화면 전체가 검은색으로 캡처되는 경우가 있습니다. 그 이유는 일반적으로 window에 secure flag가 있거나, 오버레이 화면에서 발생했습니다. 이렇게 검은색으로 캡처되면, YOLO에서 “X” 좌표를 찾지 못해 광고 SKIP이 Fail 하는 경우가 발생했습니다. 특히 최근에 더 자주 발생하고 있습니다. 사실 안드로이드를 커스텀으로 빌드해서 사용하기 때문에, 프레임워크에서 해결할수도 있었지만, 다른 환경에서도 쉽게 적용 할수 있는 방법을 찾기로 했습니다.

2. 해결 방법 – 우회 방법

스크린샷을 사용하는 것은 불가능하지만, 스크린 레코딩은 가능했습니다. Shortcha에서 테스트 할 때도 가능했습니다.

아래와 같이 구현했습니다.

a. 짧은 화면 녹화 -> 녹화된 영상에서 1frame 추출 -> 기존 screenshot 처럼 return

3. 결과 – 문제 해결

  • 영상의 품질은 조금 저하되지만, YOLO에서 인식에는 전혀 문제가 없이 버튼들이 인식됩니다.
  • 아래와 같이 육안으로 보면 둘 사이에 차이가 크지 않습니다.

d.screenshot

video frame capture

동영상 캡처 구현 – 다음은 Python 코드입니다. safe_screenshot은 기존의 스크린샷 코드이고, capture_android_frame()은 동영상 녹화 후 1프레임을 추출하는 함수입니다. (Linux 기준)

def safe_screenshot(d):
    """uiautomator2 → adb fallback screenshot"""
    try:
        img = d.screenshot(format="pillow")
        if not img.getbbox():
            raise ValueError("Black screen detected.")
        return img
    except Exception as e:
        print(f"⚠️ fallback to adb screencap: {e}")
        img = capture_android_frame()
        if img is not None:
            return img
        raise ValueError("Failed to capture screenshot.")

Code

import subprocess
import numpy as np
import cv2
import time
import signal
import sys
import threading
import os
from PIL import Image

def capture_android_frame(
    output_jpg="frame.jpg",
    tmp_video="/tmp/frame.mp4",
    width=1080,
    height=1920,
    fps=30,
    duration=2.0,
    record_format="mp4",
    cleanup=True,
):
    # Remove existing temporary file
    if os.path.exists(tmp_video):
        os.remove(tmp_video)

    print(f"[info] Starting scrcpy capture ({record_format})...")
    scrcpy = subprocess.Popen(
        [
            "scrcpy",
            "--no-display",
            "--record", tmp_video,
            "--record-format", record_format,
            "-m", str(height),
            "--max-fps", str(fps),
        ],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        bufsize=10**8,
        text=True,
    )

    # Thread to print scrcpy logs in real time
    def read_logs(pipe):
        for line in iter(pipe.readline, ''):
            sys.stdout.write(f"[scrcpy] {line}")
        pipe.close()

    log_thread = threading.Thread(target=read_logs, args=(scrcpy.stderr,))
    log_thread.daemon = True
    log_thread.start()

    # Wait for scrcpy to start and capture a few frames
    time.sleep(duration)

    # Gracefully stop scrcpy recording
    scrcpy.send_signal(signal.SIGINT)
    log_thread.join(timeout=1.0)
    scrcpy.wait()

    # Verify that the video was created
    if not os.path.exists(tmp_video) or os.path.getsize(tmp_video) < 5000:
        raise RuntimeError("⚠️ Recording failed — no video created")

    print(f"✅ Recorded temporary stream: {tmp_video} ({os.path.getsize(tmp_video)/1024:.1f} KB)")

    # Extract one frame using ffmpeg
    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-loglevel", "error",
            "-i", tmp_video,
            "-frames:v", "1",
            output_jpg,
        ],
        check=True,
    )

    # Load the frame using OpenCV
    frame = cv2.imread(output_jpg)
    if frame is None:
        raise RuntimeError("⚠️ Failed to decode frame")

    # Convert to Pillow Image before returning
    img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
    print(f"✅ Saved frame: {output_jpg} ({frame.shape[1]}x{frame.shape[0]})")

    # Optionally delete the temporary video
    if cleanup:
        os.remove(tmp_video)

    return img

if __name__ == "__main__":
    img = capture_android_frame(
        output_jpg="frame.jpg",
        tmp_video="/tmp/frame.mp4",
        duration=2.5,
        record_format="mp4",
    )
    print(f"[done] Captured image size: {img.size}")  # (width, height)

YOLO 모델 업데이트: 인식 실패 항목 추가 학습

기존 모델에서 인식 실패 했던, X와 화실표에 대해서 재학습을 진행 했습니다. 재 학습후 지난 1주일동안 테스트 결과에서, 더이상 미인식 사례가 발생하지 않았습니다.

아래는 F1 그래프 입니다.

모델파일은 아래 링크에서 다운 받을수 있습니다.

이미지의 좌측/우측 상단 20% 영역만 학습에 사용했으므로, 추론시에도 동일한 영역만을 대상으로 적용해야 최적의 성능을 얻을수 있습니다.

SCREEN_CROP_RATIO = 0.20 
def _crop_square(img: Image.Image, where: str, ratio: float) -> Tuple[Image.Image, int, int]:
    """Return (crop, x_offset, y_offset) for 'rt' or 'lt' square crops."""
    w, h = img.size
    side = int(min(w, h) * ratio)
    if where == "rt":
        crop = img.crop((w - side, 0, w, side))
        return crop, w - side, 0
    elif where == "lt":
        crop = img.crop((0, 0, side, side))
        return crop, 0, 0
    else:
        raise ValueError("where must be 'rt' or 'lt'")