↪️

【Replit Agent】往復通勤時間に自動画像回転補正ツールを作ってみた

2024/12/17に公開

はじめに

AI OCRなどの画像やPDFを扱うプロジェクトをやっていると、ままもって出てくる問題に「この画像は傾いているので、回転しないと、正しく読み込めない」というものがあります。
この問題を解決できるツールをPythonで作ってみたことがあるのですが、同じことがReplit Agentで自然言語だけでできるのか?に今回は往復の通勤時間(約40分)に挑戦してみようと思います。

もしReplit Agentについてご存知ない方は、以下Replit Agentの内容をChatGPTに3行でまとめてもらいましたので、ご参照ください。
※1:Replit Agentとは?

今回の目標

今回の目標は以下のような画像の帳票を自動で傾き補正するツールです。

1:傾きが強く、かつ、途中で途切れてしまっている帳票
 

2:90度回転してしまっている帳票
 

3:前後に傾いている、かつ、横にも傾いている帳票

※各帳票は画像検索し利用しているため、実際の事業者とは関係ありません。

これらを傾き補正を自動でしてくれるツールを作ってみます。

プロンプト

最初に書いたプロンプトは以下の通りです。

・PDFやイメージファイルをアップロードして、傾きがある場合には自動で回転補正するシステムを作りたいです。
・アップロードする想定のファイルは、日本語のファイルです。
・ファイルの傾きは-180〜180度まで、すべての角度があり得ます。ファイルの画像を読み取って回転する必要があります。
・回転が正しくなされなかった場合、傾き補正を手動でできる機能もつけてください

このプロンプトだけでは、エラーが発生したり、ボタンが動かなかったりしたので、ガチャガチャと自然言語だけ、コードは一切書かずに修正をしていきました。

最終成果物

そうしてできた最終成果物が以下のようなものです。

画像アップロード

早速画像をアップロードしてみます。
上記ファイルの1番目のファイルを選んで、アップロードボタンを押下!以下のような画面で少し待ちます。

すると、、、

途切れた帳票だったので、少し線は気になりますが、見事に補正してくれました。

手動補正

このアップロードを行うと、手動補正のボタンが登場し、以下のようなメニューが出てきます。

2番の帳票をアップロードすると自動補正はしましたが、少し傾きが残りました。

そこで、この手動補正を使い、補正をします。スライダーを操作して、-5度ほどで、ちょうどいい塩梅になりました。

前後傾き補正

さて最後に3番の帳票をアップロードします。
これは品質が悪かったので、やはり自動補正は全くされず、変な方向に反転しました。残念。

今回のこの帳票は前後にも傾いているので、横の傾きを直しつつ、前後も直すと、なんとなく補正ができました。(私のセンスがあまりない)

ダウンロード

もちろんこうした補正した帳票をダウンロードするためのボタンもあり、補正後の帳票を見事にダウンロードすることができました。

自動傾き補正のために何をしているのか?

さて、ここまで傾き補正をしてきましたが、やはり気になるのは中で何をしてるのか?ですよね。
それも、Replit Agentに確認すると、以下の回答でした。

テキスト検出による方法

・OCRエンジン(Tesseract)を使用して文字列を検出
・日本語と英語の両方の文字を認識
・検出された文字列の角度から画像の傾きを計算

エッジ検出による方法:

・Cannyエッジ検出を使用して画像の輪郭を検出
・Sobelフィルタも併用して、より正確なエッジを検出
・検出されたエッジから直線を抽出し、その角度を分析

画像の前処理:

・グレースケール変換
 ・コントラスト強調(CLAHE法使用)
 ・複数の閾値処理を適用

角度検出:

・複数のスケール(0.5倍、1.0倍、1.5倍)で処理
 ・テキストとエッジの両方の方法で角度を検出
 ・外れ値を除去し、中央値を採用

スナップ機能:

・一般的な角度(0度、90度、180度、270度)に近い場合は自動補正
 ・15度単位の角度にも自動スナップ機能あり

はいはい、やるかもねって感じの内容ですね。

参考コード

ざっとみた感じ以下の箇所で200行程度のコードで書いてありそうでした。

import cv2
import numpy as np
import pytesseract
from PIL import Image
import logging

logger = logging.getLogger(__name__)

def detect_rotation(image):
    """Detect rotation angle of image using multiple methods"""
    if image is None:
        logger.error("Input image is None")
        return 0

    try:
        height, width = image.shape[:2]
        angles = []

        if height == 0 or width == 0:
            logger.error("Invalid image dimensions")
            return 0

        def detect_text_angle(img):
            """Detect text angle using OCR"""
            try:
                # Convert to grayscale
                gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
                
                # Multiple contrast enhancement methods
                clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8,8))
                enhanced = clahe.apply(gray)
                
                # Additional contrast stretching
                enhanced = cv2.normalize(enhanced, None, 0, 255, cv2.NORM_MINMAX)
                
                # Multiple threshold versions
                local_thresh_methods = [
                    # Adaptive threshold with different parameters
                    cv2.adaptiveThreshold(enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2),
                    cv2.adaptiveThreshold(enhanced, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 15, 5),
                    # Otsu's thresholding
                    cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
                ]
                
                text_angles = []
                for thresh in local_thresh_methods:
                    try:
                        # Try Japanese text detection
                        osd = pytesseract.image_to_osd(thresh, config='--psm 0 -l jpn')
                        angle = int(osd.split('\n')[2].split(':')[1].strip())
                        text_angles.append(angle)
                    except Exception as e:
                        logger.debug(f"Japanese text detection failed: {str(e)}")
                        try:
                            # Try default text detection
                            osd = pytesseract.image_to_osd(thresh)
                            angle = int(osd.split('\n')[2].split(':')[1].strip())
                            text_angles.append(angle)
                        except Exception as e:
                            logger.debug(f"Default text detection failed: {str(e)}")
                            continue
                
                return text_angles
            except Exception as e:
                logger.debug(f"Text angle detection failed: {str(e)}")
                return []

        # Try text detection at multiple scales
        scales = [0.5, 1.0, 1.5]
        for scale in scales:
            try:
                if scale != 1.0:
                    scaled = cv2.resize(image, None, fx=scale, fy=scale)
                else:
                    scaled = image
                angles.extend(detect_text_angle(scaled))
            except Exception as e:
                logger.debug(f"Scale {scale} processing failed: {str(e)}")
                continue

        # Method 2: Edge-based detection
        try:
            # Convert to grayscale
            gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
            
            # Apply multiple edge detection methods
            edges_list = []
            
            # Canny edges
            edges1 = cv2.Canny(gray, 50, 150, apertureSize=3)
            edges_list.append(edges1)
            
            # Sobel edges
            sobelx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
            sobely = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
            edges2 = np.uint8(np.sqrt(sobelx**2 + sobely**2))
            edges_list.append(edges2)
            
            for edges in edges_list:
                lines = cv2.HoughLinesP(edges, 1, np.pi/180, 
                                    threshold=100, 
                                    minLineLength=min(height, width) * 0.1,
                                    maxLineGap=10)
                
                if lines is not None:
                    for line in lines:
                        x1, y1, x2, y2 = line[0]
                        if x2 - x1 == 0:  # Vertical line
                            angle = 90
                        else:
                            angle = np.degrees(np.arctan2(y2 - y1, x2 - x1))
                        # Normalize angle to [-90, 90]
                        while angle < -90: angle += 180
                        while angle > 90: angle -= 180
                        angles.append(angle)
        
        except Exception as e:
            logger.debug(f"Edge-based detection failed: {str(e)}")

        if not angles:
            logger.debug("No angles detected, returning 0")
            return 0

        # Advanced angle selection
        angles = np.array(angles)
        
        # Remove outliers using IQR method
        q1, q3 = np.percentile(angles, [25, 75])
        iqr = q3 - q1
        angles = angles[((angles >= q1 - 1.5 * iqr) & (angles <= q3 + 1.5 * iqr))]
        
        if len(angles) == 0:
            logger.debug("No valid angles after outlier removal")
            return 0
        
        # Calculate the median angle
        median_angle = np.median(angles)
        logger.debug(f"Median angle before snapping: {median_angle}")
        
        # Snap to common angles if close
        common_angles = [0, 90, 180, 270, -90, -180, -270]
        for common in common_angles:
            if abs(median_angle - common) < 5:
                logger.debug(f"Snapped to common angle: {common}")
                return common
        
        # Snap to 15-degree increments if close
        angle_15 = round(median_angle / 15) * 15
        if abs(median_angle - angle_15) < 5:
            logger.debug(f"Snapped to 15-degree increment: {angle_15}")
            return angle_15
        
        logger.debug(f"Final angle: {median_angle}")
        return median_angle

    except Exception as e:
        logger.error(f"Error in detect_rotation: {str(e)}")
        return 0

def correct_image(image, angle):
    """Rotate image by given angle with enhanced quality"""
    try:
        if image is None:
            logger.error("Input image is None")
            return None

        if len(image.shape) < 2:
            logger.error("Invalid image format")
            return None

        if abs(angle) < 0.1:  # Skip rotation for very small angles
            return image
        
        (h, w) = image.shape[:2]
        center = (w // 2, h // 2)
        
        # Calculate new dimensions to avoid cropping
        angle_rad = np.abs(np.radians(angle))
        new_w = int(h * np.abs(np.sin(angle_rad)) + w * np.abs(np.cos(angle_rad)))
        new_h = int(h * np.abs(np.cos(angle_rad)) + w * np.abs(np.sin(angle_rad)))
        
        # Adjust transformation matrix to prevent cropping
        M = cv2.getRotationMatrix2D(center, angle, 1.0)
        M[0, 2] += (new_w - w) / 2
        M[1, 2] += (new_h - h) / 2
        
        # Use different interpolation methods based on angle
        if abs(angle) in [90, 180, 270]:
            # Use NEAREST for right angles to prevent artifacts
            interpolation = cv2.INTER_NEAREST
        else:
            # Use CUBIC for other angles
            interpolation = cv2.INTER_CUBIC
        
        # Perform rotation with border replication
        rotated = cv2.warpAffine(image, M, (new_w, new_h),
                                flags=interpolation,
                                borderMode=cv2.BORDER_REPLICATE)
        
        return rotated
        
    except Exception as e:
        logger.error(f"Error in correct_image: {str(e)}")
        return image

おわりに

いかがでしたか?
今回は、たまに使いたいツールを作りました。
ここまでにかかった時間は通勤時間40分ほどですし、ほぼ本を読んでReplit Agentのことを待ってただけなので、実工数としては15-20分くらいです。
かつ、これにかかった、お金は6 USドル(有料会員クレジットの範囲内)でしたので、新米エンジニアがサクッと作るよりも安価に早く作れてる感じがしました。

サクッと目的を果たす程度のツールを作るには、業務しか知らない方でも、Replit Agentでもできてしまう時代になると思うと、なかなかすごい時代だなと、しみじみ感じました。

Accenture Japan (有志)

Discussion