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?

テスト投稿

Last updated at Posted at 2025-12-18
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
赤文字・黒文字 OCR ツール
商用利用可能なライブラリのみ使用
- tkinter: Python標準 (PSF License)
- OpenCV: Apache 2.0 / BSD
- EasyOCR: Apache 2.0
- NumPy: BSD
- Pillow: HPND License (商用可)
"""

import tkinter as tk
from tkinter import filedialog, ttk, messagebox
import cv2
import numpy as np
import easyocr
import os
import re
import threading
from PIL import Image, ImageTk
from dataclasses import dataclass
from typing import List, Tuple, Optional
import time
import traceback


# =============================================================================
# 設定クラス(簡単にカスタマイズ可能)
# =============================================================================
@dataclass
class ColorConfig:
    """色検出の設定"""
    name: str
    hsv_lower: Tuple[int, int, int]
    hsv_upper: Tuple[int, int, int]


# デフォルト設定(必要に応じて調整可能)
# 黒文字検出のポイント:
#   - 白背景(V値が高い)に対して、黒文字(V値が低い)を検出
#   - 白文字は除外される
COLOR_CONFIGS = {
    "red": ColorConfig(
        name="赤",
        hsv_lower=(0, 120, 120),
        hsv_upper=(10, 255, 255)
    ),
    "red2": ColorConfig(
        name="赤(高色相)",
        hsv_lower=(170, 120, 120),
        hsv_upper=(180, 255, 255)
    ),
    "black": ColorConfig(
        name="黒",
        hsv_lower=(0, 0, 0),
        hsv_upper=(180, 50, 100)  # S値50以下(彩度低)、V値100以下(暗い文字のみ)
    ),
}

# 単位パターン(簡単に追加可能)
# 新しい単位を追加する場合はここにパターンを追加してください
UNIT_PATTERNS = [
    r'um',    # マイクロメートル
    r'μm',    # マイクロメートル(記号)
    r'mm',    # ミリメートル
    r'cm',    # センチメートル
    r'nm',    # ナノメートル
    r'px',    # ピクセル
    r'm',     # メートル(最後に配置:他とマッチしないように)
]


# =============================================================================
# ユーティリティ関数
# =============================================================================
def imread_japanese_path(image_path: str, flags: int = cv2.IMREAD_UNCHANGED) -> Optional[np.ndarray]:
    """
    日本語パスに対応した画像読み込み関数
    OpenCVのimreadは日本語パスに対応していないため、NumPyで読み込む
    """
    try:
        # NumPyでバイナリ読み込み → OpenCVでデコード
        with open(image_path, 'rb') as f:
            data = np.frombuffer(f.read(), dtype=np.uint8)
        image = cv2.imdecode(data, flags)
        return image
    except Exception as e:
        raise IOError(f"画像の読み込みに失敗しました: {image_path}\n{str(e)}")


# =============================================================================
# OCR結果データクラス
# =============================================================================
@dataclass
class OCRResult:
    """OCR結果を格納"""
    filename: str
    value: str
    unit: str
    confidence: float
    raw_text: str
    bbox: Tuple[int, int, int, int]  # x1, y1, x2, y2
    image_path: str
    is_error: bool = False  # エラーフラグ
    error_message: str = ""  # エラーメッセージ


# =============================================================================
# OCRエンジンクラス
# =============================================================================
class ColorOCREngine:
    """色別OCRエンジン"""
    
    def __init__(self):
        self.reader = None
        self._init_lock = threading.Lock()
    
    def _ensure_reader(self):
        """EasyOCRリーダーの遅延初期化"""
        if self.reader is None:
            with self._init_lock:
                if self.reader is None:
                    # 英数字のみなので'en'のみ
                    self.reader = easyocr.Reader(['en'], gpu=False)
    
    def extract_color_mask(self, image: np.ndarray, color_key: str) -> np.ndarray:
        """指定色のマスクを抽出"""
        
        if color_key == "red":
            # 赤は色相が0付近と180付近の両方にまたがる
            hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
            config1 = COLOR_CONFIGS["red"]
            config2 = COLOR_CONFIGS["red2"]
            mask1 = cv2.inRange(hsv, np.array(config1.hsv_lower), np.array(config1.hsv_upper))
            mask2 = cv2.inRange(hsv, np.array(config2.hsv_lower), np.array(config2.hsv_upper))
            mask = cv2.bitwise_or(mask1, mask2)
            
        elif color_key == "black":
            # 黒文字検出:輝度ベースで暗いピクセルを抽出
            # グレースケール変換
            gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
            
            # 暗いピクセル(黒文字)を抽出(輝度が低いもの)
            # 閾値以下を白(255)、それ以外を黒(0)に
            # THRESH_BINARY_INV: 閾値以下が白になる(黒文字を抽出)
            _, mask = cv2.threshold(gray, 80, 255, cv2.THRESH_BINARY_INV)
            
        else:
            return np.zeros(image.shape[:2], dtype=np.uint8)
        
        # ノイズ除去
        kernel = np.ones((2, 2), np.uint8)
        mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
        mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
        
        return mask
    
    def process_image(self, image_path: str, color_key: str, debug_save: bool = False) -> List[OCRResult]:
        """画像をOCR処理"""
        self._ensure_reader()
        
        filename = os.path.basename(image_path)
        
        try:
            # 日本語パス対応で画像読み込み
            image = imread_japanese_path(image_path, cv2.IMREAD_UNCHANGED)
            if image is None:
                return [OCRResult(
                    filename=filename,
                    value="",
                    unit="",
                    confidence=0.0,
                    raw_text="",
                    bbox=(0, 0, 0, 0),
                    image_path=image_path,
                    is_error=True,
                    error_message="画像の読み込みに失敗しました(ファイル形式を確認してください)"
                )]
            
            # アルファチャンネルがある場合は白背景に合成
            if len(image.shape) == 3 and image.shape[2] == 4:
                alpha = image[:, :, 3] / 255.0
                white_bg = np.ones_like(image[:, :, :3], dtype=np.uint8) * 255
                for c in range(3):
                    image[:, :, c] = (image[:, :, c] * alpha + white_bg[:, :, c] * (1 - alpha)).astype(np.uint8)
                image = image[:, :, :3]
            
            # BGR形式に変換(グレースケール対応)
            if len(image.shape) == 2:
                image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
            
            # 黒文字モード: HSVで黒(明度が低い)を検出、白文字とその周辺は除外
            if color_key == "black":
                # HSV色空間に変換
                hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
                
                # 黒文字の特徴: 明度が低い(暗い)
                # V: 0-120 (明度が低い = 黒〜暗いグレー)
                black_mask = cv2.inRange(hsv, np.array([0, 0, 0]), np.array([180, 255, 120]))
                
                # 白文字を検出: 明度が高い部分(V > 200)
                white_mask = cv2.inRange(hsv, np.array([0, 0, 200]), np.array([180, 255, 255]))
                
                # 白文字マスクを膨張させて、周囲の暗い縁も含める(小さめ)
                dilate_kernel = np.ones((5, 5), np.uint8)
                white_mask_dilated = cv2.dilate(white_mask, dilate_kernel, iterations=1)
                
                # 黒文字マスクから膨張した白文字領域を除外
                mask = cv2.bitwise_and(black_mask, cv2.bitwise_not(white_mask_dilated))
                
                # 軽めのノイズ除去(文字が消えないように)
                kernel = np.ones((1, 1), np.uint8)
                mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
                
                # 白背景に黒文字を配置(赤文字と同じ方式)
                result_image = np.ones_like(image, dtype=np.uint8) * 255
                result_image[mask > 0] = image[mask > 0]
            else:
                # 赤文字の場合は従来のマスク処理
                mask = self.extract_color_mask(image, color_key)
                result_image = np.ones_like(image, dtype=np.uint8) * 255
                result_image[mask > 0] = image[mask > 0]
            
            # OCR実行
            results = self.reader.readtext(result_image)
            
            # 近接するテキストを結合
            merged_results = self._merge_nearby_texts(results)
            
            ocr_results = []
            
            for bbox, text, confidence in merged_results:
                # バウンディングボックスを整数座標に変換
                pts = np.array(bbox, dtype=np.int32)
                x1, y1 = pts.min(axis=0)
                x2, y2 = pts.max(axis=0)
                
                # 数値と単位を抽出
                parsed = self._parse_measurement(text)
                if parsed:
                    value, unit = parsed
                    ocr_results.append(OCRResult(
                        filename=filename,
                        value=value,
                        unit=unit,
                        confidence=confidence,
                        raw_text=text,
                        bbox=(x1, y1, x2, y2),
                        image_path=image_path
                    ))
            
            return ocr_results
            
        except Exception as e:
            # エラー情報を結果として返す
            error_msg = str(e)
            if len(error_msg) > 100:
                error_msg = error_msg[:100] + "..."
            return [OCRResult(
                filename=filename,
                value="",
                unit="",
                confidence=0.0,
                raw_text="",
                bbox=(0, 0, 0, 0),
                image_path=image_path,
                is_error=True,
                error_message=f"エラー: {error_msg}"
            )]
    
    def _merge_nearby_texts(self, results: List, distance_threshold: int = 50) -> List:
        """
        近接するテキストを結合する
        
        Args:
            results: EasyOCRの結果リスト [(bbox, text, confidence), ...]
            distance_threshold: 結合する最大距離(ピクセル)
        
        Returns:
            結合後の結果リスト
        """
        if not results:
            return []
        
        # 結果をコピー
        items = []
        for bbox, text, confidence in results:
            pts = np.array(bbox, dtype=np.int32)
            x1, y1 = pts.min(axis=0)
            x2, y2 = pts.max(axis=0)
            items.append({
                'bbox': bbox,
                'text': text,
                'confidence': confidence,
                'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2,
                'merged': False
            })
        
        # X座標でソート(左から右へ)
        items.sort(key=lambda x: x['x1'])
        
        merged_results = []
        
        for i, item in enumerate(items):
            if item['merged']:
                continue
            
            # 現在のアイテムを基準に近接アイテムを探す
            current_text = item['text']
            current_confidence = item['confidence']
            current_x1, current_y1 = item['x1'], item['y1']
            current_x2, current_y2 = item['x2'], item['y2']
            merge_count = 1
            
            # 後続のアイテムをチェック
            for j in range(i + 1, len(items)):
                other = items[j]
                if other['merged']:
                    continue
                
                # Y座標が近い(同じ行)かつX座標が近い場合に結合
                y_overlap = not (other['y2'] < current_y1 - 10 or other['y1'] > current_y2 + 10)
                x_distance = other['x1'] - current_x2
                
                if y_overlap and 0 <= x_distance <= distance_threshold:
                    # 結合
                    current_text += other['text']
                    current_confidence = (current_confidence * merge_count + other['confidence']) / (merge_count + 1)
                    current_x2 = max(current_x2, other['x2'])
                    current_y1 = min(current_y1, other['y1'])
                    current_y2 = max(current_y2, other['y2'])
                    other['merged'] = True
                    merge_count += 1
            
            # 結合後のbboxを作成
            merged_bbox = [[current_x1, current_y1], [current_x2, current_y1],
                          [current_x2, current_y2], [current_x1, current_y2]]
            
            merged_results.append((merged_bbox, current_text, current_confidence))
        
        return merged_results
    
    def _parse_measurement(self, text: str) -> Optional[Tuple[str, str]]:
        """テキストから数値と単位を抽出
        
        対応パターン:
        - "123um" → ("123", "um")
        - "123 um" → ("123", "um")
        - "X = 123um" → ("123", "um")
        - "X=123.5mm" → ("123.5", "mm")
        - "値: 123 um" → ("123", "um")
        """
        # 単位パターンを結合(長いものから優先)
        sorted_patterns = sorted(UNIT_PATTERNS, key=len, reverse=True)
        unit_pattern = '|'.join(sorted_patterns)
        
        # 数値+単位のパターンを抽出(前に何があっても最後の数値+単位を取得)
        # 「=」や「:」や空白の後に続く数値+単位にもマッチ
        pattern = rf'(\d+(?:\.\d+)?)\s*({unit_pattern})\b'
        
        matches = re.findall(pattern, text, re.IGNORECASE)
        if matches:
            # 最後にマッチしたものを使用("X = 123um"の場合、123umを取得)
            value, unit = matches[-1]
            return value, unit.lower()
        
        # 単位なしの数値のみの場合(最後の数値を取得)
        num_matches = re.findall(r'(\d+(?:\.\d+)?)', text)
        if num_matches:
            return num_matches[-1], ""
        
        return None


# =============================================================================
# メインGUIアプリケーション
# =============================================================================
class OCRApplication:
    """OCR GUIアプリケーション"""
    
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("赤文字・黒文字 OCR ツール")
        self.root.geometry("1200x600")
        self.root.minsize(800, 500)
        
        self.engine = ColorOCREngine()
        self.results: List[OCRResult] = []
        self.image_paths: List[str] = []
        self.processing = False
        self.start_time = 0
        
        self._setup_ui()
        self._setup_bindings()
    
    def _setup_ui(self):
        """UI構築"""
        # メインフレーム
        main_frame = ttk.Frame(self.root, padding="10")
        main_frame.pack(fill=tk.BOTH, expand=True)
        
        # === コントロールパネル ===
        control_frame = ttk.Frame(main_frame)
        control_frame.pack(fill=tk.X, pady=(0, 10))
        
        # 画像選択ボタン
        self.btn_select = ttk.Button(
            control_frame, text="画像選択", command=self._select_images
        )
        self.btn_select.pack(side=tk.LEFT, padx=(0, 10))
        
        # 選択ファイル数表示
        self.file_count_label = ttk.Label(control_frame, text="(0件選択)")
        self.file_count_label.pack(side=tk.LEFT, padx=(0, 20))
        
        # 色選択
        ttk.Label(control_frame, text="色選択:").pack(side=tk.LEFT, padx=(10, 5))
        self.color_var = tk.StringVar(value="red")
        self.color_combo = ttk.Combobox(
            control_frame, 
            textvariable=self.color_var,
            values=["red", "black", "both"],
            state="readonly",
            width=10
        )
        self.color_combo.pack(side=tk.LEFT, padx=(0, 10))
        
        # OCR実行ボタン
        self.btn_execute = ttk.Button(
            control_frame, text="OCR実行", command=self._start_ocr
        )
        self.btn_execute.pack(side=tk.LEFT, padx=(0, 10))
        
        # コピーボタン
        self.btn_copy_selected = ttk.Button(
            control_frame, text="選択行をTSVコピー", command=self._copy_selected_tsv
        )
        self.btn_copy_selected.pack(side=tk.LEFT, padx=(0, 5))
        
        self.btn_copy_all = ttk.Button(
            control_frame, text="全行をTSVコピー", command=self._copy_all_tsv
        )
        self.btn_copy_all.pack(side=tk.LEFT, padx=(0, 10))
        
        # === ステータスバー ===
        status_frame = ttk.Frame(main_frame)
        status_frame.pack(fill=tk.X, pady=(0, 10))
        
        self.status_label = ttk.Label(status_frame, text="OCR開始待ち...")
        self.status_label.pack(side=tk.LEFT)
        
        self.progress_var = tk.DoubleVar(value=0)
        self.progress_bar = ttk.Progressbar(
            status_frame, 
            variable=self.progress_var,
            maximum=100,
            length=200
        )
        self.progress_bar.pack(side=tk.LEFT, padx=(20, 0))
        
        self.time_label = ttk.Label(status_frame, text="")
        self.time_label.pack(side=tk.LEFT, padx=(20, 0))
        
        # === 結果テーブル ===
        table_container = ttk.Frame(main_frame)
        table_container.pack(fill=tk.BOTH, expand=True)
        
        # Treeview(コピー列は除く - ボタンは別で配置)
        columns = ("status", "filename", "value", "unit", "confidence", "raw_text")
        self.tree = ttk.Treeview(
            table_container, 
            columns=columns, 
            show="headings",
            selectmode="extended"
        )
        
        # カラム設定
        self.tree.heading("status", text="状態")
        self.tree.heading("filename", text="ファイル名")
        self.tree.heading("value", text="数値")
        self.tree.heading("unit", text="単位")
        self.tree.heading("confidence", text="確度")
        self.tree.heading("raw_text", text="抽出文字/エラー")
        
        self.tree.column("status", width=60, minwidth=50)
        self.tree.column("filename", width=200, minwidth=100)
        self.tree.column("value", width=100, minwidth=60)
        self.tree.column("unit", width=80, minwidth=50)
        self.tree.column("confidence", width=80, minwidth=60)
        self.tree.column("raw_text", width=250, minwidth=100)
        
        # エラー行のスタイル設定
        self.tree.tag_configure("error", foreground="red")
        self.tree.tag_configure("success", foreground="black")
        
        # スクロールバー
        scrollbar_y = ttk.Scrollbar(table_container, orient=tk.VERTICAL, command=self.tree.yview)
        self.tree.configure(yscrollcommand=scrollbar_y.set)
        
        # 配置
        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scrollbar_y.pack(side=tk.RIGHT, fill=tk.Y)
        
        # コンテキストメニュー(右クリック)
        self.context_menu = tk.Menu(self.root, tearoff=0)
        self.context_menu.add_command(label="この行をコピー", command=self._copy_row)
        self.context_menu.add_command(label="プレビュー表示", command=self._show_preview)
        
        # === プレビューエリア(下部、折りたたみ可能) ===
        self.preview_frame = ttk.LabelFrame(main_frame, text="プレビュー", padding="5")
        # 初期は非表示
        
        # プレビューヘッダー(タイトルとバツボタン)
        preview_header = ttk.Frame(self.preview_frame)
        preview_header.pack(fill=tk.X, pady=(0, 5))
        
        # バツボタン(右上)
        self.btn_close_preview = ttk.Button(
            preview_header, text="✕", width=3, command=self._hide_preview
        )
        self.btn_close_preview.pack(side=tk.RIGHT)
        
        ttk.Label(preview_header, text="検出箇所を緑枠でハイライト表示").pack(side=tk.LEFT)
        
        # プレビューキャンバス(スクロール対応)
        preview_container = ttk.Frame(self.preview_frame)
        preview_container.pack(fill=tk.BOTH, expand=True)
        
        # キャンバスとスクロールバー
        self.preview_canvas = tk.Canvas(preview_container, height=200, bg="white")
        preview_scrollbar_y = ttk.Scrollbar(preview_container, orient=tk.VERTICAL, command=self.preview_canvas.yview)
        preview_scrollbar_x = ttk.Scrollbar(preview_container, orient=tk.HORIZONTAL, command=self.preview_canvas.xview)
        
        self.preview_canvas.configure(yscrollcommand=preview_scrollbar_y.set, xscrollcommand=preview_scrollbar_x.set)
        
        # 配置
        self.preview_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        preview_scrollbar_y.pack(side=tk.RIGHT, fill=tk.Y)
        preview_scrollbar_x.pack(side=tk.BOTTOM, fill=tk.X)
        
        self.preview_image = None  # 参照保持用
    
    def _setup_bindings(self):
        """イベントバインディング"""
        self.tree.bind("<Button-3>", self._show_context_menu)  # 右クリック
        self.tree.bind("<Double-1>", self._on_double_click)  # ダブルクリック
    
    def _select_images(self):
        """画像ファイル選択"""
        filetypes = [
            ("画像ファイル", "*.png *.jpg *.jpeg *.bmp *.tiff *.gif"),
            ("PNG", "*.png"),
            ("JPEG", "*.jpg *.jpeg"),
            ("すべて", "*.*")
        ]
        
        paths = filedialog.askopenfilenames(
            title="画像を選択",
            filetypes=filetypes
        )
        
        if paths:
            self.image_paths = list(paths)
            self.file_count_label.config(text=f"({len(self.image_paths)}件選択)")
            self.status_label.config(text=f"{len(self.image_paths)}件の画像を選択しました")
    
    def _start_ocr(self):
        """OCR処理開始"""
        if not self.image_paths:
            messagebox.showwarning("警告", "画像を選択してください")
            return
        
        if self.processing:
            messagebox.showinfo("情報", "処理中です")
            return
        
        self.processing = True
        self.start_time = time.time()
        self.results.clear()
        
        # テーブルクリア
        for item in self.tree.get_children():
            self.tree.delete(item)
        
        # プレビュー非表示
        self._hide_preview()
        
        # ボタン無効化
        self.btn_execute.config(state="disabled")
        self.btn_select.config(state="disabled")
        
        # 別スレッドで実行
        thread = threading.Thread(target=self._process_ocr, daemon=True)
        thread.start()
        
        # タイマー更新開始
        self._update_timer()
    
    def _process_ocr(self):
        """OCR処理(別スレッド)"""
        total = len(self.image_paths)
        color = self.color_var.get()
        
        for i, path in enumerate(self.image_paths):
            try:
                if color == "both":
                    results_red = self.engine.process_image(path, "red")
                    results_black = self.engine.process_image(path, "black")
                    results = results_red + results_black
                else:
                    results = self.engine.process_image(path, color)
                
                self.results.extend(results)
                
                # UI更新(メインスレッド)
                self.root.after(0, lambda r=results: self._add_results_to_tree(r))
                
            except Exception as e:
                print(f"Error processing {path}: {e}")
            
            # 進捗更新
            progress = (i + 1) / total * 100
            self.root.after(0, lambda p=progress, c=i+1, t=total: self._update_progress(p, c, t))
        
        self.root.after(0, self._ocr_complete)
    
    def _add_results_to_tree(self, results: List[OCRResult]):
        """結果をテーブルに追加"""
        for r in results:
            if r.is_error:
                # エラー行
                self.tree.insert("", tk.END, values=(
                    "エラー",
                    r.filename,
                    "",
                    "",
                    "",
                    r.error_message
                ), tags=("error",))
            else:
                # 正常行
                self.tree.insert("", tk.END, values=(
                    "OK",
                    r.filename,
                    r.value,
                    r.unit,
                    f"{r.confidence:.2f}",
                    r.raw_text
                ), tags=("success",))
    
    def _update_progress(self, progress: float, current: int, total: int):
        """進捗更新"""
        self.progress_var.set(progress)
        self.status_label.config(text=f"OCR実行中 {current}/{total} 件完了")
    
    def _update_timer(self):
        """経過時間更新"""
        if self.processing:
            elapsed = time.time() - self.start_time
            self.time_label.config(text=f"経過: {elapsed:.1f}秒")
            self.root.after(100, self._update_timer)
    
    def _ocr_complete(self):
        """OCR完了"""
        self.processing = False
        elapsed = time.time() - self.start_time
        
        # エラー件数をカウント
        error_count = sum(1 for r in self.results if r.is_error)
        success_count = len(self.results) - error_count
        
        if error_count > 0:
            self.status_label.config(text=f"OCR完了 - 成功: {success_count}件, エラー: {error_count}件")
        else:
            self.status_label.config(text=f"OCR完了 - {success_count}件検出")
        
        self.time_label.config(text=f"経過: {elapsed:.1f}秒")
        self.progress_var.set(100)
        
        # ボタン有効化
        self.btn_execute.config(state="normal")
        self.btn_select.config(state="normal")
    
    def _show_context_menu(self, event):
        """右クリックメニュー表示"""
        item = self.tree.identify_row(event.y)
        if item:
            self.tree.selection_set(item)
            self.context_menu.post(event.x_root, event.y_root)
    
    def _on_double_click(self, event):
        """ダブルクリックでプレビュー表示"""
        self._show_preview()
    
    def _copy_row(self):
        """選択行をクリップボードにコピー"""
        selection = self.tree.selection()
        if not selection:
            messagebox.showinfo("情報", "行を選択してください")
            return
        
        item = selection[0]
        values = self.tree.item(item, "values")
        
        # エラー行はコピーしない
        if values[0] == "エラー":
            self.status_label.config(text="エラー行はコピーできません")
            return
        
        # ファイル名, 数値, 単位 のみコピー
        tsv_line = "\t".join([values[1], values[2], values[3]])
        
        self.root.clipboard_clear()
        self.root.clipboard_append(tsv_line)
        self.root.update()
        
        # 視覚的フィードバック
        self.status_label.config(text=f"コピーしました: {values[1]}")
    
    def _copy_selected_tsv(self):
        """選択行をTSVコピー"""
        selection = self.tree.selection()
        if not selection:
            messagebox.showinfo("情報", "行を選択してください")
            return
        
        lines = []
        error_count = 0
        for item in selection:
            values = self.tree.item(item, "values")
            # エラー行はスキップ
            if values[0] == "エラー":
                error_count += 1
                continue
            # ファイル名, 数値, 単位 のみコピー
            lines.append("\t".join([values[1], values[2], values[3]]))
        
        if not lines:
            messagebox.showinfo("情報", "コピー可能な行がありません")
            return
        
        self.root.clipboard_clear()
        self.root.clipboard_append("\n".join(lines))
        self.root.update()
        
        msg = f"{len(lines)}行をコピーしました"
        if error_count > 0:
            msg += f"(エラー行{error_count}件をスキップ)"
        messagebox.showinfo("完了", msg)
    
    def _copy_all_tsv(self):
        """全行をTSVコピー"""
        items = self.tree.get_children()
        if not items:
            messagebox.showinfo("情報", "データがありません")
            return
        
        lines = []
        error_count = 0
        for item in items:
            values = self.tree.item(item, "values")
            # エラー行はスキップ
            if values[0] == "エラー":
                error_count += 1
                continue
            # ファイル名, 数値, 単位 のみコピー
            lines.append("\t".join([values[1], values[2], values[3]]))
        
        if not lines:
            messagebox.showinfo("情報", "コピー可能な行がありません")
            return
        
        self.root.clipboard_clear()
        self.root.clipboard_append("\n".join(lines))
        self.root.update()
        
        msg = f"{len(lines)}行をコピーしました"
        if error_count > 0:
            msg += f"(エラー行{error_count}件をスキップ)"
        messagebox.showinfo("完了", msg)
    
    def _show_preview(self):
        """プレビュー表示"""
        selection = self.tree.selection()
        if not selection:
            return
        
        item = selection[0]
        values = self.tree.item(item, "values")
        
        # エラー行はプレビューしない
        if values[0] == "エラー":
            self.status_label.config(text="エラー行はプレビューできません")
            return
        
        filename = values[1]
        raw_text = values[5]
        
        # 対応する結果を検索
        result = None
        for r in self.results:
            if r.filename == filename and r.raw_text == raw_text and not r.is_error:
                result = r
                break
        
        if not result:
            return
        
        try:
            # 日本語パス対応で画像読み込み
            image = imread_japanese_path(result.image_path, cv2.IMREAD_UNCHANGED)
            if image is None:
                self.status_label.config(text="プレビュー画像の読み込みに失敗しました")
                return
            
            # アルファチャンネル処理
            if len(image.shape) == 3 and image.shape[2] == 4:
                alpha = image[:, :, 3] / 255.0
                white_bg = np.ones_like(image[:, :, :3], dtype=np.uint8) * 255
                for c in range(3):
                    image[:, :, c] = (image[:, :, c] * alpha + white_bg[:, :, c] * (1 - alpha)).astype(np.uint8)
                image = image[:, :, :3]
            
            # バウンディングボックス描画(緑色)
            x1, y1, x2, y2 = result.bbox
            cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 2)
            
            # RGB変換
            image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
            
            # プレビューフレームを先に表示
            self.preview_frame.pack(fill=tk.X, pady=(10, 0))
            self.root.update_idletasks()
            
            # 画像サイズ取得
            h, w = image_rgb.shape[:2]
            
            # PIL Image -> PhotoImage(リサイズなし、スクロールで対応)
            pil_image = Image.fromarray(image_rgb)
            self.preview_image = ImageTk.PhotoImage(pil_image)
            
            # キャンバスに表示(スクロール領域を設定)
            self.preview_canvas.delete("all")
            self.preview_canvas.create_image(0, 0, image=self.preview_image, anchor=tk.NW)
            
            # スクロール領域を画像サイズに設定
            self.preview_canvas.configure(scrollregion=(0, 0, w, h))
            
        except Exception as e:
            self.status_label.config(text=f"プレビューエラー: {str(e)[:50]}")
    
    def _hide_preview(self):
        """プレビューを非表示"""
        self.preview_frame.pack_forget()


# =============================================================================
# エントリーポイント
# =============================================================================
def main():
    root = tk.Tk()
    
    # スタイル設定
    style = ttk.Style()
    try:
        style.theme_use("clam")
    except tk.TclError:
        pass  # デフォルトテーマを使用
    
    app = OCRApplication(root)
    root.mainloop()


if __name__ == "__main__":
    main()

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?