Pydantic을 활용 사례 | 웹 데이터 검증, Config 관리
Pydantic은 데이터의 유효성을 검증할 수 있는 대표적인 라이브러리입니다. 파이썬의 타입 힌트를 활용해 데이터의 유효성을 검사하고 오류 메시지를 제공하는데 사용됩니다. JSON과 쉽게 연동되기 때문에 FastAPI 같은 웹 프레임워크와 사용해 API의 입력 데이터와 출력 데이터를 검증하는 데 유용합니다. 일반적인 사용 사례는 다음과 같습니다.from pydantic import BaseModelclass User(BaseModel): name: str age: int# 유효한 데이터 생성user1 = User(name="John", age=30)print(user1)# 유효하지 않은 데이터 생성try: user2 = User(name="Jane", age="twenty")except ..
2024.12.14
파이썬 이미지 처리 라이브러리 비교 | PIL, OpenCV, Numpy, PyTorch, Albumentations
1. PIL (Pillow)Python Imaging Library(PIL)은 이미지 처리를 위한 오픈소스 라이브러리입니다. Pillow는 PIL의 확장된 버전으로 이미지 파일 입출력 및 기본적인 처리 작업을 지원합니다. 다양한 이미지 포맷(JPEG, PNG, BMP 등)을 지원하고, 크기 조정이나 회전 등 간단한 이미지 처리가 가능합니다. [1] PIL에서는 데이터를 PIL.Image 객체로 관리해 필요할 때마다 불러옵니다. 파이썬에서 많이 사용하는 형식인 NumPy와 변환을 위해서 아래와 같은 방법을 이용할 수 있습니다.  from PIL import Imageimport numpy as np# PIL → NumPyimage_pil = Image.open('image.jpg')image_np = np..
2024.11.18
no image
DeepL을 활용해서 외국어 OCR 영수증 데이터셋 합성하기
1. 도입1.1. 필요성직전에 영수증 이미지 데이터셋으로 COCO 데이터 형식의 OCR 데이터셋을 만들었습니다. 2024.11.07 - [Python/Data Prep] - CVAT로 Labeling 해서 영수증 OCR 데이터셋 만들기 CVAT로 Labeling 해서 영수증 OCR 데이터셋 만들기1. CVAT 소개딥러닝 프로젝트를 진행하려다 보면, 모델이 없다기 보다는 메모리의 한계이거나 데이터가 없어서 제한되는 경우가 많습니다. 여기서는 객체 탐지, 특히 OCR을 수행하기 위한 데이터seanpark11.tistory.com 이렇게 데이터셋을 만드는 것은 좋은 모델을 구축하기에 필요한 과정이지만, 시간과 비용이 많이 필요한 작업입니다. 따라서, 필요할 경우 직접 데이터셋을 합성하는 것도 고려해볼 수 있습..
2024.11.09
no image
[OD] 객체 탐지(Object Detection) 대표 데이터 포맷 공부 | COCO, Pascal VOC, YOLO
데이터 포맷주어진 이미지와 그에 해당하는 클래스가 대응되는 분류 문제와 다르게 객체 탐지 문제는 객체를 찾고(Localization), 이를 분류(Classification)해야 하는 두가지 일이 존재하기 때문에 데이터의 양식이 조금 더 복잡합니다. 특히, 하나의 이미지에서 여러 개의 객체가 존재할 수도 있기 때문에 더욱 어려운 문제가 될 수 있죠. 이러한 문제들을 극복하기 위해 데이터에 레이블을 붙이는 어노테이션(annotation)을 수행하게 되는데, 어노테이션 방식에 따라 데이터를 처리하는 방식이 달라져야 합니다. 가장 대표적인 객체 탐지 데이터 형식은 COCO, Pascal VOC, YOLO 등이 있습니다. COCOCOCO는 Common Objects in COntext의 약자로 해당 데이터셋은 ..
2024.10.04
DataLoader에서 오류가 난다면 누락 데이터가 있는지 확인 필요 | DataLoader는 이터레이터
1. Dataset와 DataLoader 1.1. Datasettorch.utils.data.Dataset은 데이터셋을 정의하는 기본 클래스입니다. 데이터를 메모리에서 가져오는 방법을 정의하며, 개발자가 자체적으로 데이터셋을 만드는데 사용됩니다. Dataset의 중요한 메서드는 __len__, __getitem__ 입니다. __len__은 데이터셋의 크기를 반환하고, __getitem__ 주어진 인덱스에 해당하는 데이터를 학습에 적합한 형태로 변환할 수 있습니다. from torch.utils.data import Datasetclass MyDataset(Dataset): def __init__(self, data): self.data = data def __len__(self):..
2024.09.26
no image
PyTorch에서 Dataset과 DataLoader 클래스를 활용해 데이터 파이프라인 구축하기
딥러닝을 위해 데이터를 불러오거나 전처리하는 방법을 매번 작성하는 것은 비효율적이고 반복적인 작업이 될 수 있습니다. PyTorch에서는 torch.utils.data를 통해 다양한 클래스를 제공하고 있으며, 제공된 클래스를 적절히 활용하면 효율적이고 유연한 데이터 파이프라인을 구축할 수 있습니다. Dataset torch.utils.data.Dataset은 키 -> 데이터 샘플로 매핑되는 모든 데이터셋을 표현하기 위해 Dataset을 상속받아 사용합니다. 데이터를 초기화하는 __init__ 메서드, 데이터 크기를 반환하는 __len__ 메서드, 특정 인덱스의 데이터 샘플을 반환할 수 있도록 하는 __getitems__ 메서드를 구현할 수 있습니다.   import torchfrom torch.utils..
2024.09.09
no image
Colab에서 Kaggle 데이터셋 가져오기 | Kaggle, API, Colab
데이터분석을 위해서 Kaggle을 많이 사용할텐데, 많이 사용하는 Colab나 Jupyter Notebook 환경에서 바로 다운로드를 받을 수 있는 방법에 대해 고민을 했고, 방법을 찾아서 정리하였다.  1. Kaggle API Token 다운로드Kaggle 홈페이지에서 프로필 사진 > Settings > Account 로 이동해서 API 항목으로 이동한다.  "Create New Token"을 눌러서 kaggle.json 파일 다운로드할 수 있다.  2. Colab 환경에서 Kaggle 접근아래 코드 입력하여 kaggle에 접근한다. (Colab의 cell에서는 한꺼번에 입력해도 작동) !pip install -q kaggle!mkdir -p ~/.kagglefrom google.colab import..
2024.08.10
no image
Selenium 을 활용한 Element 찾기 (find_element, By) | Python, Web Scraping, Web Crawling, 자동화
1. 소개 Selenium을 통해 페이지에 있는 요소를 찾는 것은 여러가지 방법이 있습니다. Selenium 에서는 By 클래스를 통해 다양한 속성으로 이용이 가능합니다.(By Strategy) 먼저 현재 사용하고 있는 Selenium 라이브러리의 버전은 다음과 같습니다. Version Selenium = 4.3.0. (향후 업데이트가 될 경우 아래 소개되는 내용은 이용이 불가능할 수 있으니 버전을 꼭 확인하시기 바랍니다) 전반적인 내용은 Selenium의 문서를 가져왔으며, 구성이나 번역은 제가 이해하기 쉽도록 바꾸었습니다. 원본에 대한 내용을 확인하고 싶으신 경우 아래 링크를 확인하시기 바랍니다. Selenium Python Docs 적용은 아래 예시처럼 find_element 메서드를 활용해 가능합..
2022.08.09
no image
Selenium을 활용한 지자체 선거 당선인 데이터 가져오기 | Web Scraping
우리나라는 선관위가 운영하는 선거통계시스템이라는 포털을 통해 역대선거의 통계에 대한 데이터를 제공한다. 최근에 지역별 지자체장들과 관련된 데이터가 필요한 일이 있어서 잠시 살펴봤다. 여기서 수작업으로 데이터를 모으기엔 다소 귀찮은 점이 있는데 아래와 같이 크게 두가지가 있다. 민선7기까지 선출된만큼 살펴봐야하는 횟수 자체가 적지 않다. 광역지자체장은 선거 횟수에 비례해 나오지만, 기초지자체의 경우 가장 최근인 민선7기의 경우 16개에 달해 경우의 수가 증가한다. 위 가정에 따라 계산을 해본다면, 7[광역지자체] + (15 * 6 + 16) [기초지자체] = 113회 정도 데이터를 조회해야 할 필요가 발생 http://info.nec.go.kr/ 아쉽게도 위 사이트는 동적웹페이지라 BeautifulSoup..
2021.10.16
반응형

Pydantic은 데이터의 유효성을 검증할 수 있는 대표적인 라이브러리입니다. 파이썬의 타입 힌트를 활용해 데이터의 유효성을 검사하고 오류 메시지를 제공하는데 사용됩니다. JSON과 쉽게 연동되기 때문에 FastAPI 같은 웹 프레임워크와 사용해 API의 입력 데이터와 출력 데이터를 검증하는 데 유용합니다.

 

일반적인 사용 사례는 다음과 같습니다.

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

# 유효한 데이터 생성
user1 = User(name="John", age=30)
print(user1)

# 유효하지 않은 데이터 생성
try:
    user2 = User(name="Jane", age="twenty")
except Exception as e:
    print(e)

 

위 코드에서 User 클래스는 pydantic의 BaseModel을 상속받아 name과 age 속성을 정의합니다. user1은 유효한 데이터를 생성하지만, user2는 age가 문자열이기 때문에 오류가 발생합니다. 위 사례는 조금 단순한 사례이기 때문에 조금 더 복잡한 사례로 연습해보겠습니다.

Case1 : Web Input Validation

웹을 구축하면서 요청과 응답에 대해 크게 3가지를 체크하는 것을 생각해보겠습니다. 

  1. 올바른 url 입력
  2. 1~10 사이의 정수 입력
  3. 올바른 폴더 이름 입력

python 버전 3.7 이상을 사용한다면 dataclasses라는 라이브러리를 활용할 수 있습니다. 라이브러리 내의 dataclass를 데코레이터를 사용해 init 메서드로 별도로 정의할 필요가 없고, __post_init__ 메서드로 검증을 수행하는 로직을 생성 시점에서 수행하게끔 합니다. 

from dataclasses import dataclass


class ValidationError(Exception):
    pass


@dataclass
class ModelInput:
    url : str
    rate : int
    target_dir : str

    def _validate_url(self, url: str) -> bool:
        from urllib.parse import urlparse

        try:
            result = urlparse(url)
            return all([result.scheme, result.netloc])
        except:
            return False
        
    def _validate_directory(self, dir: str) -> bool:
        import os

        return os.path.isdir(dir)

    def validate(self) -> bool:
        validation_results = [
            self._validate_url(self.url),
            1 <= self.rate <= 10,
            self._validate_directory(self.target_dir)
        ]
        return all(validation_results) # 모두 True일 때 True 반환

    def __post_init__(self):
        if not self.validate():
            raise ValidationError("Incorrect input")


if __name__ == '__main__':

    try:
        dataclasses_test = ModelInput(**INPUT) # INPUT은 딕셔너리 형태의 입력 데이터
    
    except ValidationError as exc:
        print('Error : ', exc.json())
        pass

 

원하는 데로  파이썬의 저수준에서 검증 로직을 만든다는 점에서는 좋으나, 검증 로직을 쌓아가야 하기 때문에 코드가 길어질 수 있습니다. 이러한 부분을 보완하기에 적합한 것이 pydantic 입니다. 이미 만들어져 있는 클래스와 함수들을 가져다 씀으로 동일한 기능을 훨씬 짧은 코드로 구현이 가능합니다.

from pydantic import BaseModel, HttpUrl, Field, DirectoryPath, ValidationError


class ModelInput:
    url : HttpUrl
    rate : int = Field(ge=1, le=10)
    target_idr : DirectoryPath


if __name__ == '__main__':

    try:
        pydantic_test = ModelInput(**INPUT)
    
    except ValidationError as exc:
        print('Error : ', exc.json())
        pass

 

Case2 : Config 관리

Config란 Configuration(환경 설정)의 약자로 코드에 필요한 여러 변수들을 저장해두고 사용하는 것을 말합니다. 이를 위해 코드 내에서 활용하거나 yaml과 같은 파일을 만들어서 읽어주거나 pydantic을 활용할 수 있습니다. 

 

다음 예제는 pydantic을 사용해서 애플리케이션의 설정을 관리하고 오버라이드(덮어 쓰기)하는 예제입니다. 우선 기본적인 환경 설정을 정의합니다. 과거에는 pydantic에 있었으나 pydantic-settings로 옮겨진 BaseSettings 클래스를 상속받습니다.

from pydantic import Field
from pydantic_settings import BaseSettings
from enum import Enum


class ConfigEnv(str, Enum):
    DEV = "dev"
    PROD = "prod"


class DBConfig(BaseSettings):
    host: str = Field(default="localhost", env="db_host")
    port: int = Field(default=3306, env="db_port")
    username: str = Field(default="user", env="db_username")
    password: str = Field(default="user", env="db_password")
    database: str = Field(default="dev", env="db_database")


class AppConfig(BaseSettings):
    env: ConfigEnv = Field(default="dev", env="env")
    db: DBConfig = DBConfig()

 

Field 함수는 모델의 필드에 메타데이터를 추가하거나 바꾸는 기능을 수행합니다. 여기서는 default와 env 인자를 사용했는데, 각각 다음과 같이 정리할 수 있습니다.

 

  • default : 해당 필드의 기본 값을 지정. host : str = Field(default="localhost")는 host 필드의 기본값이 localhost 임.
  • env : 해당 필드의 환경 변수로 지정해서 해당 변수에서 값을 가져오도록 설정. 

전체 애플리케이션에 대한 기본 설정을 담은 yaml 파일이 있다고 가정하고, 이를 로드해 AppConfig 클래스에 전달합니다. 그리고 이를 검증하는 과정을 거칩니다. 

with open("dev_config.yaml", "r") as f:
    config = load(f, FullLoader)

config_with_pydantic = AppConfig(**config)

assert config_with_pydantic.env == "dev"
assert config_with_pydantic.db.model_dump() == expected

 

만약 환경 변수로 설정 오버라이딩을 원하는 경우 아래와 같이 수정할 수 있습니다. 필요하다면 검증도 추가적으로 할 수 있습니다. 

os.environ["ENV"] = "prod"
os.environ["DB_HOST"] = "mysql"
os.environ["DB_USERNAME"] = "admin"
os.environ["DB_PASSWORD"] = "SOME_SAFE_PASSWORD"

prod_config_with_pydantic = AppConfig()
assert prod_config_with_pydantic.env == "prod"
assert prod_config_with_pydantic.model_dump() != expected

 

이를 통해 BaseSettings에서 상속을 받아서 타입에 대한 검증 수행이 가능하고, Field 클래스의 env 인자는 해당 필드로 바꿔서 써주는 효과가 있습니다.

 

참고자료

[1] 변성윤. "[Product Serving] FastAPI (2)". boostcamp AI Tech.

[2] https://docs.pydantic.dev/latest/concepts/models/

[3] https://docs.pydantic.dev/latest/concepts/pydantic_settings/

[4] https://docs.pydantic.dev/latest/concepts/fields/ 

[5] https://docs.pydantic.dev/latest/migration/#basesettings-has-moved-to-pydantic-settings

 

반응형
반응형

1. PIL (Pillow)

Python Imaging Library(PIL)은 이미지 처리를 위한 오픈소스 라이브러리입니다. Pillow는 PIL의 확장된 버전으로 이미지 파일 입출력 및 기본적인 처리 작업을 지원합니다. 다양한 이미지 포맷(JPEG, PNG, BMP 등)을 지원하고, 크기 조정이나 회전 등 간단한 이미지 처리가 가능합니다. [1]

 

PIL에서는 데이터를 PIL.Image 객체로 관리해 필요할 때마다 불러옵니다. 파이썬에서 많이 사용하는 형식인 NumPy와 변환을 위해서 아래와 같은 방법을 이용할 수 있습니다. 

 

from PIL import Image
import numpy as np

# PIL → NumPy
image_pil = Image.open('image.jpg')
image_np = np.array(image_pil)

# NumPy → PIL
image_pil_converted = Image.fromarray(image_np)

2. OpenCV

OpenCV는 2000년에 최초로 공개된 이미지 처리를 위한 소프트웨어로 C++로 구현되어 있지만, 파이썬이나 자바 등 다양한 프로그래밍 언어를 지원합니다. 파이썬에서 이미지를 다루기 위해서 최근에 사용해보신 분이라면 cv2라는 라이브러리 형태로 사용해본 적이 있을 겁니다.

 

OpenCV의 강점 중 하나는 다양한 이미지 분석 및 컴퓨터 비전 알고리즘을 제공한다는 점일 것입니다. 필터링, 엣지 검출, 변환 등 옛날부터 연구된 이미지 처리 알고리즘이나 CNN을 통해서나 접했던 특징 추출을 알고리즘으로도 가능합니다. [2]

 

cv2를 통한 이미지는 NumPy 다차원 배열(ndarray)을 통해 반환됩니다. NumPy는 기본적으로 [H, W, C] 구조로 저장을 하게 되는데, 그중에서 cv2는 채널(C)에 대해 통상적으로 익숙한 RGB가 아니라 BGR 순서로 처리합니다. 그렇기 때문에 RGB 채널이 필요할 경우 아래와 같은 방법을 통해 변환이 필요합니다.

 

import cv2
from PIL import Image

# OpenCV (BGR) → Pillow (RGB)
image_cv2 = cv2.imread('image.jpg')
image_rgb = cv2.cvtColor(image_cv2, cv2.COLOR_BGR2RGB)
image_pil = Image.fromarray(image_rgb)

# Pillow (RGB) → OpenCV (BGR)
image_np = np.array(image_pil)
image_bgr = cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR)

  

참고로, EDA를 수행할 때 Jupyter Notebook을 많이 사용할텐데, cv2에서 이미지를 보여주는 기능인 cv2.imshow()는 잘 동작하지 않습니다.[3] 따라서, 저같은 경우는 NumPy 배열을 동시에 잘 활용할 수 있는 matplotlib 라이브러리를 통해 시각화를 많이 수행하고 있습니다. 이 경우에 제대로 색으로 이미지를 보기 위해선 반드시 RGB로 변환이 필요합니다.

3. PyTorch (Torchvision)

PyTorch는 대표적인 딥러닝 프레임워크로 데이터 전처리부터 모델 학습까지 모두 통합된 작업 환경을 제공하고 있습니다. Torchvision은 PyTorch 프로젝트의 한 부분으로 유명한 이미지 데이터셋, 모델 구조, 공통적인 변환을 위한 컴퓨터 비전 기능을 제공하고 있습니다. 편하게 GPU 학습을 진행하고 텐서 연산을 진행하기 위해서는 PyTorch 프레임워크에 맞는 데이터로 전환하는 과정이 Dataset을 통해 이뤄져야 합니다. 

 

PyTorch 프레임워크에서는 이미지 데이터를 텐서(torch.Tensor) 형태로 이용합니다. 따라서, 위 두가지 라이브러리에서 이용하는 NumPy 데이터를 텐서로 바꾸는 과정이 필요합니다. 역으로 학습이 된 데이터를 NumPy 배열로 바꿔야 할 수도 있습니다. 

 

아래 코드는 PyTorch와 NumPy 사이 변환을 나타내는 예제 코드입니다. 텐서는 주로 [C, H, W] 구조로 이뤄져 있기 때문에 NumPy의  [H, W, C]에 맞게끔 변환하기 위해 permute를 사용했습니다. [4]

 

import torch
import numpy as np

# NumPy → PyTorch 텐서
image_tensor = torch.from_numpy(image_np)
image_tensor = image_tensor.permute(2, 0, 1)  # [H, W, C] → [C, H, W]

# PyTorch 텐서 → NumPy
image_np = image_tensor.permute(1, 2, 0).numpy()  # [C, H, W] → [H, W, C]

 

조금 더 나아가서 PIL로 이미지를 읽고 텐서로 바꾸는 예제를 살펴보겠습니다. 1번 과정에서 NumPy로 바꾸고 처리하는 방법도 있겠지만, torchvision에서 제공하고 있는 ToTensor를 이용할 수 있습니다.

 

import torchvision.transforms.v2 as transforms
from PIL import Image

# PIL → PyTorch 텐서
image_pil = Image.open('image.jpg')
transform = transforms.ToTensor()
image_tensor = transform(image_pil)

# PyTorch 텐서 → PIL
image_pil_converted = transforms.ToPILImage()(image_tensor)

4. Albumentations

Albumentations는 엄밀하게는 이미지를 읽는데 필요하진 않지만, 데이터 증강에 필요한 많은 기능을 제공하는 대표적인 데이터 증강 라이브러리 입니다. Torchvision에서도 제공하지만 기능이 상대적으로 적은 편인 것 같고, cv2보다는 훨씬 쓰기 편한 것 같아 자주 애용합니다. (특히, 객체 탐지에서 bbox나 세그멘테이션의 마스크 관련 처리 기능을 함께 제공해서 매우 편하게 사용할 수 있는 라이브러리라 생각합니다)

 

Albumentations는 NumPy 배열을 사용합니다. 따라서 증강 이후 훈련에 사용해야 하는 On-line Augmentation을 수행해야 할 때는 PyTorch로 바꿔야 합니다. 방법은 아래와 같이 가능합니다.

 

import albumentations as A
from albumentations.pytorch import ToTensorV2

# Albumentations 증강 정의
transform = A.Compose([
    A.Resize(256, 256),
    A.HorizontalFlip(p=0.5),
    A.Normalize(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5)),
    ToTensorV2()  # NumPy → PyTorch 텐서
])

# NumPy 이미지에 증강 적용
augmented = transform(image=image_np)
image_tensor = augmented['image']

5. 정리

앞서 한꺼번에 볼 수 있도록 정리하면 아래와 같습니다.

라이브러리 데이터 형식 대표적인 데이터 변환 방법
Pillow PIL.Image - np.array(image) → NumPy 배열로 변환.
- Image.fromarray(array) → PIL 이미지로 변환.
OpenCV NumPy(BGR) - cv2.cvtColor(image, cv2.COLOR_BGR2RGB) 
PyTorch torch.Tensor - image.numpy() → NumPy 배열.
- transforms.ToTensor() → PIL 이미지를 텐서로.
Albumentations NumPy NumPy 기반의 증강 후 PyTorch 텐서로 변환 (ToTensorV2())

 

6. 참고 자료

[1] https://pillow.readthedocs.io/en/stable/index.html

[2] https://docs.opencv.org/4.x/d0/de3/tutorial_py_intro.html

[3] https://stackoverflow.com/questions/46236180/opencv-imshow-will-cause-jupyter-notebook-crash

[4] https://discuss.pytorch.org/t/using-torchvision-transforms-with-numpy-arrays/184688

반응형
반응형

1. 도입

1.1. 필요성

직전에 영수증 이미지 데이터셋으로 COCO 데이터 형식의 OCR 데이터셋을 만들었습니다.
 
2024.11.07 - [Python/Data Prep] - CVAT로 Labeling 해서 영수증 OCR 데이터셋 만들기

 

CVAT로 Labeling 해서 영수증 OCR 데이터셋 만들기

1. CVAT 소개딥러닝 프로젝트를 진행하려다 보면, 모델이 없다기 보다는 메모리의 한계이거나 데이터가 없어서 제한되는 경우가 많습니다. 여기서는 객체 탐지, 특히 OCR을 수행하기 위한 데이터

seanpark11.tistory.com

 
이렇게 데이터셋을 만드는 것은 좋은 모델을 구축하기에 필요한 과정이지만, 시간과 비용이 많이 필요한 작업입니다. 따라서, 필요할 경우 직접 데이터셋을 합성하는 것도 고려해볼 수 있습니다. 최근에는 디퓨전과 같이 성능이 좋은 합성 모델이 많지만, 이러한 모델들 역시 기본적으로 데이터가 많은 상태에서 많은 학습을 거쳐야 효과적인 것으로 보입니다. (예전에 30장 이미지 기준 100번의 학습을 진행해도 원하는 모습이 나오진 않았던 경험이 있습니다.)
 
모델을 학습시키기 충분한 양의 데이터를 비교적 적은 비용과 시간으로 얻기 위해 이미지 합성을 생각했고, 비교적 정형화된 모습을 갖고 있는 OCR의 경우 정해진 틀에 Rule-based의 방식으로도 충분히 다른 형태의 객체를 만들어낼 수 있을 것이라 생각했습니다. 
 
이를 해내기 위해 기존의 데이터셋에서 객체의 정보를 가져와 이미 학습된 번역기에 전달해서 나온 결과를 기존 이미지에 입히는 방식을 생각했고, 실제 이를 구현하였습니다. 

1. 2. 고려 사항

구현하는 과정에서 크게 3개의 고려 사항이 있었습니다. 
 

  • 번역된 문자를 넣을 깨끗한 영수증 이미지 필요
  • 번역기 문자 수 제한 때문에 번역이 필요한 문자만 추출 필요
  • 번역하면서 너무 길어지는 경우 잘라낼 필요 

1.3. 설계

1.2에서 언급한 문제나 고려 사항들을 감안하여 다음과 같은 방향으로 진행하고자 설계하였으며, 고려 사항을 해결하기 위해 특별히 필요한 기능이 있다면 기재했습니다,
 

  1. Import data
  2. Clean image 
  3. Translate : 필요한 문자만 추출 
  4. Insert text : 글자 길이 조정

또한 구현을 위한 기초적인 환경 세팅은 다음과 같습니다. (파이썬 표준 라이브러리는 제외)

python == 3.10.12
opencv-python == 4.10.0
numpy == 1.26.4
deepl == 1.19.1
PIL == 10.4.0

2. 구현

2.1. Import Data

우선 COCO 데이터와 이미지 데이터를 불러와야 합니다. COCO 데이터 포맷의 경우 pycocotools라는 라이브러리가 있긴 하지만, 여기서는 사용하지 않았습니다. 아래 코드는 json 데이터를 불러옵니다.
 

import json
from pathlib import Path

def read_json(path: str):
    with Path(path).open(encoding='utf8') as file:
        data = json.load(file)
    return data

json_data = read_json('./instances_default.json')

 
이미지도 불러오는 코드입니다. 여기서는 OpenCV를 활용했고, 코드를 돌리면 잘 불러오는 것을 확인할 수 있습니다. 
 

import cv2
import matplotlib.patches as patches
import matplotlib.pyplot as plt

img = cv2.imread('./receipt.jpg')
annotations = json_data['annotations']

fig, ax = plt.subplots(1)
for ann in annotations:
    x, y, w, h = ann['bbox']
    coordinates = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
    polygon = patches.Polygon(coordinates, closed=True, linewidth=0.5, edgecolor='red', facecolor='none')
    ax.add_patch(polygon)

plt.axis('off')
plt.imshow(img)

2.2. Clean image

구상했던 이미지 생성방법은 기존 영수증 이미지에서 글자들을 지우고 같은 자리에 번역된 글자를 넣는 방식입니다. 그러다보니, 기존 이미지에서 텍스트만을 지우는 방식을 고민했고 Stackoverflow에 제안된 방식을 활용했습니다. [1]
 
해당 방식의 가장 핵심적인 것은 모폴로지 연산이라고 생각하는데, 모폴로지 연산이란 이미지의 형태에 기반한 연산들을 말하며, 형태에 집중해야 하기 때문에 일반적으로 흑백 이미지에 적용됩니다. [2] 이를 위해 그레이 스케일로 변환하고 닫힘(Closing) 연산을 통해 텍스트 영역의 작은 구멍을 제거 및 팽창(Dilation) 연산으로 텍스트 영역을 확장합니다.
 
그 다음 팽창된 이미지에서 텍스트 영역의 윤곽선을 검출해 윤곽선 면적 일정 범위 내에 있는 것만 선택해 실제 텍스트 영역으로 간주합니다. 선택된 윤곽선을 기반으로 마스크 이미지를 생성해 마스크 이미지에서 흰색은 텍스트 / 검은색은 배경 영역으로 사각형을 그립니다. 원본 이미지와 생성된 마스크 이미지를 비교하면서 주변 픽셀 정보를 활용해 자연스럽게 채워넣는 기술인 inpainting으로 처리합니다.  
 

import numpy as np

def inpaint_text_areas(image):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]

    close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3))
    close = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=1)

    dilate_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 3))
    dilate = cv2.dilate(close, dilate_kernel, iterations=1)

    cnts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    cnts = cnts[0] if len(cnts) == 2 else cnts[1]

    mask = np.zeros(image.shape[:2], dtype=np.uint8)

    for c in cnts:
        area = cv2.contourArea(c)
        if area > 100 and area < 30000:
            x, y, w, h = cv2.boundingRect(c)
            cv2.rectangle(mask, (x, y), (x + w, y + h), 255, -1)

    inpainted_image = cv2.inpaint(image, mask, inpaintRadius=1, flags=cv2.INPAINT_TELEA)

    return inpainted_image

inpainted_img = inpaint_text_areas(img)
plt.axis('off')
plt.imshow(inpainted_img)

 
아래 이미지를 보면 꽤 자연스럽게 잘 지워진 것을 확인할 수 있습니다. 

2.3. Translate

직접적으로 번역을 위한 모델을 구축할 수는 없기에, 번역을 위해서는 DeepL의 API를 사용했습니다. 기존에 알고있는 다른 번역 API들은 유료였기 때문에 간단히 테스트용으로 사용하기는 부적합하다고 판단했고, DeepL은 500,000자까지는 무료로 제공하기 때문에 채택했습니다. 아래 코드는 DeepL 문서에서 알려주고 있는 코드를 활용해 함수를 만들었습니다. [3]
 

import deepl
import getpass

def authenticate():
    auth_key = getpass.getpass("Enter API Key : ")
    translator = deepl.Translator(auth_key)
    return translator

def translate_text(translator, text, target_lang):
    result = translator.translate_text(text, target_lang=target_lang)
    return result.text

 
다만, 500,000자 제한이 있기 때문에 모든 문자를 번역하는 것은 다소 아까운 일입니다. 그래서 정규식을 이용해 해당 문자가 한국어를 포함하는 경우만 번역하도록 했습니다. 구현한 모든 함수들을 활용해 한국어가 포함된 경우에만 일본어로 번역하고 이를 동일한 annotations로 반환하도록 했습니다.
 

import re

def check_korean(text):
    korean_pattern = re.compile(r'[\u3131-\u3163\uac00-\ud7a3]+')
    matches = re.findall(korean_pattern, text)
    if len(matches) < 1:
        return False
    return True
    
translator = authenticate()
translated_annotations = []
for ann in annotations:
    transcription = ann['attributes']['transcription']
    if check_korean(transcription):
        translated_text = translate_text(translator, transcription, 'JA') # JA : Japanese
        ann_copy = ann.copy()
        ann_copy['attributes']['transcription'] = translated_text
        translated_annotations.append(ann_copy)
    else:
        translated_annotations.append(ann)

2.4. Insert text

2.2.에서 만든 백지 이미지에 번역된 글자들을 추가하는 코드입니다. PIL에서 제공하고 있는 Draw를 통해 텍스트를 삽입하는 방식인데, 사용하는 메서드에 대한 설명은 다음 링크에서 확인하시면 됩니다. 
 
ImageDraw Module - Pillow (PIL Fork) 11.0.0 documentation
 
add_new_text의 입력 매개변수 중 다른 것보다 특이한 font가 있습니다. 이는 PIL 라이브러리의 ImageFont. truetype으로 생성한 인스턴스로 글씨체와 사이즈를 지정합니다. 여기 프로젝트에서는 많은 세계 각국의 언어를 지원하는 Google과 Adobe가 협업해서 만들었다고 알려진 Noto Fonts 를 사용했습니다.
 
 

from PIL import Image, ImageDraw, ImageFont

def add_new_text(image, bbox, text, font):
    x, y, w, h = bbox
    pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(pil_image)

    text_pos_x = x
    text_pos_y = y + h // 2

    draw.text((text_pos_x, text_pos_y), text, fill=(0, 0, 0), font=font, anchor='lm')

    return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)

 
하지만, 여전히 부족한 부분이 있는데, 번역을 통해 길어진 텍스트에 대한 문제 입니다. 다음 사진을 보면 일본어로 번역했을 때 바운딩 박스를 넘어가는 것을 확인할 수 있습니다. 이는 언어마다 표현하는 길이가 달라지면서 발생하는 문제입니다. 

 
이를 해결하기 위해 3가지 방법을 시도했습니다.

  • 폰트 사이즈 조절 : 바운딩 박스에 맞추는 방향으로 폰트 사이즈를 조절했으나, 너무 작아지는 텍스트가 만들어져 학습에 적절하지 않은 데이터가 생성된다고 판단했습니다. 
  • Pseudo Character Center : 적당히 잘라내기 위해 PCC를 찾았지만, 단순한 방법으로는 문자마다 다른 길이로 표현되기 때문에 너무 많은 문자가 소실이 발생했습니다.
  • PIL getbbox : PIL font 인스턴스에 있는 getbbox를 활용하고 있는데, 이는 비교적 최신 라이브러리에서 지원하는 메서드이기 때문에 버전을 확인할 필요가 있습니다.

가장 효과적이라고 판단한 getbbox를 사용해 문자들의 길이를 측정하고, 그에 맞춰서 번역한 텍스트를 반환해서 문자를 기입하는 코드는 다음과 같습니다. 
 

def get_char_widths(text, font:ImageFont.truetype):
    char_widths = []
    
    for char in text:
        bbox = font.getbbox(char)
        char_width = bbox[2] - bbox[0]  
        char_widths.append((char, char_width))

    return char_widths

def get_text_in_box(char_widths):
    text_in_box = ''
    text_width = 0
    for char, width in char_widths:
        text_width += width
        if text_width <= w:
            text_in_box += char
        else:
            break
    return text_in_box


fig, ax = plt.subplots(1)
for ann in translated_annotations:
    x, y, w, h = ann['bbox']
    coordinates = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
    font_size = h
    font = ImageFont.truetype(font='./font/NotoSansJP-Regular.ttf', size=font_size)
    polygon = patches.Polygon(coordinates, closed=True, linewidth=0.5, edgecolor='red', facecolor='none')
    ax.add_patch(polygon)

    char_widths = get_char_widths(ann['attributes']['transcription'], font)
    text_in_box = get_text_in_box(char_widths)
    inpainted_img=add_new_text(inpainted_img, ann['bbox'], text_in_box, font)

plt.axis('off')
plt.imshow(inpainted_img)

 

 
완벽하게 기존 것처럼 재현한 것은 아니지만, 꽤 그럴싸하게 만들어진 것 같습니다. 
 

반응형

3. 최종 코드

import json
from pathlib import Path
import cv2
import matplotlib.patches as patches
import matplotlib.pyplot as plt
import numpy as np
import deepl
import re
import getpass
from PIL import Image, ImageDraw, ImageFont

def read_json(path: str):
    with Path(path).open(encoding='utf8') as file:
        data = json.load(file)
    return data

def inpaint_text_areas(image):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]

    close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3))
    close = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=1)

    dilate_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 3))
    dilate = cv2.dilate(close, dilate_kernel, iterations=1)

    cnts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    cnts = cnts[0] if len(cnts) == 2 else cnts[1]

    mask = np.zeros(image.shape[:2], dtype=np.uint8)

    for c in cnts:
        area = cv2.contourArea(c)
        if area > 100 and area < 30000:
            x, y, w, h = cv2.boundingRect(c)
            cv2.rectangle(mask, (x, y), (x + w, y + h), 255, -1)

    inpainted_image = cv2.inpaint(image, mask, inpaintRadius=1, flags=cv2.INPAINT_TELEA)

    return inpainted_image

def authenticate():
    auth_key = getpass.getpass("Enter API Key : ") 
    translator = deepl.Translator(auth_key)
    return translator

def translate_text(translator, text, target_lang):
    result = translator.translate_text(text, target_lang=target_lang)
    return result.text

def check_korean(text):
    korean_pattern = re.compile(r'[\u3131-\u3163\uac00-\ud7a3]+')
    matches = re.findall(korean_pattern, text)
    if len(matches) < 1:
        return False
    return True

def add_new_text(image, bbox, text, font):
    x, y, w, h = bbox
    pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(pil_image)

    text_pos_x = x
    text_pos_y = y + h // 2

    draw.text((text_pos_x, text_pos_y), text, fill=(0, 0, 0), font=font, anchor='lm')

    return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)

def get_char_widths(text, font:ImageFont.truetype):
    char_widths = []
    
    for char in text:
        bbox = font.getbbox(char)
        char_width = bbox[2] - bbox[0]  
        char_widths.append((char, char_width))

    return char_widths

def get_text_in_box(char_widths):
    text_in_box = ''
    text_width = 0
    for char, width in char_widths:
        text_width += width
        if text_width <= w:
            text_in_box += char
        else:
            break
    return text_in_box


json_data = read_json('./instances_default.json')
img = cv2.imread('./receipt.jpg') 
annotations = json_data['annotations']

inpainted_img = inpaint_text_areas(img)

translator = authenticate()
translated_annotations = []
for ann in annotations:
    transcription = ann['attributes']['transcription']
    if check_korean(transcription):
        translated_text = translate_text(translator, transcription, 'JA') # JA : Japanese
        ann_copy = ann.copy()
        ann_copy['attributes']['transcription'] = translated_text
        translated_annotations.append(ann_copy)
    else:
        translated_annotations.append(ann)

fig, ax = plt.subplots(1)
for ann in translated_annotations:
    x, y, w, h = ann['bbox']
    coordinates = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
    font_size = h
    font = ImageFont.truetype(font='./font/NotoSansJP-Regular.ttf', size=font_size)
    polygon = patches.Polygon(coordinates, closed=True, linewidth=0.5, edgecolor='red', facecolor='none')
    ax.add_patch(polygon)

    char_widths = get_char_widths(ann['attributes']['transcription'], font)
    text_in_box = get_text_in_box(char_widths)
    inpainted_img=add_new_text(inpainted_img, ann['bbox'], text_in_box, font)

plt.axis('off')
plt.imshow(inpainted_img)

4. 한계 및 배운 점

4.1. 한계

(외부 툴) 직접 번역하는 모델을 구축하기 어려운만큼 외부 툴을 사용할 수 밖에 없었지만, 제한되는 환경이라서 실제로 사용하기에는 다소 무리가 있을 것 같습니다. 
 
(완성도) 중간중간 나온 실패작보다는 괜찮은 결과물이지만, 생성한 데이터만으로 영수증을 떠올리기는 쉽지 않은 것 같습니다. 빈 영수증을 만드는 과정에서 그래도 자연스럽게 만들어졌다고 생각하지만, 로고까지 지워지지 않도록 규제하는 방향을 고민해볼 필요가 있습니다. 그리고 번역으로 인해 잘리는 문제가 발생하게 되는데, 위치만 찾는 Text Detector로는 기능할 수 있지만 글자까지 인식해야 하는 Text Recognizer까지 모델이 확장된다면 이러한 데이터 생성은 적절하지 않아 보입니다. 

4.2. 배운 점

(OpenCV) Computer Vision에서 많이 사용한다는 OpenCV에 대해 간략하게만 알고 있었지만, 꽤나 강력한 도구들을 제공하고, 제대로 배워보면 이미지를 다루는데 큰 무기를 얻을 수 있겠다는 생각이 들었습니다. 물론 OpenCV 뿐 아니라 PIL도 꽤 유용한 것들이 많아서 앞으로 계속 프로젝트를 해보면서 익숙해져야 할 것 같습니다. 
 
(함수) 프로젝트라는 이름을 붙일 수준은 아니지만, 여러 개의 함수들을 만들고 이들을 최대한 간결하게 작성해보려는 시도를 하면서 앞으로 어떤 방식으로 코드를 작성해야 할지에 대한 감을 늘릴 수 있었습니다. 최근에 본 몇몇 영상과 부스트 캠프 과정에서 배운 내용들을 상기해보면서 몇 번 재배치를 하고, 어떻게 함수를 만드는 것이 나중에 유지보수를 최소화할 수 있을지에 대해 잠깐이지만 고민해볼 기회였습니다.

5. 참고자료

[1] https://stackoverflow.com/questions/58349726/opencv-how-to-remove-text-from-background
[2] https://docs.opencv.org/4.x/d9/d61/tutorial_py_morphological_ops.html 
[3] https://developers.deepl.com/docs/ko/api-reference/translate

반응형
반응형

데이터 포맷

주어진 이미지와 그에 해당하는 클래스가 대응되는 분류 문제와 다르게 객체 탐지 문제는 객체를 찾고(Localization), 이를 분류(Classification)해야 하는 두가지 일이 존재하기 때문에 데이터의 양식이 조금 더 복잡합니다. 특히, 하나의 이미지에서 여러 개의 객체가 존재할 수도 있기 때문에 더욱 어려운 문제가 될 수 있죠.
 
이러한 문제들을 극복하기 위해 데이터에 레이블을 붙이는 어노테이션(annotation)을 수행하게 되는데, 어노테이션 방식에 따라 데이터를 처리하는 방식이 달라져야 합니다. 가장 대표적인 객체 탐지 데이터 형식은 COCO, Pascal VOC, YOLO 등이 있습니다. 

COCO

COCO는 Common Objects in COntext의 약자로 해당 데이터셋은 객체 탐지, 세그멘테이션, 키포인트 추출 등 다양한 작업을 위한 대규모 데이터셋입니다. COCO 데이터셋은 JSON 파일로 객체의 위치, 크기, 분류 정보를 저장하고 있습니다. 객체 탐지를 위한 주요한 정보는 아래와 같이 정리할 수 있습니다. 
 

  • Image Info : 이미지 id, 이미지 파일명, 크기(width, height), 이미지 출처 및 수집 시기 등 관련 정 
  • Categories : 클래스 정보(id, 명칭)
  • Annotations : 각 객체에 대한 어노테이션 정보로 bounding box, segmentation 정보, 클래스 id 등 포함
    • Bounding box : (x, y, width, height) 형태로 저장되며, 좌측 상단 모서리 좌표(x,y)와 너비와 높이
    • Segmentation : 객체를 폴리곤 형태로 나타내는 좌표 목록
    • Category ID : 객체 클래스의 ID
    • IsCrowd : 객체가 군중인지 여부 (1이면 군중)

특히, Annotations가 중요하므로 예시를 살펴보면 아래와 같이 나타납니다. 
 

COCO 데이터 포맷 중 annotations 예시 [2]

 
좀 더 자세한 내용을 살펴보고 싶으신 경우 아래 링크들을 참고하면 데이터셋 운영주체나 AWS에서 기술하고 있는 설명을 확인할 수 있습니다.

COCO - Common Objects in Context

cocodataset.org

 

The COCO dataset format - Rekognition

The COCO dataset format A COCO dataset consists of five sections of information that provide information for the entire dataset. The format for a COCO object detection dataset is documented at COCO Data Format. info – general information about the datase

docs.aws.amazon.com

Pascal VOC

Pascal VOC(Visual Object Classes)는 객체 탐지 작업을 위해 XML 형식으로 어노테이션을 저장하는 데이터셋입니다. 이미지와 객체의 bounding box 정보를 기록하는 방식으로 다음과 같은 주요 어노테이션 포맷으로 활용이 됩니다. 
 

  • Folder :이미지가 저장된 폴더
  • Filename : 이미지 파일 이름
  • Size :이미지 너비, 높이, 채널 정보
  • Object : 이미지에 포함된 객체 정보로 주요 어노테이션 정보 포함
    • Name : 객체 클래스 이름
    • Bounding box : (xmin, ymin, xmax, ymax)로 객체의 bounding box  좌표
    • Difficult : 객체 탐지가 어려운 경우 1 아닌 경우 0

img0001에 대한 정보를 담은 Pascal VOC 포맷 예시입니다. 보면 상단에는 이미지에 대한 정보들이 있고, object 부터 이미지에 존재하는 여러 객체들에 대한 정보(클래스, 바운딩박스 위치 정보)를 담고 있는 것을 확인할 수 있습니다. 

Pascal VOC 예시 [3]

YOLO

YOLO(You Only Look Once)는 객체 탐지 모델로 유명하지만, 데이터셋의 어노테이션 양식도 있습니다. YOLO의 포맷은 txt 형태로 저장되며, 각 줄에 한 객체의 어노테이션 정보를 한 칸씩 띄어쓰기한 형태로 포함합니다.
(이러한 형태로 ➡️ 클래스  x y w h)
 

  • 클래스 인덱스 : 객체가 어떤 클래스에 포함되는지 표현하는 정수
  • 바운딩 박스 좌표 : (x_center, y_center, width, height) 순으로 표현되며, 모두 상대적인 좌표 (0~1 사이 존재)로 주어집니다. 

YOLO 데이터셋 포맷의 예시는 아래와 같습니다. 다른 형태에 비해 비교적 간단하게 담아냅니다. 

YOLO 데이터셋 포맷 예시[4]

데이터 변환

위에서 살펴보면 데이터셋이 담는 파일 양식(json, xml, txt), 바운딩 박스 표현도 다른 것을 알 수 있습니다. 양식이야 그때 그때 맞춰주면 되겠지만, 바운딩 박스 양식은 크게 영향을 받을 수 있는데 각각의 데이터셋을 변환하는 방법을 정리하려고 합니다. 사실 원리만 알면 2~3개만 정리해도 되지만, 언제든 편리하게 갖다 쓰기 위해 모든 경우의 수를 고려한 6가지 소제목으로 아래와 같이 정리합니다.

COCO ➡️ Pascal VOC

def coco_to_voc(coco_annotations):
    voc_annotations = []
    for ann in coco_annotations:
        x_min, y_min, width, height = ann["bbox"]
        xmax = x_min + width
        ymax = y_min + height
        class_name = coco_category_to_voc_class(ann["category_id"])  # Map category ID to class name
        
        voc_annotations.append({
            "class_name": class_name,
            "xmin": int(x_min),
            "ymin": int(y_min),
            "xmax": int(xmax),
            "ymax": int(ymax)
        })
    return voc_annotations

COCO ➡️ YOLO

def coco_to_yolo(coco_annotations, img_width, img_height):
    yolo_annotations = []
    for ann in coco_annotations:
        x_min, y_min, width, height = ann["bbox"]
        x_center = (x_min + width / 2) / img_width
        y_center = (y_min + height / 2) / img_height
        width = width / img_width
        height = height / img_height
        class_id = ann["category_id"] - 1  # YOLO는 0부터 시작
        yolo_annotations.append([class_id, x_center, y_center, width, height])
    return yolo_annotations

Pascal VOC ➡️ COCO

import xml.etree.ElementTree as ET

def voc_to_coco(voc_annotations, img_id, category_id_map):
    coco_annotations = []
    annotation_id = 1  # Unique ID for each annotation
    
    for voc_annotation in voc_annotations:
        tree = ET.parse(voc_annotation)
        root = tree.getroot()

        img_filename = root.find('filename').text
        img_width = int(root.find('size/width').text)
        img_height = int(root.find('size/height').text)
        
        for obj in root.findall('object'):
            class_name = obj.find('name').text
            if class_name not in category_id_map:
                continue  # Skip unknown classes
            category_id = category_id_map[class_name]

            bndbox = obj.find('bndbox')
            xmin = int(bndbox.find('xmin').text)
            ymin = int(bndbox.find('ymin').text)
            xmax = int(bndbox.find('xmax').text)
            ymax = int(bndbox.find('ymax').text)

            x_min = xmin
            y_min = ymin
            width = xmax - xmin
            height = ymax - ymin

            # Create COCO annotation
            annotation = {
                "id": annotation_id,
                "image_id": img_id,
                "category_id": category_id,
                "bbox": [x_min, y_min, width, height],
                "area": width * height,
                "iscrowd": 0
            }
            coco_annotations.append(annotation)
            annotation_id += 1
        
    return coco_annotations

Pascal VOC ➡️ YOLO

import xml.etree.ElementTree as ET

def voc_to_yolo(voc_annotation, img_width, img_height):
    yolo_annotations = []
    tree = ET.parse(voc_annotation)
    root = tree.getroot()
    
    for obj in root.findall('object'):
        class_name = obj.find('name').text
        bndbox = obj.find('bndbox')
        xmin = int(bndbox.find('xmin').text)
        ymin = int(bndbox.find('ymin').text)
        xmax = int(bndbox.find('xmax').text)
        ymax = int(bndbox.find('ymax').text)

        x_center = (xmin + xmax) / 2 / img_width
        y_center = (ymin + ymax) / 2 / img_height
        width = (xmax - xmin) / img_width
        height = (ymax - ymin) / img_height

        # Assume a mapping from class names to YOLO class indices
        class_id = class_name_to_id(class_name)  # e.g., {'person': 0}
        yolo_annotations.append([class_id, x_center, y_center, width, height])
    return yolo_annotations

 

YOLO ➡️ COCO

def yolo_to_coco(x_center, y_center, width, height, img_width, img_height):
    # YOLO에서는 w, h가 비율이므로 원래 이미지의 너비와 높이를 따로 받아야 함
    x_min = (x_center - width / 2) * img_width
    y_min = (y_center - height / 2) * img_height
    width = width * img_width
    height = height * img_height
    return [x_min, y_min, width, height]
    
def convert_yolo_to_coco(yolo_annotations, img_width, img_height):
    coco_annotations = []
    for ann in yolo_annotations:
        class_id, x_center, y_center, width, height = ann
        bbox = yolo_to_coco(x_center, y_center, width, height, img_width, img_height)
        coco_annotations.append({
            "category_id": int(class_id),
            "bbox": bbox,
            "area": bbox[2] * bbox[3],
            "iscrowd": 0
        })
    return coco_annotations

YOLO ➡️ Pascal VOC

import xml.etree.ElementTree as ET

def yolo_to_voc(x_center, y_center, width, height, img_width, img_height):
    xmin = (x_center - width / 2) * img_width
    ymin = (y_center - height / 2) * img_height
    xmax = (x_center + width / 2) * img_width
    ymax = (y_center + height / 2) * img_height
    return int(xmin), int(ymin), int(xmax), int(ymax)

def create_voc_annotation(filename, width, height, bbox, class_name):
    annotation = ET.Element("annotation")
    ET.SubElement(annotation, "filename").text = filename

    size = ET.SubElement(annotation, "size")
    ET.SubElement(size, "width").text = str(width)
    ET.SubElement(size, "height").text = str(height)

    obj = ET.SubElement(annotation, "object")
    ET.SubElement(obj, "name").text = class_name

    bndbox = ET.SubElement(obj, "bndbox")
    ET.SubElement(bndbox, "xmin").text = str(bbox[0])
    ET.SubElement(bndbox, "ymin").text = str(bbox[1])
    ET.SubElement(bndbox, "xmax").text = str(bbox[2])
    ET.SubElement(bndbox, "ymax").text = str(bbox[3])

    return ET.tostring(annotation)

참고자료

[1] https://cocodataset.org/#format-data
[2] https://docs.aws.amazon.com/rekognition/latest/customlabels-dg/md-coco-overview.html
[3] https://roboflow.com/formats/pascal-voc-xml
[4] https://docs.ultralytics.com/ko/datasets/detect/#ultralytics-yolo-format
 

반응형
반응형

1. Dataset와 DataLoader 

1.1. Dataset

torch.utils.data.Dataset은 데이터셋을 정의하는 기본 클래스입니다. 데이터를 메모리에서 가져오는 방법을 정의하며, 개발자가 자체적으로 데이터셋을 만드는데 사용됩니다. Dataset의 중요한 메서드는 __len__, __getitem__ 입니다. __len__은 데이터셋의 크기를 반환하고, __getitem__ 주어진 인덱스에 해당하는 데이터를 학습에 적합한 형태로 변환할 수 있습니다.

 

from torch.utils.data import Dataset

class MyDataset(Dataset):
    def __init__(self, data):
        self.data = data

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        return self.data[idx]

1.2. DataLoader

Pytorch로 딥러닝 학습을 진행하면, torch.utils.data.DataLoader라는 것을 많이 사용하게 됩니다. DataLoader는 데이터셋에서 데이터를 배치 단위로 로드하는 역할을 수행합니다. 이를 통해 모델이 배치 단위로 학습을 진행하고, 별도로 구현된 코드를 통해 메모리 사용을 최적화하고 학습 속도를 높여줄 수 있습니다.

 

DataLoader에는 다음과 같은 주요 세팅 값을 지정해서 사용할 수 있습니다.

  • batch_size : 배치당 얼마나 많은 데이터 샘플을 가져갈지 결정
  • shuffle : 데이터를 로드할 때마다 순서를 무작위로 섞을지를 결정
  • num_workers : 데이터 로드를 위해 얼마나 많은 병렬처리를 위한 서브프로세서를 사용할지 결정
  • drop_last : 마지막 배치 크기 사이즈가 지정한 숫자보다 작을 경우 버릴지 결정

 

- 추가 설명 : https://pytorch.org/docs/stable/data.html#torch.utils.data.DataLoader

 

torch.utils.data — PyTorch 2.4 documentation

torch.utils.data At the heart of PyTorch data loading utility is the torch.utils.data.DataLoader class. It represents a Python iterable over a dataset, with support for These options are configured by the constructor arguments of a DataLoader, which has si

pytorch.org

 

이렇듯 원래라면 한줄씩 코드를 구현하는 노력이 필요하겠지만, 학습 코드에서 제외하고 별도의 Class를 통해 가독성을 높일 수 있기 때문에 (심지어 직접 구현한 것보다 더 효율적일 수 있기 때문에) 자주 활용됩니다. 

2. DataLoader 오류

2.1. DataLoader 작동 

Dataset 자체는 이터레이터가 아니므로, next()를 사용할 수 없습니다. 하지만 __getitem__을 통해 인덱스를 통해 데이터를 반환하는 방식이 동작할 수 있습니다.  DataLoader는 __iter__와 __next__ 메서드를 구현해 이터레이터로 동작합니다. DataLoader는 인덱스 - 이미지(데이터)로 반환하고 있는 Dataset을 받아서 사용하고 있습니다. 그렇기 때문에 아래와 같은 코드가 가능합니다.

 

for images in tqdm(data_loader):
    # 정상적인 이미지 처리
    images = images.to(device)
    predictions = model(images)

 

하지만, 이터레이터 특성상 중간에 데이터가 빌 경우 문제가 발생할 수 있습니다. 

2.2. DataLoader 오류 해결

DataLoader를 잘 사용하던 중 아래와 같은 오류가 나왔습니다.

 

error: OpenCV(4.9.0) /io/opencv/modules/imgproc/src/color.cpp:196: error: (-215:Assertion failed) !_src.empty() in function 'cvtColor'

 

위 오류의 설명은 OpenCV의 cvtColor() 함수가 비어 있는 이미지를 처리하려고 할 때 발생한 문제입니다. 즉, 이미지가 None인 상태에서 cvtColor()를 호출하려고 해서 실패하고 있는 것입니다. 

 

위(2.1)에서 언급하고 있는 DataLoader 작동을 생각해봤을 때 이미지가 없다는 것은 iterable 객체에 중간에 데이터가 비어 있을 것이라 추론할 수 있습니다. 살펴보니 데이터셋으로 가져오고 있는 데이터가 비어있는 것을 발견해 데이터를 추가해서 오류를 해결했습니다.  

 

 

 

 

 

 

반응형
반응형

딥러닝을 위해 데이터를 불러오거나 전처리하는 방법을 매번 작성하는 것은 비효율적이고 반복적인 작업이 될 수 있습니다. PyTorch에서는 torch.utils.data를 통해 다양한 클래스를 제공하고 있으며, 제공된 클래스를 적절히 활용하면 효율적이고 유연한 데이터 파이프라인을 구축할 수 있습니다. 

Dataset 

torch.utils.data.Dataset은 키 -> 데이터 샘플로 매핑되는 모든 데이터셋을 표현하기 위해 Dataset을 상속받아 사용합니다. 데이터를 초기화하는 __init__ 메서드, 데이터 크기를 반환하는 __len__ 메서드, 특정 인덱스의 데이터 샘플을 반환할 수 있도록 하는 __getitems__ 메서드를 구현할 수 있습니다.  

 

import torch
from torch.utils.data import Dataset

class MyDataset(Dataset):
    def __init__(self, data, labels):
        self.data = data
        self.labels = labels

    def __len__(self): # 데이터셋의 총 샘플 수를 반환
        return len(self.data)

    def __getitem__(self, idx): # 주어진 인덱스에서 데이터와 레이블을 반환
        sample = self.data[idx]
        label = self.labels[idx]
        return sample, label

 

위 예시에 대해 아래와 같이 생성해보고, 결과를 출력해보겠습니다. 먼저 예제로 사용할 데이터셋을 생성하고 가장 첫번째에 있을 데이터를 출력해보겠습니다.

 

data = torch.randn(100, 3) 
labels = torch.randint(0, 2, (100,))

 

 

그렇다면 만들어본 클래스가 잘 작동되는지 출력해보겠습니다. __len__는 len()함수를 통해 확인하고, __getitem__은 [index]를 통해 접근해보겠습니다. 출력 결과를 보니, 문제가 없이 잘 구현된 것을 확인했습니다.  

 

dataset = MyDataset(data, labels)
print(len(dataset))  # output: 100

sample, label = dataset[0]
print(sample, label)

 

DataLoader

torch.utils.data.DataLoader는 데이터를 배치(batch: 데이터를 건마다 처리하는 것이 아닌, 한 번에 처리되는 데이터의 묶음) 단위로 처리하고 병렬작업을 지원해 속도를 높입니다. 앞에서 만든 Dataset의 인스턴스를 감싸 배치 크기에 맞춰 나눠주고, 특정 순서에 의존하지 않도록 섞어주는 기능(shuffle : 훈련에서는 True / 테스트는 False)도 제공합니다. 또한, 필요할 경우 여러 스레드를 사용해 데이터를 병렬로 로드할 수 있도록 기능(num_workers)도 제공하고 있습니다. 외에도 다양하게 사용할 수 있으니 아래 링크를 통해 학습하면 좋습니다.

 

https://pytorch.org/docs/stable/data.html#torch.utils.data.DataLoader

 

torch.utils.data — PyTorch 2.4 documentation

torch.utils.data At the heart of PyTorch data loading utility is the torch.utils.data.DataLoader class. It represents a Python iterable over a dataset, with support for These options are configured by the constructor arguments of a DataLoader, which has si

pytorch.org

'

DataLoader는 별도 클래스를 구성할 것 없이 만들어진 것을 활용하면 됩니다. 

 

from torch.utils.data import DataLoader

dataloader = DataLoader(dataset, batch_size=32, shuffle=True)

for batch_data, batch_labels in dataloader:
    print(batch_data.shape, batch_labels.shape)

 

반응형
반응형

 

데이터분석을 위해서 Kaggle을 많이 사용할텐데, 많이 사용하는 Colab나 Jupyter Notebook 환경에서 바로 다운로드를 받을 수 있는 방법에 대해 고민을 했고, 방법을 찾아서 정리하였다. 

 

1. Kaggle API Token 다운로드

Kaggle 홈페이지에서 프로필 사진 > Settings > Account 로 이동해서 API 항목으로 이동한다.

 

 

"Create New Token"을 눌러서 kaggle.json 파일 다운로드할 수 있다. 

 

2. Colab 환경에서 Kaggle 접근

아래 코드 입력하여 kaggle에 접근한다. (Colab의 cell에서는 한꺼번에 입력해도 작동)

 

!pip install -q kaggle
!mkdir -p ~/.kaggle
from google.colab import files
files.upload()

 

 

API Key를 활용할 수 있도록 아래 코드 입력하면 된다. 

 

!cp kaggle.json ~/.kaggle/
!chmod 600 ~/.kaggle/kaggle.json

3. Kaggle 데이터셋 조회

아래 코드를 입력해 캐글의 데이터셋 조회할 수 있다. 

 

!kaggle datasets list

 

 

원하는 데이터셋을 다운로드 받기 위해 필요한 데이터셋 식별자(dataset identifier)를 조회할 수 있다. 

 

방법 1) kaggle 홈페이지에서 원하는 데이터셋을 찾고, url에서 "../datasets/" 이후 값을 확인 

 

 

 

방법 2) 아래 코드에 " " 안에 원하는 키워드를 통해 검색해서 원하는 ref 값을 확인

!kaggle datasets list -s "google play store"

 

4. Kaggle 데이터셋 다운로드

 

아래 코드는 데이터셋을 다운로드하는 명령어로 바로 위에서 확인한 식별자를 입력한다. (보통 'username/dataset-name' 형태라는 점은 참고하면 좋다)

 

# import the dataset
!kaggle datasets download -d <dataset-identifier>

 

아래 코드는 다운로드 받은 데이터셋(zip)을 압축을 해제시킨다. (이번 사례에서는 file_name은 "google-play-store-apps.zip"을 활용)

 

# unzip the dataset
!unzip -q /content/file_name.zip

 

간혹, 위 코드 실행 과정 중에서 아래처럼 물어보는 경우가 있는데, 이건 n, A, N, r 중 알아서 원하는 것을 입력하면 된다. 

 

 

위 과정을 잘 마치고 나면 아래처럼 잘 다운로드된 것을 확인할 수 있다.

 

반응형
반응형

1. 소개

 

Selenium을 통해 페이지에 있는 요소를 찾는 것은 여러가지 방법이 있습니다. Selenium 에서는 By 클래스를 통해 다양한 속성으로 이용이 가능합니다.(By Strategy) 먼저 현재 사용하고 있는 Selenium 라이브러리의 버전은 다음과 같습니다.

 

Version
Selenium = 4.3.0.
(향후 업데이트가 될 경우 아래 소개되는 내용은 이용이 불가능할 수 있으니 버전을 꼭 확인하시기 바랍니다)

 

전반적인 내용은 Selenium의 문서를 가져왔으며, 구성이나 번역은 제가 이해하기 쉽도록 바꾸었습니다. 원본에 대한 내용을 확인하고 싶으신 경우 아래 링크를 확인하시기 바랍니다.

 

Selenium Python Docs

 

적용은 아래 예시처럼 find_element 메서드를 활용해 가능합니다. 만약에 찾고자 하는 요소가 없다면, NoSuchElementException 예외가 발생합니다. 여러 요소를 찾을 경우에는 find_elements 메서드를 활용할 수 있습니다.

 

from selenium import webdriver
from selenium.webdriver.common.by import By
# 기본 작업 : driver 작동 및 웹페이지 접근
driver = webdriver.Chrome()
driver.get("address")

# 페이지 탐색 방법
driver.find_element(By.ID, "id")
driver.find_element(By.NAME, "name")
driver.find_element(By.XPATH, "xpath")
driver.find_element(By.LINK_TEXT, "link text")
driver.find_element(By.PARTIAL_LINK_TEXT, "partial link text")
driver.find_element(By.TAG_NAME, "tag name")
driver.find_element(By.CLASS_NAME, "class name")
driver.find_element(By.CSS_SELECTOR, "css selector")

 

2. Selenium에서 요소(Element) 찾기

2.1. Id 로 찾기

id 속성을 알고 있는 경우, 사용할 수 있는 방법입니다. 아래는 페이지 소스 예시입니다. (이후 케이스에서도 활용)

<html>
 <body>
  <form id="loginForm">
   <input name="username" type="text" />
   <input name="password" type="password" />
   <input name="continue" type="submit" value="Login" />
  </form>
 </body>
</html>

여기서 id인 loginForm을 찾기 위해서는 아래와 같이 코드를 사용합니다.

login_form = driver.find_element(By.ID, 'loginForm')

 

2.2. 이름으로 찾기

이름 속성을 아는 경우 사용할 수 있는 방법입니다. 2.1에서 사용한 페이지 소스 예시에서 username과 password란 name의 요소를 찾고자 하는 경우 아래와 같이 구현이 가능합니다.

username = driver.find_element(By.NAME, 'username')
password = driver.find_element(By.NAME, 'password')

 

2.3. XPath로 찾기

XPath는 W3C 표준으로 XML 문서 구조를 통해 노드를 찾도록 사용되는 언어입니다. XPath는 절대항으로 찾거나 상대적인 속성을 통해 찾을 수 있습니다. 2.1.에서 사용한 페이지 소스 예시에서 loginForm을 XPath를 활용하여 아래 코드와 같이 찾을 수 있습니다.

login_form = driver.find_element(By.XPATH, "/html/body/form[1]")       # Absoulte Path
login_form = driver.find_element(By.XPATH, "//form[1]")                # 1st form element 
login_form = driver.find_element(By.XPATH, "//form[@id='loginForm']")  # attribute id

XPath에 대한 더 세부적인 사용법을 공부할 수 있는 컨텐츠를 W3C나 W3School에서 제공하고 있습니다. XPath를 더욱 잘 활용하기 위해 공부를 하고 싶다면 아래 링크를 통해 접근할 수 있습니다.

W3C XPath

W3School (Xpath_Syntax)

 

2.4. 하이퍼링크 찾기

링크가 달려있는 텍스트를 알고 있다면 사용가능한 방법입니다. 새로운 페이지 소스 예시입니다. 안에는 앵커태그()가 있고 안에는 href 속성을 통해 보여주고자 하는 html을 정의하고 있습니다.

<html>
 <body>
  <p>Are you sure you want to do this?</p>
  <a href="continue.html">Continue</a>
  <a href="cancel.html">Cancel</a>
</body>
</html>

continue.html 로 가는 링크를 찾기 위해서는 아래와 같이 찾을 수 있습니다.

continue_link = driver.find_element(By.LINK_TEXT, 'Continue')
continue_link = driver.find_element(By.PARTIAL_LINK_TEXT, 'Conti')

 

2.5. 태그 이름으로 찾기

BeautifulSoup에서도 많이 활용되는 방법인데, 태그 이름을 가지고도 찾을 수 있습니다. 아래와 같은 페이지 소스가 있다고 가정합니다.

<html>
 <body>
  <h1>Welcome</h1>
  <p>Site content goes here.</p>
</body>
</html>

여기서 "Welcome"이라는 문구가 적힌 h1 태그이름을 알고 있다면 아래와 같이 찾을 수 있습니다.

heading1 = driver.find_element(By.TAG_NAME, 'h1'))

 

2.6. 클래스 이름으로 찾기

태그와 동일하게 클래스 이름으로도 찾을 수 있습니다. (BeautifulSoup을 많이 사용해보신 분들은 익숙하실 것 같습니다) 2.5.와 동일한 페이지 소스 예시에서 "p" 요소를 찾으려면 아래와 같이 구현할 수 있습니다.

content = driver.find_element(By.CLASS_NAME, 'content')

 

2.7. CSS 선택자로 찾기

XPath처럼 여러가지 정보를 담을 수 있는 CSS 선택자(Selector)도 크롤링에서 자주 등장하는 구문해석 방법입니다. 2.5.의 동일한 예시에서 같은 내용을 찾으려면 아래와 같이 구현이 가능합니다.

content = driver.find_element(By.CSS_SELECTOR, 'p.content')

XPath와 마찬가지로, 다양한 기관에서 CSS 선택자에 대한 자료를 제공하고 있으니 참고하시기 바랍니다. (Selenium 프로젝트 웹페이지에서는 아래 링크를 추천하고 있습니다.)

Sauce Labs (CSS documentation)

 

3. 활용

이렇게 구현한 요소를 찾는 것은 Web Element를 반환합니다. 한 예시로 제가 최근에 크롤링 작업을 했던 web element에 담겨있는 정보들입니다.

페이지를 구성하는 여러가지 요소들이 보이는데, 필요한 정보에 접근하기 위해서는 '변수명.정보'와 같이 해서 가져올 수 있습니다.  

반응형
반응형

우리나라는 선관위가 운영하는 선거통계시스템이라는 포털을 통해 역대선거의 통계에 대한 데이터를 제공한다. 최근에 지역별 지자체장들과 관련된 데이터가 필요한 일이 있어서 잠시 살펴봤다. 여기서 수작업으로 데이터를 모으기엔 다소 귀찮은 점이 있는데 아래와 같이 크게 두가지가 있다.

  • 민선7기까지 선출된만큼 살펴봐야하는 횟수 자체가 적지 않다.
  • 광역지자체장은 선거 횟수에 비례해 나오지만, 기초지자체의 경우 가장 최근인 민선7기의 경우 16개에 달해 경우의 수가 증가한다.
  • 위 가정에 따라 계산을 해본다면, 7[광역지자체] + (15 * 6 + 16) [기초지자체] = 113회 정도 데이터를 조회해야 할 필요가 발생

 

http://info.nec.go.kr/

 

아쉽게도 위 사이트는 동적웹페이지라 BeautifulSoup으로 크롤링하기엔 한계가 있다. 이런 경우 (조금은 느리지만) 대안은 Selenium과 브라우저 드라이버(여기선 Chrome)를 활용한 방법으로 활용하는 것이 적절하다. 주의할 점은 Chrome은 매번 (자동으로) 업데이트가 되는만큼, 현재 크롬의 버전과 다른 버전의 드라이버를 사용하는 경우 동작하지 않을 위험이 있다.

 

관련링크:

- Selenium Docs (https://selenium-python.readthedocs.io/)

- Chrome Driver (https://chromedriver.chromium.org/downloads)

코드의 순서는 다음과 같이 동작하도록 구현하였다.

  1. 크롬을 통해 선거통계시스템에 접속
  2. 클릭을 통해 지방선거 당선인 명부에 이동 ("역대선거" > "당선인" > "당선인명부" > "지방선거" 탭을 클릭해 이동)
  3. 콤보박스의 내용을 가져와 리스트로 가져옴
  4. 리스트내 아이템 중 하나를 선택해 "검색" 버튼 클릭
  5. 조회되는 사이트의 표 내용을 가져와 Dataframe에 추가하기
  6. 콤보박스 아이템 리스트의 다음으로 iterate (4-5 반복)
  7. (번외) 한자가 인코딩이 안되는 문제를 해결하기 위해 한문이름 날리기
Version
Python = 3.8
Pandas = 1.3.1
BeautifulSoup = 4.9.3
Selenium = 3.141.0

(참고로 Selenium은 최근에 새로운 버전이 나와 최신버전으로 구현고자 할 경우 아래 코드가 동작하지 않습니다)

 

위 순서를 구현한 코드는 다음과 같다. 읽어보고 이해가 필요하다면, 각 순서별로 쪼개 살펴본 아래를 살펴보길.

import pandas as pd
from selenium import webdriver
from bs4 import BeautifulSoup
import time

def get_data(n_th, city):
    html = driver.page_source
    soup = BeautifulSoup(html, 'html.parser')
    col_name = [col.get_text() for col in soup.find_all('th')]
    data = [d.get_text().strip() for d in soup.find_all('td')]
    df = pd.DataFrame(columns=col_name)
    row_number = int(len(soup.find_all('td')) / len(col_name))
    for i in range(row_number):
        start = i * len(col_name)
        df.loc[len(df)] = data[start:start + len(col_name)]
    df['n_th'] = n_th
    df['city'] = city
    return df

driver = webdriver.Chrome('./venv/chromedriver.exe')
driver.get('http://info.nec.go.kr/')
driver.switch_to.default_content()
driver.switch_to.frame('main')

driver.find_element_by_class_name('eright').click()
driver.implicitly_wait(5)
driver.find_element_by_xpath('//*[@id="presubmu"]/li[5]/a').click()
driver.implicitly_wait(5)
driver.find_element_by_xpath('//*[@id="header"]/div[4]/ul/li[1]/a').click()
driver.implicitly_wait(5)
driver.find_element_by_xpath('//*[@id="electionType4"]').click()
driver.implicitly_wait(5)

df_election = pd.DataFrame()
election_name = driver.find_element_by_xpath('//*[@id="electionName"]')
n_th_election = [option.text for option in election_name.find_elements_by_tag_name("option")]
n_th_election = n_th_election[1:]
for n_th in n_th_election:
    election_name = driver.find_element_by_xpath('//*[@id="electionName"]')
    election_name.send_keys(n_th)
    time.sleep(3)
    election_code = driver.find_element_by_xpath('//*[@id="electionCode"]')
    election_code_lst = [option.text for option in election_code.find_elements_by_tag_name("option")]
    election_code_lst = election_code_lst[1:3]
    for code in election_code_lst:
        election_code = driver.find_element_by_xpath('//*[@id="electionCode"]')
        election_code.send_keys(code)
        time.sleep(3)
        if code == election_code_lst[-1]:
            # 시군구의 장의 경우만
            city_code = driver.find_element_by_xpath('//*[@id="cityCode"]')
            city_code_lst = [option.text for option in city_code.find_elements_by_tag_name("option")]
            city_code_lst = city_code_lst[1:]
            for city in city_code_lst:
                city_code = driver.find_element_by_xpath('//*[@id="cityCode"]')
                city_code.send_keys(city)
                driver.find_element_by_xpath('//*[@id="searchBtn"]').click()
                df_election = pd.concat([df_election, get_data(n_th, city)], ignore_index=True)
                time.sleep(3)
        else:
            driver.find_element_by_xpath('//*[@id="searchBtn"]').click()
            df_election = pd.concat([df_election, get_data(n_th, None)], ignore_index=True)
            time.sleep(3)

    time.sleep(3)

for ind in range(len(df_election)):
    itm = df_election.loc[ind, '성명(한자)']
    itm = itm[:itm.find('(')]
    df_election.loc[ind, '성명(한자)'] = itm

1.크롬을 통해 선거통계시스템에 접속

2.클릭을 통해 지방선거 당선인 명부에 이동

("역대선거" > "당선인" > "당선인명부" > "지방선거" 탭을 클릭해 이동)

우선 크롬드라이버를 통해 통계시스템에 접속하는 과정이다. 크게 설명이 필요한 부분은 아니니 스킵

from selenium import webdriver
import time
# 크롬 드라이버를 통해 통계시스템 접속
driver = webdriver.Chrome('./venv/chromedriver.exe')
driver.get('http://info.nec.go.kr/')
driver.switch_to.default_content()
driver.switch_to.frame('main')
# 지방선거 페이지로 이동
driver.find_element_by_class_name('eright').click()
driver.implicitly_wait(5)
driver.find_element_by_xpath('//*[@id="presubmu"]/li[5]/a').click()
driver.implicitly_wait(5)
driver.find_element_by_xpath('//*[@id="header"]/div[4]/ul/li[1]/a').click()
driver.implicitly_wait(5)
driver.find_element_by_xpath('//*[@id="electionType4"]').click()
driver.implicitly_wait(5)

3. 콤보박스의 내용을 가져와 리스트로 가져옴

선거 유형별로 선택하기 위해서는 화살표를 눌러 목록을 선택하는 방식인 콤보박스를 활용하고 있는데, 개발자도구를 통해 살펴보면 아래와 같이 나오는 것을 확인할 수 있다.

import pandas as pd
# 데이터를 넣을 빈 데이터프레임 생성 
df_election = pd.DataFrame()
# 콤보박스 목록내 아이템 리스트로 만들기
election_name = driver.find_element_by_xpath('//*[@id="electionName"]')
n_th_election = [option.text for option in election_name.find_elements_by_tag_name("option")]
n_th_election = n_th_election[1:]

election_code_lst = [option.text for option in election_code.find_elements_by_tag_name("option")]
election_code_lst = election_code_lst[1:3]

city_code_lst = [option.text for option in city_code.find_elements_by_tag_name("option")]
city_code_lst = city_code_lst[1:]

위에 코드는 선거회차, 선거유형(광역지자체, 기초지자체 등) 및 시도를 보관하기 위한 리스트를 만드는 코드이다. 다만, 내가 원하는 정보만 가져오기 위해 리스트 중 일부만 발췌해서 리스트를 재정의하였다.

(e.g. election_code_lst = ['시도지사선거', '구시군의장선거'])

4. 리스트내 아이템 중 하나를 선택해 "검색" 버튼 클릭

콤보박스는 하나를 선택할 때에는 해당 요소를 찾아 send_keys 메써드를 사용해주면 된다. 여기서 주의할 것이 광역지자체와 기초지자체 선거 페이지의 경우 "시도"에 관한 콤보박스가 있느냐 없느냐 문제가 있는데, 이건 if문으로 해결해주자. 즉, 위에서 만들어준 election_code_lst의 아이템(code) 중 끝에 있는 값을 선택했느냐 여부에 따라 "시도" 콤보박스 관련 코드를 실행여부를 결정하게끔 한다. 코드는 다음과 같이 할 수 있다. (위의 3단계도 같이 포함)

# 회차 선택
for n_th in n_th_election:
    election_name = driver.find_element_by_xpath('//*[@id="electionName"]')
    election_name.send_keys(n_th)
    time.sleep(3)
    election_code = driver.find_element_by_xpath('//*[@id="electionCode"]')
    election_code_lst = [option.text for option in election_code.find_elements_by_tag_name("option")]
    election_code_lst = election_code_lst[1:3]
    # 선거유형 선택
    for code in election_code_lst:
        election_code = driver.find_element_by_xpath('//*[@id="electionCode"]')
        election_code.send_keys(code)
        time.sleep(3)
        if code == election_code_lst[-1]:
            # 시군구의 장의 경우만, 시도 선택
            city_code = driver.find_element_by_xpath('//*[@id="cityCode"]')
            city_code_lst = [option.text for option in city_code.find_elements_by_tag_name("option")]
            city_code_lst = city_code_lst[1:]
            for city in city_code_lst:
                city_code = driver.find_element_by_xpath('//*[@id="cityCode"]')
                city_code.send_keys(city)
                driver.find_element_by_xpath('//*[@id="searchBtn"]').click() #검색버튼 클릭
                time.sleep(3)
        else:
            driver.find_element_by_xpath('//*[@id="searchBtn"]').click() #검색버튼 클릭
            time.sleep(3)

5. 조회되는 사이트의 표 내용을 가져와 Dataframe에 추가하기

일반적으로 BeautifulSoup은 동적웹페이지에서는 힘을 발휘하기 힘들지만, Selenium을 통해 소스를 가져오면 적용이 가능하다. 주어진 테이블의 데이터를 가져오기 위해 열이름을 위한 'th', 데이터를 위한 'td'를 찾아준다. 그렇게 해서 만든 Data frame을 반환하는 함수를 get_data(n_th, city)로 해서 작성해보았다.

from bs4 import BeautifulSoup
def get_data(n_th, city):
    html = driver.page_source
    soup = BeautifulSoup(html, 'html.parser')
    col_name = [col.get_text() for col in soup.find_all('th')]
    data = [d.get_text().strip() for d in soup.find_all('td')]
    df = pd.DataFrame(columns=col_name)
    row_number = int(len(soup.find_all('td')) / len(col_name))
    for i in range(row_number):
        start = i * len(col_name)
        df.loc[len(df)] = data[start:start + len(col_name)]
    df['n_th'] = n_th
    df['city'] = city
    return df

그리고 이렇게 작성한 get_data함수를 기존 데이터 프레임에 추가할 수 있도록 조치해주자.

if code == election_code_lst[-1]:
    df_election = pd.concat([df_election, get_data(n_th, city)], ignore_index=True)
else:
    df_election = pd.concat([df_election, get_data(n_th, None)], ignore_index=True)

6. 콤보박스 아이템 리스트의 다음으로 iterate (4-5 반복)

Iterate은 For문으로 반복하면 되는거고, 위 내용들을 총 종합해서 만들어주면 된다. 코드는 위를 참고

7. (번외) 한자가 인코딩이 안되는 문제를 해결하기 위해 한문이름 날리기

이렇게 가져온 데이터를 활용하기 위해 파이썬 자체에서 처리할 수도 있지만, 엑셀 등으로 옮겨서 처리하고 싶은 경우도 있을 것이다. 이 경우 인코딩 문제가 발생하는데, 성명부분에 한자이름에서 인식을 못하는 문제가 발생한다. 따라서, 이 문제를 해결하기 위해 한자명을 제거하는 코드를 작성하였다.

사실, 한자코드를 직접 읽어서 처리하기 보다는 한자명이 ( )안에 있다는 점에서 착안, '('을 기준으로 데이터클렌징을 해준게 전부인데, 여기서는 잘 작동한다.

for ind in range(len(df_election)):
    itm = df_election.loc[ind, '성명(한자)']
    itm = itm[:itm.find('(')]
    df_election.loc[ind, '성명(한자)'] = itm

사실 데이터가 당장 필요한 상황에서 수작업이 귀찮아서 짠 코드다보니, 예외처리를 넣지 않았거나, 최악의 경우 for문이 3번까지 중첩이 가능하는 등 코드의 효율성을 보자면,,,, 잘 모르겠다.

반응형