파이썬으로 MacOS용 간편 사진 편집기 만들어보기! (GPT와 함께 파이썬 코드를 짜봅시다.)

간단한 이미지 편집 프로그램 만들기: Python과 Automator로 나만의 도구 뚝딱!

1. 왜 만들게 됐나요?

블로그 포스팅을 위해 매번 사진 편집을 위해 무거운 프로그램을 실행하는 건 시간도 아깝고 번거로웠어요. 특히 단순한 회전이나 크롭만 필요한 경우, "이거 하나 하려고 이걸 다 설치해야 해?"라는 생각이 들더라구요. macOS에서 기본적으로 제공하는 '미리보기' 앱은 이미지 회전 기능이 없고, 사진 앱은 라이브러리에 이미지를 추가해야만 사용할 수 있다는 제약이 있습니다. 시중의 다른 앱들은 대부분 유료이거나, 필요 이상으로 무거운 기능들이 포함되어 있죠. 그래서 GPT와 함께 Python으로 간단한 스크립트를 작성하고, Automator로 맥에서 바로 실행할 수 있는 워크플로우를 만들기로 결심했답니다!

파이썬을 통해 이미지 로테이터를 만들어 보았습니다.

그래서 저는 GPT에 아래와 같은 요구사항을 작성했어요.
- Macos 사용 중인데, 사진의 수평을 맞추는 rotate 기능을 제공하는 가벼운 프로그램이 없어, 전부 유료로 제공하고 있거나 프로그램이 너무 무거워. 
- 애플 사진 네이티브 앱의 기능이 딱인데 사진 앱은 라이브러리에 사진을 넣어야만 하더라고, 그게 아니라 그냥 사진 파일에다 가볍게 쓰고 싶어. 우리 이거 파이썬으로 만들어보자. 필요한 기능은 이거야. 
- 사진을 그 프로그램으로 열 수 있어야돼. (마우스 우버튼 누르고 해당 프로그램으로 열기) 
- 기본적으로 crop을 지원해야돼. gui형태로 마우스로 사각형 모서리 4군데를 드래그해서 조절할 수 있어야돼. 비율은 깨져도 무관해. 
- 크롭 기능 중에, rotate기능도 있어야 돼 좌 우 방향으로 1도씩 이동할 수 있도록 디폴트는 0 마우스로 드래그해서 -방향이나 +방향으로 1도씩 움직일 수 있어야돼.
 - GUI 형태여야하고 크롭이나 rotate는 내가 올린 파일이 프리뷰되어야돼. 
- 가장 중요한건 rotate 할때 여백이 생기면 그게 확대되면서 autocrop도 되어야 돼.

그러자 GPT가 파이썬 코드를 출력해줬습니다. 

2. Python으로 이미지 로테이터 개발하기

Python은 OpenCVPillow 라이브러리를 통해 이미지 처리를 쉽게 구현할 수 있어요. 아래는 핵심 기능입니다:

  • 이미지 회전: 슬라이더로 각도 조절
  • 크롭: 마우스 드래그로 원하는 영역 선택
  • 저장: 편집 후 JPEG로 저장
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import cv2
import numpy as np
from PIL import Image, ImageTk
import time
import os
import sys

class ImageRotator:
    def __init__(self, root):
        self.root = root
        self.root.title("Image Rotator")
        self.root.geometry("800x600")
        
        self.original_image = None
        self.cached_image = None
        self.display_image = None
        self.current_angle = 0
        self.last_update = time.time()
        self.update_interval = 0.05
        
        self.setup_ui()
        
        # 커맨드라인 인자 처리
        if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]):
            self.load_image_from_path(sys.argv[1])
    
    def setup_ui(self):
        self.main_frame = ttk.Frame(self.root)
        self.main_frame.pack(fill=tk.BOTH, expand=True)
        
        self.toolbar = ttk.Frame(self.main_frame)
        self.toolbar.pack(fill=tk.X)
        
        ttk.Button(self.toolbar, text="이미지 열기", command=self.load_image).pack(side=tk.LEFT, padx=5, pady=5)
        ttk.Button(self.toolbar, text="초기화", command=self.reset_image).pack(side=tk.LEFT, padx=5, pady=5)
        ttk.Button(self.toolbar, text="저장", command=self.save_image).pack(side=tk.LEFT, padx=5, pady=5)
        
        self.angle_label = ttk.Label(self.toolbar, text="회전 각도: 0°")
        self.angle_label.pack(side=tk.RIGHT, padx=5)
        
        self.canvas = tk.Canvas(self.main_frame, bg='gray')
        self.canvas.pack(fill=tk.BOTH, expand=True)
        
        self.rotation_slider = ttk.Scale(
            self.main_frame,
            from_=-180,
            to=180,
            orient='horizontal',
            command=self.on_rotation_change
        )
        self.rotation_slider.pack(fill=tk.X, padx=10, pady=5)
        
        self.canvas.bind("", self.on_button_press)
        self.canvas.bind("", self.on_mouse_drag)
        self.canvas.bind("", self.on_button_release)
        
    def load_image(self):
        file_path = filedialog.askopenfilename(
            filetypes=[("이미지 파일", "*.jpg *.jpeg *.png *.bmp")]
        )
        if file_path:
            self.load_image_from_path(file_path)
    
    def load_image_from_path(self, file_path):
        try:
            img = cv2.imread(file_path)
            if img is None:
                raise Exception("이미지를 불러올 수 없습니다.")
            
            self.source_path = file_path
            self.original_image = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            self.cached_image = None
            self.reset_image()
            
        except Exception as e:
            messagebox.showerror("오류", str(e))
    
    def on_rotation_change(self, value):
        if self.original_image is None or time.time() - self.last_update < self.update_interval:
            return
            
        try:
            self.current_angle = float(value)
            self.rotate_and_crop()
            self.last_update = time.time()
        except ValueError:
            pass
            
    def rotate_and_crop(self):
        if self.original_image is None:
            return
            
        if self.cached_image is None:
            self.cached_image = self.original_image.copy()
            
        height, width = self.cached_image.shape[:2]
        angle_rad = np.radians(self.current_angle % 180)
        cos_a = np.abs(np.cos(angle_rad))
        sin_a = np.abs(np.sin(angle_rad))
        scale = 1 / min(cos_a + sin_a, max(cos_a, sin_a))
        
        scaled_size = (int(width * scale), int(height * scale))
        scaled_image = cv2.resize(self.cached_image, scaled_size, interpolation=cv2.INTER_LANCZOS4)
        
        center = (scaled_size[0] // 2, scaled_size[1] // 2)
        rotation_matrix = cv2.getRotationMatrix2D(center, -self.current_angle, 1.0)
        
        rotated = cv2.warpAffine(scaled_image, rotation_matrix, scaled_size, flags=cv2.INTER_LANCZOS4, borderMode=cv2.BORDER_REFLECT)
        
        start_x = (scaled_size[0] - width) // 2
        start_y = (scaled_size[1] - height) // 2
        
        self.display_image = rotated[start_y:start_y+height, start_x:start_x+width]
        self.update_display()
        
    def update_display(self):
        if self.display_image is None:
            return
            
        canvas_width = self.canvas.winfo_width()
        canvas_height = self.canvas.winfo_height()
        
        if canvas_width <= 1:
            canvas_width = self.root.winfo_width() - 20
        if canvas_height <= 1:
            canvas_height = self.root.winfo_height() - 100
            
        image_ratio = self.display_image.shape[1] / self.display_image.shape[0]
        canvas_ratio = canvas_width / canvas_height
        
        if image_ratio > canvas_ratio:
            new_width = canvas_width
            new_height = int(canvas_width / image_ratio)
        else:
            new_height = canvas_height
            new_width = int(canvas_height * image_ratio)
            
        resized = cv2.resize(self.display_image, (new_width, new_height), interpolation=cv2.INTER_AREA)
        
        self.photo = ImageTk.PhotoImage(Image.fromarray(resized))
        
        self.canvas.delete("all")
        self.canvas.create_image(canvas_width//2, canvas_height//2, image=self.photo, anchor="center")
        
        self.angle_label.config(text=f"회전 각도: {self.current_angle:.1f}°")
        
    def on_button_press(self, event):
        self.start_x = event.x
        self.start_y = event.y
        self.rect = self.canvas.create_rectangle(self.start_x, self.start_y, self.start_x, self.start_y, outline='red')
        
    def on_mouse_drag(self, event):
        self.canvas.coords(self.rect, self.start_x, self.start_y, event.x, event.y)
        
    def on_button_release(self, event):
        if self.rect:
            x1, y1, x2, y2 = self.canvas.coords(self.rect)
            self.canvas.delete(self.rect)
            self.crop_image(x1, y1, x2, y2)
            
    def crop_image(self, x1, y1, x2, y2):
        if self.display_image is None:
            return
            
        canvas_width = self.canvas.winfo_width()
        canvas_height = self.canvas.winfo_height()
        
        image_width = self.display_image.shape[1]
        image_height = self.display_image.shape[0]
        
        x1 = int((x1 / canvas_width) * image_width)
        y1 = int((y1 / canvas_height) * image_height)
        x2 = int((x2 / canvas_width) * image_width)
        y2 = int((y2 / canvas_height) * image_height)
        
        self.display_image = self.display_image[y1:y2, x1:x2]
        self.update_display()
        
    def reset_image(self):
        if self.original_image is not None:
            self.current_angle = 0
            self.rotation_slider.set(0)
            self.display_image = self.original_image.copy()
            self.cached_image = None
            self.update_display()
            
    def save_image(self):
        if self.display_image is None or self.source_path is None:
            return
            
        try:
            save_path = os.path.splitext(self.source_path)[0] + '.jpg'
            save_image = cv2.cvtColor(self.display_image, cv2.COLOR_RGB2BGR)
            cv2.imwrite(save_path, save_image, [cv2.IMWRITE_JPEG_QUALITY, 95])
            messagebox.showinfo("성공", "이미지가 저장되었습니다!")
            
        except Exception as e:
            messagebox.showerror("오류", f"저장 실패: {str(e)}")

if __name__ == "__main__":
    root = tk.Tk()
    app = ImageRotator(root)
    root.mainloop()

3. Automator로 1초 만에 실행하기

터미널에서 매번 스크립트를 실행하는 건 귀찮죠? Automator를 사용하면 Finder에서 이미지 우클릭만으로 프로그램을 실행할 수 있어요!

finder에서 스크립트를 이미지에 불러올 수 있도록 automator를 통해 셋팅합니다.

  1. 가상환경 설정:
        python3 -m venv venv
    source venv/bin/activate
    pip install opencv-python numpy Pillow
        
  2. Automator Quick Action 만들기:
    • 워크플로우 유형: Quick Action 선택
    • "받는 항목"에서 이미지 파일 지정
    • 셸 스크립트에 실행 코드 추가
      빠른동작에 직접 만든 이미지로테이터가 추가된 모습입니다.

4. 실제 사용 후기

장점: 프로그램 설치 없음, 가볍고 빠름, 무료!
단점: 고급 기능은 없지만 기본 편집에는 충분해요.
활용: 블로그 썸네일 크롭, SNS용 사진 회전 등에 유용합니다.

불러와진 이미지를 로테이트 하고 크롭 할 수있는 간단 편집기입니다.

5. 마치며

이미지 편집을 위해 무거운 프로그램을 켤 필요가 없어졌어요! GPT와 함께 만들어본 Python과 Automator 조합은 단순해 보이지만 생산성을 폭발시키는 강력한 도구가 됐답니다. 여러분도 직접 필터 적용이나 텍스트 삽입 같은 커스텀 기능을 추가해보세요!

간단한 도구가 일상의 번거로움을 해결해준다면, 그것이 바로 최고의 효율이죠.
블로그에 전체 코드와 상세 설명을 올려두었으니 참고해주세요! 😊

🚀 프로 버전 업그레이드 팁

  • 이미지 필터 기능 추가: cv2.filter2D() 함수로 엣지 검출 구현
  • 배치 처리 기능: 폴더 내 모든 이미지 자동 크롭 기능 추가
  • 단축키 설정: 'Ctrl+S'로 저장 기능 연결
다음 이전

POST ADS1

POST ADS 2