웨스턴 블롯 정량 — numpy 이미지 배열에서 밴드 강도 재현 가능하게 측정
이 토픽을 마치면
교과서에서 배운 numpy 배열, 상관관계, 예외 처리 를 엮어서, 웨스턴 블롯 젤 이미지에서 밴드 강도를 자동 측정하고 loading control로 정규화하는 도구를 직접 만들 수 있습니다. ImageJ의 클릭 작업을 파이썬 스크립트로 대체해 재현 가능성 을 확보합니다.
이 글은 교육용 일반 예제 입니다. 실전 densitometry는 이미지의 자동 밴드 검출, 배경 보정, 포화 감지 등 더 정교한 처리를 요구합니다.
"ImageJ로 100번 클릭" — 손 정량의 함정
여러분이 대학원 3년 차에 처음 웨스턴 정량을 배웠던 방식은 대개 이럴 겁니다.
- ImageJ로 이미지 열기
- Rectangle Tool로 밴드 하나 감싸기
Analyze > Measure클릭- 결과 창에서 IntDen 값 복사
- 엑셀에 붙여넣기
- 다음 밴드... 반복
젤 하나에 8개 lane × 3개 항체 = 24번. 젤이 10장이면 240번. 손이 아프고 실수가 나옵니다.
이 접근의 실전 문제:
문제 1: 재현성. 여러분이 그린 rectangle 위치가 정확히 어디였는지 기록되지 않습니다. 리뷰어가 "이 밴드 강도를 어떻게 측정했나요?" 물으면 답할 방법이 없습니다.
문제 2: 배경 보정의 자의성. 배경을 어디로 잡느냐에 따라 최종 강도가 크게 달라집니다. ImageJ의 배경 감산 옵션을 매번 같은 값으로 쓰기가 어렵습니다.
문제 3: 규모. 논문 마감 전날 젤 이미지가 20장 더 추가되면 손 작업으로는 감당 불가.
진짜 접근은 파이썬 스크립트로 정량 파이프라인을 짜는 것입니다. 각 lane의 위치를 좌표로 명시하고, 밴드 영역의 픽셀 합을 numpy로 계산하고, loading control로 정규화하고, 결과를 CSV로 저장합니다. 정확히 같은 스크립트로 정확히 같은 결과가 재현됩니다.
블랙박스에서 부품으로
부품 1: 이미지 = numpy 2D 배열
젤 이미지는 각 픽셀이 밝기 값을 가진 2D 배열입니다. 파이썬에서는 PIL 이나 imageio 로 로드해 numpy 배열로 변환합니다.
import numpy as npfrom PIL import Image
def load_blot_image(path: str) -> np.ndarray: img = Image.open(path).convert("L") # 흑백으로 return np.array(img)
image = load_blot_image("western_blot_01.png")print(image.shape) # 예: (400, 800) — 세로 400, 가로 800 픽셀print(image.dtype) # uint8 — 0 ~ 255 밝기주의: 8-bit 이미지는 밝기 0~255. 어두운 밴드가 낮은 값입니다. densitometry는 대개 반전해서 씁니다 — 밝기 대신 밀도(density) = 255 - brightness 로.
density = 255 - image부품 2: ROI (Region of Interest) 선택
밴드가 있는 사각 영역을 좌표로 지정합니다.
def measure_band(density: np.ndarray, roi: tuple[int, int, int, int]) -> float: """ roi = (top, left, bottom, right) 반환: 영역 내 픽셀 밀도 합 (integrated density) """ top, left, bottom, right = roi region = density[top:bottom, left:right] return float(region.sum())
band_intensity = measure_band(density, (100, 200, 130, 260))print(f"밴드 강도: {band_intensity}")이 함수는 정확히 ImageJ의 IntDen(Integrated Density)과 같은 값을 계산합니다.
부품 3: 배경 보정
밴드 근처의 배경 영역도 측정해 감산합니다.
def measure_band_with_background( density: np.ndarray, band_roi: tuple[int, int, int, int], bg_roi: tuple[int, int, int, int]) -> float: band = measure_band(density, band_roi) bg_area = (bg_roi[2] - bg_roi[0]) * (bg_roi[3] - bg_roi[1]) band_area = (band_roi[2] - band_roi[0]) * (band_roi[3] - band_roi[1]) bg_density_per_pixel = measure_band(density, bg_roi) / bg_area background_contribution = bg_density_per_pixel * band_area return band - background_contribution이 함수는 밴드 영역의 총 밀도에서 같은 크기 배경의 예상 밀도를 뺍니다. 배경을 밴드보다 넓게 잡을 수 있으므로 픽셀당 밀도로 정규화한 뒤 곱합니다.
부품 4: Loading Control 정규화
웨스턴에서는 loading control (대개 GAPDH 또는 β-actin)의 강도로 정규화합니다. 이유는 각 lane에 로드된 총 단백질 양의 미세한 차이를 보정하기 위함입니다.
def normalize_to_control(target: float, control: float) -> float: if control == 0: raise ValueError("Loading control cannot be zero") return target / control여러 lane을 한 번에 처리:
def quantify_gel( density: np.ndarray, target_rois: list[tuple[int, int, int, int]], control_rois: list[tuple[int, int, int, int]], background_roi: tuple[int, int, int, int]) -> list[float]: """ 각 lane마다 target 강도와 control 강도를 측정, 정규화된 값을 반환. """ if len(target_rois) != len(control_rois): raise ValueError("Target and control ROIs must have same length") normalized = [] for target_roi, control_roi in zip(target_rois, control_rois): target = measure_band_with_background(density, target_roi, background_roi) control = measure_band_with_background(density, control_roi, background_roi) normalized.append(normalize_to_control(target, control)) return normalized실전 검증 — Loading Control과의 상관관계
정규화가 잘 되었는지 확인하는 방법이 있습니다. loading control 강도들이 서로 크게 다르다면, 각 lane의 로드량 자체가 크게 달랐다는 신호입니다. 정규화 없이 target 강도를 그대로 쓰면 이 차이가 결과를 오염시킵니다.
def check_loading_uniformity(control_intensities: list[float]) -> dict: arr = np.array(control_intensities) mean = float(arr.mean()) std = float(arr.std()) cv = std / mean if mean > 0 else float("inf") return { "mean": mean, "std": std, "cv": cv, # coefficient of variation "acceptable": cv < 0.2 # 20% 이하가 안전 범위 }CV(변동계수)가 20% 이상이면, 젤 로드가 크게 불균등했다는 신호입니다. 이 경우 실험을 다시 하거나, 정규화 이후에도 결과 해석에 주의가 필요합니다.
Target과 control의 상관관계도 확인합니다. 강한 양의 상관관계 가 있다면 loading 차이가 target 측정치를 지배한다는 뜻이고, 이는 정규화의 필요성을 정당화합니다.
def check_target_control_correlation( targets: list[float], controls: list[float]) -> float: t_arr = np.array(targets) c_arr = np.array(controls) return float(np.corrcoef(t_arr, c_arr)[0, 1])값이 0.7 이상이면 강한 정규화 효과. 값이 낮으면 loading 차이가 크지 않아 정규화가 결과에 큰 영향을 주지 않습니다.
페이딩 — 여러분이 채워야 할 세 빈칸
빈칸 1: 자동 lane 검출
lane 위치를 수동으로 지정하는 대신, 이미지 밑변을 훑어 밝기 프로필로 lane 자동 검출.
def detect_lanes(density: np.ndarray, num_lanes: int) -> list[int]: """ 이미지 각 세로 위치의 밀도 합을 계산해, peak 위치를 lane 중심으로 반환. """ column_profile = density.sum(axis=0) # TODO: 상위 num_lanes 개 peak를 찾아 정렬된 위치 리스트 반환 # 힌트: scipy.signal.find_peaks 사용 가능 pass힌트: from scipy.signal import find_peaks; peaks, _ = find_peaks(column_profile, distance=min_lane_spacing).
빈칸 2: 밴드 포화 감지
밴드가 너무 강해 픽셀이 255에 도달하면 실제 강도가 얼마나 큰지 알 수 없습니다.
def check_saturation( image: np.ndarray, band_roi: tuple[int, int, int, int], saturation_threshold: float = 0.05) -> bool: """ ROI 안의 픽셀 중 값이 0(=완전 어두운 밴드, 반전 시 255)인 비율. threshold 이상이면 포화되었다고 판정. """ top, left, bottom, right = band_roi region = image[top:bottom, left:right] # TODO: region에서 값이 0 이하인 픽셀 비율 계산 # threshold와 비교해 bool 반환 pass힌트: saturated_pixels = (region <= 0).sum(); ratio = saturated_pixels / region.size.
빈칸 3: 예외 안전 정량 파이프라인
여러 젤 이미지를 배치 처리할 때, 하나의 이미지가 실패해도 나머지가 계속 처리되어야 합니다.
def batch_quantify( image_paths: list[str], roi_config: dict) -> dict: """ 각 이미지를 정량화. 실패한 것은 에러와 함께 기록. 반환: {"results": [...], "errors": [...]} """ results = [] errors = [] for path in image_paths: try: # TODO: 이미지 로드, 정량화, 결과 append pass except Exception as e: # TODO: 어떤 파일의 어떤 에러인지 errors에 기록 pass return {"results": results, "errors": errors}힌트: except (FileNotFoundError, ValueError) as e: 로 여러 예외 타입을 잡거나, 상위 Exception 을 잡되 로그를 상세히.
성찰 — 실전 densitometry 도구와의 차이
Sub-pixel 정확도: 실전 도구는 밴드 중심을 sub-pixel 정확도로 결정합니다. 여러분의 ROI는 정수 픽셀 단위입니다.
곡선 배경: 젤 이미지의 배경은 대개 균일하지 않고 gradient가 있습니다. 실전은 rolling ball background subtraction 같은 알고리즘으로 곡선 배경을 제거합니다. ImageJ의 기본 옵션 중 하나.
포화 자동 감지: 여러분의 접근은 단순 임계값. 실전은 히스토그램 분석으로 포화 여부를 더 정교하게 판정.
정량 다이나믹 레인지: 화학발광 웨스턴은 다이나믹 레인지가 좁습니다 — 정량 신뢰 구간이 몇 배 정도 뿐. 실전은 표준 곡선을 함께 로드해 각 밴드가 이 신뢰 구간 안에 있는지 확인합니다.
Fluorescent western: LI-COR Odyssey 같은 형광 시스템은 훨씬 넓은 다이나믹 레인지와 정확한 정량을 제공합니다. 원리는 같지만 이미지 특성이 다릅니다.
확장 프로젝트
1. matplotlib 자동 시각화: 각 이미지에 lane ROI를 오버레이해 저장. 사후 검증용.
2. 표준 곡선: 알려진 농도의 재조합 단백질을 lane 몇 개에 로드한 뒤 표준 곡선 fitting. 미지 시료의 절대량 추정.
3. Streamlit 앱: 여러분의 파이프라인을 웹 UI로 감싸기. 이미지 업로드 → ROI 조정 → 결과 다운로드.
4. 여러 항체 처리: 같은 blot을 stripping하고 여러 항체로 재프로빙한 데이터. 각 항체마다 별개 파일 → 매칭 → 종합 리포트.
이 편의 부품 지도
- [F] numpy 2D 배열: 이미지 = 배열. 슬라이싱으로 ROI 추출,
sum()으로 강도 계산. - [F] 상관관계:
np.corrcoef로 target-control 상관 확인. 정규화 필요성 검증. - [F] 예외 처리: 배치 처리 중 실패 파일 격리. try-except의 실전 사용.
- [W] 파일 I/O · matplotlib: 이미지 로드와 결과 시각화 (완성 스크립트).
[F] = 여러분이 직접 구현 / [W] = 완성 코드로 제공.