0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OCR + OpenCVで低コスト作業ログシステムを作る:LMMに頼らない画面・人物解析

Posted at

はじめに

リモートワークの普及に伴い、作業状況を可視化するツールへの需要が高まっています。GPT-4 VisionなどのLMM(Large Multimodal Model)を使えば高精度な解析が可能ですが、APIコストが高いのが悩みどころです。

本記事では、Pythonの従来型画像処理技術(OCR + OpenCV)を使って、低コストで作業ログシステムを構築する方法を解説します。

対象読者

  • リモートワーク管理ツールに興味がある方
  • LMM APIのコストに悩んでいる方
  • Python + OpenCVでの画像処理に興味がある方

システム概要

実現する機能

  1. 画面解析:スクリーンショットからOCRでテキスト抽出し、作業内容を推定
  2. 在席判定:Webカメラから顔検出で在席/離席を判定
  3. 作業ログ記録:タイムスタンプ付きでデータベースに保存

技術スタック

  • Python 3.9+
  • Tesseract OCR / EasyOCR:画面テキスト抽出
  • OpenCV:Webカメラ映像処理・顔検出
  • Pillow:スクリーンショット取得
  • SQLite:ログデータ保存

セットアップ

必要なライブラリのインストール

# 基本ライブラリ
pip install opencv-python pillow easyocr

# Tesseract OCR(高速・軽量)
# Windows: https://github.com/UB-Mannheim/tesseract/wiki
# Mac: brew install tesseract
# Linux: sudo apt-get install tesseract-ocr
pip install pytesseract

# データベース(標準ライブラリ)
# sqlite3は標準で含まれています

実装:画面解析(OCR)

スクリーンショット取得 + OCR

import pytesseract
from PIL import ImageGrab
import re
from datetime import datetime

class ScreenAnalyzer:
    def __init__(self, lang='jpn+eng'):
        """
        言語設定:日本語+英語のOCR
        """
        self.lang = lang
    
    def capture_screen(self):
        """
        画面全体をキャプチャ
        """
        screenshot = ImageGrab.grab()
        return screenshot
    
    def extract_text(self, image):
        """
        OCRでテキスト抽出
        """
        text = pytesseract.image_to_string(image, lang=self.lang)
        return text.strip()
    
    def classify_activity(self, text):
        """
        抽出テキストから作業内容を推定
        """
        text_lower = text.lower()
        
        # キーワードベースの分類
        if any(keyword in text_lower for keyword in ['vscode', 'pycharm', 'def ', 'class ', 'import']):
            return 'コーディング'
        elif any(keyword in text_lower for keyword in ['chrome', 'firefox', 'http', 'www']):
            return 'ブラウジング'
        elif any(keyword in text_lower for keyword in ['excel', 'word', 'powerpoint', '.xlsx', '.docx']):
            return 'ドキュメント作成'
        elif any(keyword in text_lower for keyword in ['slack', 'teams', 'zoom', 'meet']):
            return 'コミュニケーション'
        else:
            return '不明'
    
    def analyze(self):
        """
        画面解析の実行
        """
        screenshot = self.capture_screen()
        text = self.extract_text(screenshot)
        activity = self.classify_activity(text)
        
        return {
            'timestamp': datetime.now().isoformat(),
            'activity': activity,
            'text_sample': text[:100]  # 最初の100文字のみ保存
        }

# 使用例
analyzer = ScreenAnalyzer()
result = analyzer.analyze()
print(f"作業内容: {result['activity']}")
print(f"抽出テキスト: {result['text_sample']}")

EasyOCRを使った高精度版

Tesseractで精度が出ない場合は、EasyOCRを試してみましょう。

import easyocr

class ScreenAnalyzerEasy:
    def __init__(self):
        # 初回実行時にモデルダウンロード(時間がかかる)
        self.reader = easyocr.Reader(['ja', 'en'], gpu=False)
    
    def extract_text(self, image):
        """
        EasyOCRでテキスト抽出
        """
        results = self.reader.readtext(image)
        # 検出されたテキストを結合
        text = ' '.join([result[1] for result in results])
        return text.strip()

比較:Tesseract vs EasyOCR

項目 Tesseract EasyOCR
速度 高速 低速
精度
GPU対応 なし あり
初期設定 要インストール pip install のみ

実装:人物検出(OpenCV)

Webカメラから顔検出

import cv2
from datetime import datetime

class PersonDetector:
    def __init__(self):
        """
        Haar Cascade分類器の初期化
        """
        self.face_cascade = cv2.CascadeClassifier(
            cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
        )
        self.cap = cv2.VideoCapture(0)  # 0はデフォルトカメラ
    
    def detect_face(self):
        """
        顔検出による在席判定
        """
        ret, frame = self.cap.read()
        if not ret:
            return {'present': False, 'error': 'カメラ読み取り失敗'}
        
        # グレースケール変換(顔検出の前処理)
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        
        # 顔検出
        faces = self.face_cascade.detectMultiScale(
            gray,
            scaleFactor=1.1,
            minNeighbors=5,
            minSize=(30, 30)
        )
        
        return {
            'timestamp': datetime.now().isoformat(),
            'present': len(faces) > 0,
            'face_count': len(faces)
        }
    
    def release(self):
        """
        カメラリソースの解放
        """
        self.cap.release()

# 使用例
detector = PersonDetector()
result = detector.detect_face()
print(f"在席状況: {'在席' if result['present'] else '離席'}")
detector.release()

より高精度な顔検出(dlib)

Haar Cascadeで精度が出ない場合は、dlibを試してみましょう。

pip install dlib
import dlib

class PersonDetectorDlib:
    def __init__(self):
        self.detector = dlib.get_frontal_face_detector()
        self.cap = cv2.VideoCapture(0)
    
    def detect_face(self):
        ret, frame = self.cap.read()
        if not ret:
            return {'present': False, 'error': 'カメラ読み取り失敗'}
        
        # RGB変換(dlibはRGB形式を期待)
        rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        
        # 顔検出
        faces = self.detector(rgb, 1)
        
        return {
            'timestamp': datetime.now().isoformat(),
            'present': len(faces) > 0,
            'face_count': len(faces)
        }

実装:統合システム

定期実行 + データベース保存

import sqlite3
import time
import threading

class WorkLogSystem:
    def __init__(self, db_path='work_log.db'):
        self.db_path = db_path
        self.screen_analyzer = ScreenAnalyzer()
        self.person_detector = PersonDetector()
        self.init_database()
    
    def init_database(self):
        """
        SQLiteデータベースの初期化
        """
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS work_logs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT NOT NULL,
                activity TEXT,
                present INTEGER,
                text_sample TEXT
            )
        ''')
        
        conn.commit()
        conn.close()
    
    def log_work(self):
        """
        作業ログの記録
        """
        # 画面解析
        screen_result = self.screen_analyzer.analyze()
        
        # 在席判定
        person_result = self.person_detector.detect_face()
        
        # データベース保存
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        cursor.execute('''
            INSERT INTO work_logs (timestamp, activity, present, text_sample)
            VALUES (?, ?, ?, ?)
        ''', (
            screen_result['timestamp'],
            screen_result['activity'],
            1 if person_result['present'] else 0,
            screen_result['text_sample']
        ))
        
        conn.commit()
        conn.close()
        
        print(f"[{screen_result['timestamp']}] "
              f"作業: {screen_result['activity']}, "
              f"在席: {'' if person_result['present'] else '×'}")
    
    def start_monitoring(self, interval=300):
        """
        定期監視の開始(デフォルト5分間隔)
        
        Args:
            interval: 記録間隔(秒)
        """
        print(f"監視開始({interval}秒間隔)")
        try:
            while True:
                self.log_work()
                time.sleep(interval)
        except KeyboardInterrupt:
            print("\n監視終了")
            self.person_detector.release()
    
    def get_summary(self, date=None):
        """
        作業サマリーの取得
        """
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        if date:
            cursor.execute('''
                SELECT activity, COUNT(*) as count
                FROM work_logs
                WHERE date(timestamp) = ?
                GROUP BY activity
            ''', (date,))
        else:
            cursor.execute('''
                SELECT activity, COUNT(*) as count
                FROM work_logs
                GROUP BY activity
            ''')
        
        results = cursor.fetchall()
        conn.close()
        
        return results

# 使用例
if __name__ == '__main__':
    system = WorkLogSystem()
    
    # 5分間隔で監視開始
    system.start_monitoring(interval=300)
    
    # サマリー表示(別途実行)
    # summary = system.get_summary()
    # for activity, count in summary:
    #     print(f"{activity}: {count}回")

コスト比較:LMM vs OCR+OpenCV

料金試算(1日8時間、5分間隔で記録)

方式 1回あたり 1日(96回) 月額(20営業日)
GPT-4 Vision $0.01 $0.96 $19.2
OCR + OpenCV $0 $0 $0(電気代・サーバー代のみ)

圧倒的なコスト優位性!

精度比較

項目 LMM OCR + OpenCV
作業内容の詳細理解 ⭐⭐⭐⭐⭐ ⭐⭐⭐
テキスト抽出精度 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
在席判定 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
表情・集中度分析 ⭐⭐⭐⭐⭐ ⭐⭐

ハイブリッドアプローチの提案

最もコスト効率が良い方法:

class HybridAnalyzer:
    def __init__(self):
        self.ocr_analyzer = ScreenAnalyzer()
        self.confidence_threshold = 0.7
        self.llm_call_count = 0
        self.max_llm_calls_per_day = 10  # コスト制限
    
    def analyze_with_fallback(self):
        """
        通常はOCR、不明瞭な場合のみLMM
        """
        ocr_result = self.ocr_analyzer.analyze()
        
        # 「不明」判定が多い場合、LMMで再解析
        if (ocr_result['activity'] == '不明' and 
            self.llm_call_count < self.max_llm_calls_per_day):
            
            print("OCR判定不明 → LMMで再解析")
            # ここでLMM APIを呼び出す
            # llm_result = call_gpt4_vision(screenshot)
            self.llm_call_count += 1
            return llm_result
        
        return ocr_result

戦略:

  1. 通常時はOCR + OpenCVで処理(コスト $0)
  2. 判定が曖昧な時だけLMMに問い合わせ(月10回まで = $0.1)
  3. 総コスト:月額 $0.1 vs 純粋LMMの $19.2(99%削減!)

制約と課題

OCRの制約

  • 画像・グラフ・UIアイコンからの意味理解は困難
  • 文脈理解が必要な複雑な判定は精度が低い
  • 日本語OCRは環境によって精度にばらつきがある

OpenCVの制約

  • 表情や集中度の詳細分析は困難
  • 照明条件に敏感(暗い環境では検出率低下)
  • Haar Cascadeは横顔や角度のある顔の検出が苦手

改善案

  1. MediaPipeで姿勢推定を追加(前のめり度で集中度を推定)
  2. アクティブウィンドウ取得でより正確な作業分類
  3. キーボード・マウスの操作ログと組み合わせて精度向上

プライバシー配慮

実装すべき機能

class PrivacyFilter:
    def __init__(self):
        self.sensitive_keywords = [
            'password', 'credit card', '口座番号', 'マイナンバー'
        ]
    
    def mask_sensitive_data(self, text):
        """
        機密情報のマスキング
        """
        for keyword in self.sensitive_keywords:
            if keyword in text.lower():
                return '[機密情報を含むため非表示]'
        return text
    
    def blur_face(self, frame, faces):
        """
        顔部分をぼかし処理
        """
        for (x, y, w, h) in faces:
            face_region = frame[y:y+h, x:x+w]
            blurred = cv2.GaussianBlur(face_region, (99, 99), 30)
            frame[y:y+h, x:x+w] = blurred
        return frame

運用ルール

  • データ保存期間の制限(例:7日間)
  • 監視の一時停止機能
  • 従業員への明示的な同意取得
  • ローカル処理(クラウドアップロードなし)

まとめ

本記事では、LMMに頼らず、OCR + OpenCVで低コストな作業ログシステムを構築する方法を紹介しました。

メリット

  • 圧倒的な低コスト(月額 $0 vs LMMの $19.2)
  • オフライン動作可能(プライバシー保護)
  • カスタマイズ性が高い

デメリット

  • LMMに比べて精度は劣る
  • 複雑な文脈理解は困難

推奨アプローチ

ハイブリッド方式で、99%のコスト削減と高精度を両立させましょう!

参考リンク


次のステップ:

  • MediaPipeでの姿勢推定追加
  • Dashboardの作成(Flask + Chart.js)
  • 機械学習モデルでの作業分類精度向上

ぜひ、あなたの環境でも試してみてください!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?