← .mpy 공유
사용자 공유시각화

이미지의 밝기 변화 경계를 검출하고 원본·경계 지도·중첩 이미지를 비교한다

이미지의 밝기 변화 경계를 검출하고 원본·경계 지도·중첩 이미지를 비교한다

이미지의 밝기 변화 경계를 검출하고 원본·경계 지도·중첩 이미지를 비교한다 실행 결과 미리보기🔍 클릭하면 원본 크기로 보기

README

이미지의 밝기 변화 경계를 검출하고 원본·경계 지도·중첩 이미지를 비교한다

설명

이미지의 밝기 변화 경계를 검출하고 원본·경계 지도·중첩 이미지를 비교한다

필요 데이터

파일 선택창에서 선택하는 PNG·JPG·BMP·TIFF·WEBP 이미지(df 불필요)

출력

원본·전처리·경계·중첩 그래프와 검출 요약표

태그

visualization

작성자

메이커-4e07 | MDS 1.0

Source · 234줄 (실행 전에 꼭 확인하세요 — 펼치기)
# MDS_DESC: 이미지의 밝기 변화 경계를 검출하고 원본·경계 지도·중첩 이미지를 비교한다
# MDS_TAGS: visualization
# MDS_INPUT: 파일 선택창에서 선택하는 PNG·JPG·BMP·TIFF·WEBP 이미지(df 불필요)
# MDS_OUTPUT: 원본·전처리·경계·중첩 그래프와 검출 요약표
# 밝기 변화 경계를 검출합니다. 물체의 의미를 인식하는 AI 분할은 아닙니다.
# PIL·scipy·numpy·matplotlib은 제공된 MDS 기본 라이브러리를 사용합니다.

from PIL import Image, ImageOps
from scipy import ndimage


def load_image(path: str, max_side: int) -> tuple[np.ndarray, tuple[int, int]]:
    """방향과 투명도를 처리하고 분석 이미지 크기를 제한합니다."""
    with Image.open(path) as source:
        original_size = source.size
        if source.width * source.height > 40_000_000:
            raise ValueError("이미지가 4천만 픽셀을 초과합니다. 크기를 줄여 다시 선택하세요.")

        image = ImageOps.exif_transpose(source)
        image.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)

        if image.mode == "RGBA" or "transparency" in image.info:
            rgba = image.convert("RGBA")
            background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
            image = Image.alpha_composite(background, rgba).convert("RGB")
        else:
            image = image.convert("RGB")

        rgb = np.array(image, dtype=np.uint8)

    if min(rgb.shape[:2]) < 5:
        raise ValueError("가로와 세로가 각각 5픽셀 이상인 이미지를 선택하세요.")
    return rgb, original_size


def detect_edges(
    rgb: np.ndarray,
    sigma: float,
    percentile: float,
    min_pixels: int,
) -> tuple[np.ndarray, np.ndarray, float]:
    """평활화·기울기·비최대 억제·연결 기반 이중 임계값으로 경계를 검출합니다."""
    gray = (
        rgb[:, :, 0].astype(np.float32) * 0.299
        + rgb[:, :, 1].astype(np.float32) * 0.587
        + rgb[:, :, 2].astype(np.float32) * 0.114
    ) / 255.0

    smooth = ndimage.gaussian_filter(gray, sigma=sigma, mode="reflect")
    gx = ndimage.sobel(smooth, axis=1, mode="reflect")
    gy = ndimage.sobel(smooth, axis=0, mode="reflect")
    magnitude = np.hypot(gx, gy)
    angle = np.mod(np.rad2deg(np.arctan2(gy, gx)), 180.0)

    # 기울기 방향의 양옆보다 강한 픽셀만 남겨 경계를 가늘게 만듭니다.
    thin = np.zeros_like(magnitude)
    directions = [
        ((angle < 22.5) | (angle >= 157.5), (0, 1)),
        ((angle >= 22.5) & (angle < 67.5), (1, 1)),
        ((angle >= 67.5) & (angle < 112.5), (1, 0)),
        ((angle >= 112.5) & (angle < 157.5), (1, -1)),
    ]
    for mask, shift in directions:
        forward = np.roll(magnitude, shift, axis=(0, 1))
        backward = np.roll(magnitude, (-shift[0], -shift[1]), axis=(0, 1))
        keep = mask & (magnitude >= forward) & (magnitude >= backward)
        thin[keep] = magnitude[keep]

    thin[0, :] = 0
    thin[-1, :] = 0
    thin[:, 0] = 0
    thin[:, -1] = 0

    positive = thin[thin > 1e-6]
    if positive.size == 0:
        return smooth, np.zeros(gray.shape, dtype=bool), 0.0

    high = float(np.percentile(positive, percentile))
    low = high * 0.4
    structure = np.ones((3, 3), dtype=bool)

    # 강한 경계와 연결된 약한 경계만 유지합니다.
    edges = ndimage.binary_propagation(
        thin >= high,
        structure=structure,
        mask=thin >= low,
    )

    if min_pixels > 1:
        labels, count = ndimage.label(edges, structure=structure)
        if count > 0:
            sizes = np.bincount(labels.ravel())
            accepted = sizes >= min_pixels
            accepted[0] = False
            edges = accepted[labels]

    return smooth, edges, high


def show_results(
    rgb: np.ndarray,
    smooth: np.ndarray,
    edges: np.ndarray,
    summary: pd.DataFrame,
) -> None:
    """검출 결과를 비교하고 요약표를 표시합니다."""
    plt.rcParams["font.family"] = "Malgun Gothic"
    plt.rcParams["axes.unicode_minus"] = False

    overlay = rgb.copy()
    overlay[edges] = [255, 30, 30]

    fig, axes = plt.subplots(2, 2, figsize=(9, 7))
    try:
        axes[0, 0].imshow(rgb)
        axes[0, 0].set_title("원본 — 분석 해상도")
        axes[0, 1].imshow(smooth, cmap="gray", vmin=0, vmax=1)
        axes[0, 1].set_title("노이즈 완화 후 밝기")
        axes[1, 0].imshow(edges, cmap="gray", vmin=0, vmax=1)
        axes[1, 0].set_title("검출된 경계")
        axes[1, 1].imshow(overlay)
        axes[1, 1].set_title("원본 + 빨간색 경계")
        for ax in axes.flat:
            ax.axis("off")
        fig.suptitle("이미지 경계 검출 — 그림자·무늬도 경계로 검출될 수 있습니다")
        fig.tight_layout()
        plt.show()
    finally:
        plt.close(fig)

    mIO.show_df(summary, title="경계 검출 요약")
    return None


def run_image_edges(df: pd.DataFrame) -> str:
    """파일 선택으로 실행하며 기존 df는 변경하지 않습니다."""
    path = mIO.open_file_single(
        dialog_title="경계를 분석할 이미지 선택",
        file_filter="이미지 (*.png *.jpg *.jpeg *.bmp *.tif *.tiff *.webp)",
        initial_dir=None,
    )
    if not path:
        return "이미지 선택을 취소했습니다."

    values = mIO.form(
        [
            {
                "type": "combo",
                "label": "경계 민감도",
                "options": ["높음 — 약한 경계 포함", "보통", "낮음 — 강한 경계 위주"],
                "default": "보통",
            },
            {
                "type": "combo",
                "label": "노이즈 완화",
                "options": ["약하게", "보통", "강하게"],
                "default": "보통",
            },
            {
                "type": "spin",
                "label": "분석 최대 길이(픽셀)",
                "min": 256,
                "max": 2000,
                "default": 1200,
                "step": 100,
            },
            {
                "type": "spin",
                "label": "최소 연결 경계 크기(픽셀)",
                "min": 1,
                "max": 500,
                "default": 10,
                "step": 1,
            },
        ],
        title="경계 검출 설정",
    )
    if values is None:
        return "설정을 취소했습니다."

    percentile = {
        "높음 — 약한 경계 포함": 65.0,
        "보통": 85.0,
        "낮음 — 강한 경계 위주": 95.0,
    }[values["경계 민감도"]]
    sigma = {"약하게": 0.7, "보통": 1.2, "강하게": 2.0}[values["노이즈 완화"]]

    try:
        rgb, original_size = load_image(
            str(path), int(values["분석 최대 길이(픽셀)"])
        )
        smooth, edges, threshold = detect_edges(
            rgb,
            sigma,
            percentile,
            int(values["최소 연결 경계 크기(픽셀)"]),
        )
    except (OSError, ValueError, Image.DecompressionBombError) as error:
        mIO.print(f"이미지 분석 실패: {error}")
        return "이미지를 분석하지 못했습니다."

    edge_count = int(np.count_nonzero(edges))
    ratio = edge_count / edges.size * 100.0
    height, width = edges.shape

    summary = pd.DataFrame(
        {
            "항목": [
                "원본 크기(가로×세로)",
                "분석 크기(가로×세로)",
                "경계 민감도",
                "노이즈 완화",
                "경계 픽셀 수",
                "경계 픽셀 비율(%)",
                "강한 경계 임계값",
                "주의",
            ],
            "값": [
                f"{original_size[0]} × {original_size[1]}",
                f"{width} × {height}",
                values["경계 민감도"],
                values["노이즈 완화"],
                f"{edge_count:,}",
                f"{ratio:.2f}",
                f"{threshold:.6f}",
                "축소된 분석 해상도 기준이며 물체 수·실제 길이를 뜻하지 않습니다.",
            ],
        }
    )
    show_results(rgb, smooth, edges, summary)
    return f"경계 검출 완료: {edge_count:,}픽셀, 비율 {ratio:.2f}%"


result = run_image_edges(df)

댓글