【Python】범용 RAW 이미지 뷰어 및 평가 앱 생성 방법

ImageSensor

이번에는 범용 RAW 이미지를 조회하고 평가하기 위한 Python 애플리케이션 생성 방법을 소개합니다. 이 앱은 헤더 정보가 없는 순수한 이진 데이터의 .raw 파일을 처리할 수 있으며, 이미지 표시, 확대/축소, 팬, 픽셀 값 확인, 범위 선택을 통한 이미지 분석 등 다양한 기능을 갖추고 있습니다.

특징

  • 여러 RAW 이미지의 읽기 및 평균화 표시: 여러 RAW 이미지 파일을 한 번에 읽어 평균화된 이미지를 표시합니다.
  • 이미지의 확대/축소 및 팬: 마우스 조작으로 이미지의 확대/축소 및 이동이 가능합니다.
  • 픽셀 값 확인: 마우스 커서를 이미지 위로 이동하면 해당 위치의 좌표와 픽셀 값이 표시됩니다.
  • 범위 선택 및 분석: 이미지 위에서 범위 선택을 수행하고 해당 영역의 평균 픽셀 값 및 노이즈(표준 편차)를 계산합니다. 또한, 수직 및 수평 방향의 평균 픽셀 값 프로파일을 그래프로 표시합니다.
  • 드래그 앤 드롭 지원: 이미지 파일을 캔버스 위에 드래그 앤 드롭하여 읽어올 수 있습니다.

필요한 라이브러리 설치

이 앱을 작동시키기 위해서는 다음의 Python 라이브러리가 필요합니다. 명령줄에서 다음을 실행하여 설치해 주세요.

pip install numpy pillow matplotlib tkinterdnd2
  • numpy: 수치 계산용 라이브러리
  • Pillow (PIL): 이미지 처리용 라이브러리
  • matplotlib: 그래프 그리기용 라이브러리
  • tkinterdnd2: Tkinter에서 드래그 앤 드롭 기능을 구현하기 위한 라이브러리

코드의 설명



다음은 애플리케이션의 전체 코드입니다.

import tkinter as tk
from tkinter import filedialog, messagebox
import numpy as np
from PIL import Image, ImageTk
import os
import json
import matplotlib.pyplot as plt
from tkinterdnd2 import DND_FILES, TkinterDnD

class RawImageViewer(TkinterDnD.Tk):
    def __init__(self):
        super().__init__()
        self.title('Universal RAW Image Viewer')
        self.geometry('1200x900')
        self.image = None
        self.photo_image = None
        self.zoom_level = 1.0
        self.offset_x = 0
        self.offset_y = 0
        self.last_params = self.load_last_params()
        self.rect = None  # Rectangle for selection
        self.create_widgets()
        self.pixel_window = None  # Window to display surrounding pixel values
        self.bind('<Control-o>', lambda event: self.open_raw_image())  # Open with Ctrl+O

    def create_widgets(self):
        # Create menu bar
        menubar = tk.Menu(self)
        filemenu = tk.Menu(menubar, tearoff=0)
        filemenu.add_command(label="Open (Ctrl+O)", command=self.open_raw_image)
        filemenu.add_separator()
        filemenu.add_command(label="Exit", command=self.quit)
        menubar.add_cascade(label="File", menu=filemenu)
        self.config(menu=menubar)

        # Create frame
        self.frame = tk.Frame(self)
        self.frame.pack(fill=tk.BOTH, expand=True)

        # Create canvas
        self.canvas = tk.Canvas(self.frame, bg='gray', cursor="cross")
        self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        # Create scrollbars
        self.hbar = tk.Scrollbar(self.frame, orient=tk.HORIZONTAL, command=self.canvas.xview)
        self.hbar.pack(side=tk.BOTTOM, fill=tk.X)
        self.vbar = tk.Scrollbar(self.frame, orient=tk.VERTICAL, command=self.canvas.yview)
        self.vbar.pack(side=tk.RIGHT, fill=tk.Y)
        self.canvas.config(xscrollcommand=self.hbar.set, yscrollcommand=self.vbar.set)

        # Bind events
        self.canvas.bind('<MouseWheel>', self.zoom)  # Windows
        self.canvas.bind('<Button-4>', self.zoom)    # Linux (scroll up)
        self.canvas.bind('<Button-5>', self.zoom)    # Linux (scroll down)
        self.canvas.bind('<ButtonPress-1>', self.start_pan)
        self.canvas.bind('<B1-Motion>', self.do_pan)
        self.canvas.bind('<ButtonRelease-1>', self.end_pan)
        self.canvas.bind('<Motion>', self.show_pixel_value)
        self.canvas.bind('<ButtonPress-3>', self.start_rect)
        self.canvas.bind('<B3-Motion>', self.draw_rect)
        self.canvas.bind('<ButtonRelease-3>', self.end_rect)

        # Configure drag and drop
        self.canvas.drop_target_register(DND_FILES)
        self.canvas.dnd_bind('<<Drop>>', self.drop)

        # Create status bar
        self.statusbar = tk.Label(self, text="Please open an image", bd=1, relief=tk.SUNKEN, anchor=tk.W)
        self.statusbar.pack(side=tk.BOTTOM, fill=tk.X)

        # Create zoom buttons
        zoom_in_button = tk.Button(self, text="Zoom In (+)", command=self.zoom_in)
        zoom_in_button.pack(side=tk.LEFT, padx=5, pady=5)
        zoom_out_button = tk.Button(self, text="Zoom Out (-)", command=self.zoom_out)
        zoom_out_button.pack(side=tk.LEFT, padx=5, pady=5)

    def open_raw_image(self):
        file_paths = filedialog.askopenfilenames(filetypes=[("RAW files", "*.raw"), ("All files", "*.*")])
        if file_paths:
            self.load_images(file_paths)

    def drop(self, event):
        # Debug output to check event data
        print("Drop event data:", event.data)
        # Parse file paths
        file_paths = self.parse_drop_files(event.data)
        if file_paths:
            self.load_images(file_paths)
        else:
            messagebox.showerror("Error", "Failed to load files.")

    def parse_drop_files(self, data):
        # Parse file paths from drag-and-drop data
        import re
        # Strip leading and trailing whitespace
        data = data.strip()
        # Regular expression to extract file paths
        pattern = r'{(.*?)}|"(.*?)"|\'(.*?)\'|(\S+)'
        matches = re.findall(pattern, data)
        file_paths = []
        for match in matches:
            path = next(filter(None, match))
            # Adjust path encoding if necessary
            if path.startswith('file://'):
                path = path[7:]
            path = path.replace('\\', '/')
            file_paths.append(path)
        return file_paths

    def load_images(self, file_paths):
        print("Loading images:", file_paths)
        try:
            # Input image parameters through a custom dialog
            params = self.get_image_params()
            if params is None:
                return  # If canceled
            width, height, channels, bit_depth = params

            # Save parameters
            self.save_last_params(params)

            # Determine data type
            if bit_depth == 8:
                dtype = np.uint8
            elif bit_depth == 16:
                dtype = np.uint16
            else:
                messagebox.showerror("Error", "Unsupported bit depth.")
                return

            images = []
            for file_path in file_paths:
                # Read binary data from file
                with open(file_path, 'rb') as f:
                    raw_data = f.read()

                # Calculate expected data size
                expected_size = width * height * channels * (bit_depth // 8)
                if len(raw_data) < expected_size:
                    messagebox.showerror("Error", f"File size is insufficient.\nFile: {os.path.basename(file_path)}")
                    return
                elif len(raw_data) > expected_size:
                    messagebox.showwarning("Warning", f"File size is too large. Extra data will be ignored.\nFile: {os.path.basename(file_path)}")
                    raw_data = raw_data[:expected_size]

                # Convert binary data to NumPy array
                image = np.frombuffer(raw_data, dtype=dtype)
                image = image.reshape((height, width, channels))

                # Scale 16-bit images to 8-bit
                if bit_depth == 16:
                    image = (image / 256).astype('uint8')

                # Convert to PIL image
                if channels == 1:
                    image = image.reshape((height, width))
                    pil_image = Image.fromarray(image, mode='L')
                elif channels == 3:
                    pil_image = Image.fromarray(image, mode='RGB')
                else:
                    messagebox.showerror("Error", f"Unsupported number of channels.\nFile: {os.path.basename(file_path)}")
                    return

                images.append(np.array(pil_image, dtype=np.float32))  # Use float32 for accumulation

            # Average the images
            averaged_image = np.mean(images, axis=0).astype('uint8')
            self.image = Image.fromarray(averaged_image, mode='L') if channels == 1 else Image.fromarray(averaged_image, mode='RGB')

            # Reset zoom level and offsets
            self.zoom_level = 1.0
            self.offset_x = 0
            self.offset_y = 0

            # Clear canvas and place the image
            self.update_image()

        except Exception as e:
            messagebox.showerror("Error", f"Failed to load images.\n{e}")

    def get_image_params(self):
        dialog = ImageParamsDialog(self, self.last_params)
        self.wait_window(dialog.top)
        return dialog.result

    def save_last_params(self, params):
        with open('last_params.json', 'w') as f:
            json.dump(params, f)

    def load_last_params(self):
        if os.path.exists('last_params.json'):
            with open('last_params.json', 'r') as f:
                params = json.load(f)
                return params
        else:
            return None

    def update_image(self):
        if self.image is None:
            return

        # Apply zoom
        width, height = self.image.size
        resized_image = self.image.resize((int(width * self.zoom_level), int(height * self.zoom_level)), Image.NEAREST)

        # Convert PIL image to a format displayable in Tkinter
        self.photo_image = ImageTk.PhotoImage(resized_image)

        # Clear canvas and redraw image
        self.canvas.delete("all")
        self.image_on_canvas = self.canvas.create_image(self.offset_x, self.offset_y, anchor=tk.NW, image=self.photo_image)
        self.canvas.config(scrollregion=self.canvas.bbox(tk.ALL))

    def zoom_in(self):
        self.zoom_level *= 1.2
        self.update_image()

    def zoom_out(self):
        self.zoom_level /= 1.2
        self.update_image()

    def zoom(self, event):
        if self.image is None:
            return
        if event.delta > 0 or event.num == 4:
            self.zoom_in()
        elif event.delta < 0 or event.num == 5:
            self.zoom_out()

    def start_pan(self, event):
        if self.image is None:
            return
        self.canvas.scan_mark(event.x, event.y)

    def do_pan(self, event):
        if self.image is None:
            return
        self.canvas.scan_dragto(event.x, event.y, gain=1)
        self.offset_x = self.canvas.canvasx(0)
        self.offset_y = self.canvas.canvasy(0)

    def end_pan(self, event):
        if self.image is None:
            return
        self.offset_x = self.canvas.canvasx(0)
        self.offset_y = self.canvas.canvasy(0)

    def show_pixel_value(self, event):
        if self.image is None:
            self.statusbar.config(text="Please open an image")
            return

        # Convert canvas coordinates to image coordinates
        canvas_coords = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y)
        x = int((canvas_coords[0] - self.offset_x) / self.zoom_level)
        y = int((canvas_coords[1] - self.offset_y) / self.zoom_level)
        if 0 <= x < self.image.width and 0 <= y < self.image.height:
            pixel = self.image.getpixel((x, y))
            self.statusbar.config(text=f"Coordinates: ({x}, {y}) Pixel Value: {pixel}")
        else:
            self.statusbar.config(text="Outside of image")

    def start_rect(self, event):
        if self.image is None:
            return
        self.rect_start_x = self.canvas.canvasx(event.x)
        self.rect_start_y = self.canvas.canvasy(event.y)
        self.rect = self.canvas.create_rectangle(self.rect_start_x, self.rect_start_y, self.rect_start_x, self.rect_start_y, outline='red')

    def draw_rect(self, event):
        if self.rect is None:
            return
        self.rect_end_x = self.canvas.canvasx(event.x)
        self.rect_end_y = self.canvas.canvasy(event.y)
        self.canvas.coords(self.rect, self.rect_start_x, self.rect_start_y, self.rect_end_x, self.rect_end_y)

    def end_rect(self, event):
        if self.rect is None:
            return
        # Get selection area
        x0 = int((min(self.rect_start_x, self.rect_end_x) - self.offset_x) / self.zoom_level)
        y0 = int((min(self.rect_start_y, self.rect_end_y) - self.offset_y) / self.zoom_level)
        x1 = int((max(self.rect_start_x, self.rect_end_x) - self.offset_x) / self.zoom_level)
        y1 = int((max(self.rect_start_y, self.rect_end_y) - self.offset_y) / self.zoom_level)

        # Crop the selected region from the image
        region = self.image.crop((x0, y0, x1, y1))
        # Convert to NumPy array
        region_data = np.array(region)
        # Calculate mean value and noise (standard deviation)
        mean_value = np.mean(region_data)
        noise_value = np.std(region_data)
        messagebox.showinfo("Calculation Result", f"Average Pixel Value: {mean_value:.2f}\nNoise (Std Dev): {noise_value:.2f}")

        # Plot the profile
        self.plot_profile(region_data)

        # Delete the rectangle
        self.canvas.delete(self.rect)
        self.rect = None

    def plot_profile(self, data):
        # Set Japanese font for matplotlib
        from matplotlib import font_manager

        # Specify the font path (example for Windows)
        font_path = 'C:/Windows/Fonts/msgothic.ttc'  # MS Gothic font

        # Check if the font exists
        if not os.path.exists(font_path):
            messagebox.showerror("Error", f"Japanese font not found.\nPlease check {font_path}.")
            return

        font_prop = font_manager.FontProperties(fname=font_path)
        plt.rcParams['font.family'] = font_prop.get_name()

        # Calculate horizontal and vertical mean profiles
        hor_profile = np.mean(data, axis=0)
        ver_profile = np.mean(data, axis=1)
        # Plot the profiles
        plt.figure(figsize=(10, 5))
        plt.subplot(2,1,1)
        plt.plot(hor_profile, color='blue')
        plt.title('Horizontal Average Pixel Value', fontproperties=font_prop)
        plt.xlabel('Pixel Position', fontproperties=font_prop)
        plt.ylabel('Average Pixel Value', fontproperties=font_prop)
        plt.subplot(2,1,2)
        plt.plot(ver_profile, color='green')
        plt.title('Vertical Average Pixel Value', fontproperties=font_prop)
        plt.xlabel('Pixel Position', fontproperties=font_prop)
        plt.ylabel('Average Pixel Value', fontproperties=font_prop)
        plt.tight_layout()
        plt.show()

class ImageParamsDialog:
    def __init__(self, parent, last_params=None):
        self.result = None
        self.top = tk.Toplevel(parent)
        self.top.title("Enter Image Parameters")
        self.top.grab_set()

        tk.Label(self.top, text="Image Width (pixels):").grid(row=0, column=0, padx=10, pady=5, sticky='e')
        tk.Label(self.top, text="Image Height (pixels):").grid(row=1, column=0, padx=10, pady=5, sticky='e')
        tk.Label(self.top, text="Number of Channels (1: Grayscale, 3: RGB):").grid(row=2, column=0, padx=10, pady=5, sticky='e')
        tk.Label(self.top, text="Bit Depth (8 or 16):").grid(row=3, column=0, padx=10, pady=5, sticky='e')

        self.width_entry = tk.Entry(self.top)
        self.height_entry = tk.Entry(self.top)
        self.channels_entry = tk.Entry(self.top)
        self.bit_depth_entry = tk.Entry(self.top)

        self.width_entry.grid(row=0, column=1, padx=10, pady=5)
        self.height_entry.grid(row=1, column=1, padx=10, pady=5)
        self.channels_entry.grid(row=2, column=1, padx=10, pady=5)
        self.bit_depth_entry.grid(row=3, column=1, padx=10, pady=5)

        # Set default values from last input
        if last_params:
            self.width_entry.insert(0, str(last_params[0]))
            self.height_entry.insert(0, str(last_params[1]))
            self.channels_entry.insert(0, str(last_params[2]))
            self.bit_depth_entry.insert(0, str(last_params[3]))

        self.ok_button = tk.Button(self.top, text="OK", command=self.ok)
        self.ok_button.grid(row=4, column=0, padx=10, pady=10)
        self.cancel_button = tk.Button(self.top, text="Cancel", command=self.cancel)
        self.cancel_button.grid(row=4, column=1, padx=10, pady=10)

        self.width_entry.focus_set()
        self.top.protocol("WM_DELETE_WINDOW", self.cancel)

    def ok(self):
        try:
            width = int(self.width_entry.get())
            height = int(self.height_entry.get())
            channels = int(self.channels_entry.get())
            bit_depth = int(self.bit_depth_entry.get())
            if width <= 0 or height <= 0:
                raise ValueError
            if channels not in (1, 3):
                raise ValueError
            if bit_depth not in (8, 16):
                raise ValueError
            self.result = [width, height, channels, bit_depth]
            self.top.destroy()
        except ValueError:
            messagebox.showerror("Input Error", "Please enter valid numbers.")

    def cancel(self):
        self.result = None
        self.top.destroy()

if __name__ == "__main__":
    app = RawImageViewer()
    app.mainloop()


앱 사용 방법

1. 프로그램 실행

위 코드를 raw_image_viewer.py로 저장하고 Python 3 환경에서 실행합니다.

python raw_image_viewer.py

2. 이미지 불러오기

  • 드래그 앤 드롭으로 불러오는 경우:드래그 앤 드롭으로 불러오는 경우:
    • RAW 이미지 파일을 캔버스 위로 드래그 앤 드롭합니다.
    • 이미지 매개변수 입력 대화상자가 표시되므로, 아래 정보를 입력합니다.
      • 이미지 너비(픽셀)
      • 이미지 높이(픽셀)
      • 채널 수(1 또는 3)
      • 비트 깊이(8 또는 16)
  • 파일 대화상자에서 불러오는 경우:
    • 메뉴 바의 “파일”에서 “열기 (Ctrl+O)”를 선택합니다.
    • 여러 개의 RAW 이미지 파일을 선택합니다.

3. 이미지 조작

  • 확대/축소: 마우스 휠이나 “확대 (+)”, “축소 (-)” 버튼으로 이미지를 확대하거나 축소할 수 있습니다.
  • 이미지 이동: 마우스 왼쪽 버튼을 누른 채로 드래그하여 이미지를 이동할 수 있습니다.
  • 픽셀 값 확인: 마우스 커서를 이미지 위로 이동하면 상태 표시줄에 좌표와 픽셀 값이 표시됩니다.
  • 범위 선택 및 분석:
    • 마우스 오른쪽 버튼을 누른 채로 드래그하여 범위를 선택합니다.
    • 선택 범위를 종료하면 해당 영역의 평균 픽셀 값과 노이즈 값(표준 편차)이 표시됩니다.
    • 또한, 선택 범위의 세로 및 가로 방향의 평균 픽셀 값이 그래프로 표시됩니다.

주의 사항

  • 파일 형식: 이 프로그램은 헤더 정보가 없는 순수 이진 데이터 .raw 파일에 대응합니다. 카메라 제조사 고유의 RAW 형식(예: CR2, NEF, ARW 등)에는 대응하지 않습니다.
  • 이미지 매개변수: 이미지의 너비, 높이, 채널 수, 비트 깊이가 정확하지 않으면 이미지가 올바르게 표시되지 않습니다. 정확한 값을 입력해 주십시오.
  • 면책 사항
    본 애플리케이션의 이용으로 인해 발생하는 모든 손해나 불이익에 대해 당사는 일체의 책임을 지지 않습니다. 이용은 본인의 책임 하에 진행해 주시기 바랍니다. 본 애플리케이션을 사용할 때는 사전 데이터 백업 등 충분한 주의와 대책을 강구해 주십시오. 또한, 본 애플리케이션의 동작이나 결과에 대해 보증하지 않습니다.

요약

이 범용 RAW 이미지 뷰어 및 평가 애플리케이션은 간단한 조작으로 RAW 이미지를 조회하고 분석할 수 있는 유용한 도구입니다. 특히 여러 이미지를 평균화하여 노이즈를 감소시키거나 특정 영역의 픽셀 값을 자세히 조사하는 데 도움이 됩니다.

이 애플리케이션을 활용하여 RAW 이미지의 분석 및 평가에 유용하게 사용하시기 바랍니다.

コメント

제목과 URL을 복사했습니다