ELLS / nu /utils.py
Hyungseoky's picture
Upload 10 files
4efdf15 verified
# utils.py
"""
웨이퍼 결함 데이터 처리 곡용 μœ ν‹Έλ¦¬ν‹°.
이 λͺ¨λ“ˆμ€ LLS(Laser Light Scattering) 결함 뢄석 νŒŒμ΄ν”„λΌμΈμ—μ„œ
νŒ¨ν„΄ λΆ„λ₯˜ 이전·이후 단계에 κ³΅ν†΅μœΌλ‘œ μ‚¬μš©λ˜λŠ” ν•¨μˆ˜λ“€μ„ μ œκ³΅ν•œλ‹€.
크게 6κ°€μ§€ λ²”μ£Όλ₯Ό 포함:
1. ν™˜κ²½ μ„€μ • : ν•œκΈ€ 폰트, JSON config λ‘œλ“œ
2. 결함 라벨 λ§€ν•‘ : roughbin_no β†’ ν•œκΈ€ 결함 λΆ„λ₯˜λͺ…
3. Zone 라벨링 : μ‹œκ³„λ°©ν–₯ 12ꡬ역 Γ— Inner/Outer λΆ„λ₯˜
4. Fine-grid 처리 : 결함 μ’Œν‘œλ₯Ό 격자 cell에 ν• λ‹Ή
5. 필터링 : cell λ‹¨μœ„ wafer 수 κΈ°μ€€ λ…Έμ΄μ¦ˆ 제거
6. μ‹œκ°ν™” : 웨이퍼 λ§΅ (산점도 + zone + centroid λ§ˆν‚Ή)
클래슀 `WaferUtils`둜 λͺ¨λ“  μœ ν‹Έλ¦¬ν‹°λ₯Ό λ¬Άμ–΄ IDE μžλ™μ™„μ„±/νƒ€μž…νžŒνŠΈ 일관성을 높이고,
ν•˜μœ„ ν˜Έν™˜μ„ μœ„ν•΄ 동일 μ΄λ¦„μ˜ λͺ¨λ“ˆ 레벨 ν•¨μˆ˜λ„ ν•¨κ»˜ λ…ΈμΆœν•œλ‹€.
"""
from __future__ import annotations
import os
import json
from typing import Optional, Tuple
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
from matplotlib.patches import Circle, Wedge
# ----------------------------------------------------------------------
# λͺ¨λ“ˆ μƒμˆ˜
# ----------------------------------------------------------------------
# roughbin_no(μ •μˆ˜) β†’ 결함 λΆ„λ₯˜λͺ…(ν•œκΈ€/영문) λ§€ν•‘.
# 검사기 raw μ½”λ“œλ₯Ό μš΄μ˜μ—μ„œ ν†΅μš©λ˜λŠ” λΆ„λ₯˜λͺ…μœΌλ‘œ λ³€ν™˜ν•  λ•Œ μ‚¬μš©.
ROUGHBIN_MAPPING = {
0: 'LPD', 100: 'LPD-N', 110: 'Micro-Scratch', 111: 'Void', 115: 'PID',
120: 'LPD-E', 130: 'LPD-S', 140: 'LLPD', 141: 'Air Pocket', 150: 'DIC-Unique',
160: 'Stain', 170: 'COP', 200: 'Cluster Area', 205: 'Extended Defects',
210: 'Scratch', 220: 'Slipline', 230: 'Line', 231: 'Area', 233: 'Radial',
234: 'Ring', 512: 'Residue', 520: 'Boat Mark', 902: 'Streak', 999: 'Nuisance',
990: 'LPD Nuisance', 991: 'PPD Nuisance', 501: 'Haze Slipline', 502: 'Hazeline',
600: 'Grid', 700: 'ROI', 800: 'X Section',
}
# μ‹œκ³„λ°©ν–₯ 12ꡬ역 라벨. 12μ‹œλΆ€ν„° μ‹œμž‘ν•΄μ„œ μ‹œκ³„λ°©ν–₯(1, 2, ... 11μ‹œ)으둜 μ§„ν–‰.
CLOCK_LABELS = ["12", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11"]
# νŒ¨ν„΄λ³„ μ‹œκ°ν™” 색상.
PATTERN_COLORS = {
"ν™˜ν˜•": "darkorange",
"μ„ ν˜•": "forestgreen",
"κ΅°μ§‘": "mediumpurple",
"정상/미달": "gray",
"Others": "gray",
}
# ======================================================================
# WaferUtils
# ======================================================================
class WaferUtils:
"""
웨이퍼 결함 데이터 처리 μœ ν‹Έλ¦¬ν‹° 클래슀 (facade ν˜•νƒœ).
μƒνƒœκ°€ ν•„μš” μ—†λŠ” 순수 ν•¨μˆ˜λ“€μ΄λ―€λ‘œ λŒ€λΆ€λΆ„ `@staticmethod`둜 κ΅¬μ„±λ˜λ©°,
μΈμŠ€ν„΄μŠ€ν™” 없이 `WaferUtils.method(...)` ν˜•νƒœλ‘œ μ‚¬μš©ν•  수 μžˆλ‹€.
Examples
--------
>>> df = WaferUtils.assign_fine_grid(df, cell_size_mm=3.0)
>>> df = WaferUtils.add_zone_labels(df, inner_radius=105.0)
>>> WaferUtils.plot_wafer_map(result_df, key="...", pattern_list=["ν™˜ν˜•"], ...)
"""
# ------------------------------------------------------------------
# 1. ν™˜κ²½ μ„€μ •
# ------------------------------------------------------------------
@staticmethod
def setup_korean_font() -> Optional[str]:
"""
μ‹œμŠ€ν…œ ν•œκΈ€ 폰트λ₯Ό matplotlib κΈ°λ³Έ 폰트둜 등둝.
μ„ ν˜Έ μˆœμ„œ: Malgun Gothic > Nanum Gothic > NanumBarunGothic > Batang > Gulim > AppleGothic.
μ„ ν˜Έ 후보가 μ—†μœΌλ©΄ μ‹œμŠ€ν…œμ—μ„œ 'gothic/mincho/dotum/gulim/malgun/sans/korean'
ν‚€μ›Œλ“œκ°€ ν¬ν•¨λœ 첫 번째 ν•œκΈ€ 후보λ₯Ό μ‚¬μš©.
Returns
-------
Optional[str]
적용된 폰트λͺ…. μ‹œμŠ€ν…œμ— ν•œκΈ€ ν°νŠΈκ°€ μ „ν˜€ μ—†μœΌλ©΄ None.
"""
korean_fonts = [
f.name for f in fm.fontManager.ttflist
if any(k in f.name.lower()
for k in ["gothic", "mincho", "dotum", "gulim", "malgun", "sans", "korean"])
]
preferred = ["Malgun Gothic", "Nanum Gothic", "NanumBarunGothic",
"Batang", "Gulim", "AppleGothic"]
selected = next((f for f in preferred if f in korean_fonts), None)
if selected is None and korean_fonts:
selected = korean_fonts[0]
if selected:
plt.rcParams["font.family"] = selected
plt.rcParams["font.size"] = 10
# ν•œκΈ€ ν°νŠΈμ—μ„œ 음수 λΆ€ν˜Έ(βˆ’)κ°€ κΉ¨μ§€λŠ” ν˜„μƒ λ°©μ§€
plt.rcParams["axes.unicode_minus"] = False
print(f"βœ… ν•œκΈ€ 폰트 μ„€μ • μ™„λ£Œ: {selected}")
else:
print("⚠️ κ²½κ³ : μ‹œμŠ€ν…œμ— ν•œκΈ€ ν°νŠΈκ°€ μ—†μŠ΅λ‹ˆλ‹€. κΈ°λ³Έ 폰트 μ‚¬μš© (κΈ€μž 깨짐 λ°œμƒ)")
return selected
@staticmethod
def load_config(config_path: str = "./lls_config.json") -> dict:
"""
JSON ν˜•μ‹μ˜ 뢄석 μ„€μ • 파일 λ‘œλ“œ.
Parameters
----------
config_path : str
μ„€μ • 파일 경둜 (UTF-8 인코딩 κ°€μ •).
Returns
-------
dict
μ„€μ • 트리 (preprocessing / clustering / ring / linear / lof / cluster /
misc / contact_mapping λ“±μ˜ ν‚€ 포함).
Raises
------
FileNotFoundError
μ§€μ •ν•œ κ²½λ‘œμ— 파일이 μ—†λŠ” 경우.
"""
if not os.path.exists(config_path):
raise FileNotFoundError(f"μ„€μ • 파일 μ—†μŒ: {config_path}")
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
# ------------------------------------------------------------------
# 2. 결함 라벨 λ§€ν•‘
# ------------------------------------------------------------------
@staticmethod
def map_roughbin_no(roughbin) -> Optional[str]:
"""
roughbin_no(검사기 raw μ½”λ“œ)λ₯Ό 운영 결함 λΆ„λ₯˜λͺ…μœΌλ‘œ λ§€ν•‘.
κ³ μ • λ§€ν•‘ ν…Œμ΄λΈ”(`ROUGHBIN_MAPPING`)을 μš°μ„  μ‘°νšŒν•˜κ³ ,
λ²”μœ„ν˜• μ½”λ“œ(541~548 λ“±)λŠ” 별도 if-쑰건으둜 μ²˜λ¦¬ν•œλ‹€.
Parameters
----------
roughbin : Any
μ •μˆ˜ λ˜λŠ” μ •μˆ˜ λ³€ν™˜ κ°€λŠ₯ν•œ κ°’. NaN/None/λ¬Έμžμ—΄ 등은 None λ°˜ν™˜.
Returns
-------
Optional[str]
λΆ„λ₯˜λͺ…("LPD", "Haze Slipline" λ“±). λ§€ν•‘ μ‹€νŒ¨ μ‹œ "Unknown",
μž…λ ₯이 NaN/λ³€ν™˜ λΆˆκ°€ μ‹œ None.
"""
if pd.isna(roughbin):
return None
try:
roughbin = int(roughbin)
except (TypeError, ValueError):
return None
if roughbin in ROUGHBIN_MAPPING:
return ROUGHBIN_MAPPING[roughbin]
# λ²”μœ„ν˜• μ½”λ“œ 처리
if 541 <= roughbin <= 548: return "Haze Slipline"
if 531 <= roughbin <= 538: return "Hazeline"
if 601 <= roughbin <= 609: return "Grid"
if 701 <= roughbin <= 709: return "ROI"
if 801 <= roughbin <= 809: return "X Section"
return "Unknown"
# ------------------------------------------------------------------
# 3. Zone 라벨링
# ------------------------------------------------------------------
@staticmethod
def add_zone_labels(df: pd.DataFrame, inner_radius: float = 105.0) -> pd.DataFrame:
"""
결함 μ’Œν‘œμ— zone 라벨을 λΆ€μ—¬.
Zone 라벨 ν˜•μ‹: `{Inner|Outer}_{μ‹œκ³„μœ„μΉ˜ 2자리}`
예) "Inner_03" = λ°˜μ§€λ¦„ ≀ inner_radius, 3μ‹œ λ°©ν–₯
"Outer_12" = λ°˜μ§€λ¦„ > inner_radius, 12μ‹œ λ°©ν–₯
각도 λ³€ν™˜:
- μˆ˜ν•™ 각도(atan2) β†’ 12μ‹œ κΈ°μ€€ μ‹œκ³„λ°©ν–₯ 각도(`theta_from_12 = (90Β° - math) mod 360`)
- sector index = floor(theta_from_12 / 30Β°) % 12
Parameters
----------
df : pd.DataFrame
'coor_x', 'coor_y' μ»¬λŸΌμ„ ν¬ν•¨ν•œ 결함 μ’Œν‘œ DF.
inner_radius : float
Inner / Outer 경계가 λ˜λŠ” λ°˜μ§€λ¦„ (mm).
Returns
-------
pd.DataFrame
'zone_label', 'r' (원점 거리), 'theta_deg' (μˆ˜ν•™ 각도) 컬럼 μΆ”κ°€λœ 사본.
"""
df = df.copy()
r = np.hypot(df["coor_x"], df["coor_y"])
theta_deg = np.degrees(np.arctan2(df["coor_y"], df["coor_x"]))
# μ‹œκ³„ λ°©ν–₯ ν™˜μ‚°: 12μ‹œ = 0Β°, μ‹œκ³„λ°©ν–₯으둜 증가
theta_from_12 = (90.0 - theta_deg) % 360.0
sector_index = (theta_from_12 // 30).astype(int) % 12
clock_str = pd.Series([CLOCK_LABELS[i] for i in sector_index], index=df.index)
zone_type = np.where(r <= inner_radius, "Inner", "Outer")
df["zone_label"] = [f"{zt}_{c}" for zt, c in zip(zone_type, clock_str)]
df["r"] = r
df["theta_deg"] = theta_deg
return df
# ------------------------------------------------------------------
# 4. Fine-grid 처리
# ------------------------------------------------------------------
@staticmethod
def assign_fine_grid(df: pd.DataFrame, cell_size_mm: float = 3.0) -> pd.DataFrame:
"""
결함 μ’Œν‘œλ₯Ό fine-grid cell 에 ν• λ‹Ή.
웨이퍼 μ’Œν‘œ λ²”μœ„ [-150, 150] Γ— [-150, 150] (mm)λ₯Ό `cell_size_mm` 크기의
정사각 격자둜 λΆ„ν• ν•˜κ³ , 각 결함이 μ†ν•˜λŠ” cell의 쀑심 μ’Œν‘œμ™€ IDλ₯Ό λΆ€μ—¬.
Parameters
----------
df : pd.DataFrame
'coor_x', 'coor_y' 컬럼 포함 DF.
cell_size_mm : float
μ…€ ν•œ λ³€μ˜ 크기 (mm). κΈ°λ³Έ 3.0.
Returns
-------
pd.DataFrame
'cell_x', 'cell_y' (μ…€ 쀑심 μ’Œν‘œ mm),
'cell_id' ("{int_x}_{int_y}" ν˜•μ‹μ˜ unique ID) μΆ”κ°€λœ 사본.
Notes
-----
cell_idλŠ” cell_x/cell_yλ₯Ό λ°˜μ˜¬λ¦Όν•˜μ—¬ μ •μˆ˜ν™”ν•œ λ¬Έμžμ—΄μ΄λΌ
cell_size_mmκ°€ μ •μˆ˜ 경계와 μ–΄κΈ‹λ‚˜λ©΄ 좩돌 κ°€λŠ₯. 톡상 3.0/5.0 λ“± μ •μˆ˜ ꢌμž₯.
"""
df = df.copy()
# μ’Œν‘œ 평행이동 ν›„ floor β†’ bin index
bin_x = np.floor((df["coor_x"] + 150) / cell_size_mm).astype(int)
bin_y = np.floor((df["coor_y"] + 150) / cell_size_mm).astype(int)
# μ…€ 쀑심 μ’Œν‘œ (mm)
df["cell_x"] = bin_x * cell_size_mm - 150 + cell_size_mm / 2
df["cell_y"] = bin_y * cell_size_mm - 150 + cell_size_mm / 2
cell_x_int = np.round(df["cell_x"]).astype(int)
cell_y_int = np.round(df["cell_y"]).astype(int)
df["cell_id"] = cell_x_int.astype(str) + "_" + cell_y_int.astype(str)
return df
@staticmethod
def get_cell_wafer_counts(df: pd.DataFrame) -> pd.DataFrame:
"""
각 cellμ—μ„œ 결함이 λ°œμƒν•œ unique wafer μˆ˜μ™€ 결함 수λ₯Ό 집계.
Parameters
----------
df : pd.DataFrame
'cell_id', 'WAF_ID' 컬럼 포함 DF.
Returns
-------
pd.DataFrame
index = cell_id
columns = ['wafer_count', 'defect_count', 'wafer_ratio']
- wafer_count : ν•΄λ‹Ή cellμ—μ„œ 결함을 보인 unique wafer 수
- defect_count : ν•΄λ‹Ή cell의 전체 결함 수
- wafer_ratio : wafer_count / 전체 unique wafer 수
"""
total_wafers = df["WAF_ID"].nunique()
cell_stats = df.groupby("cell_id").agg(
wafer_count=("WAF_ID", "nunique"),
defect_count=("WAF_ID", "size"),
)
cell_stats["wafer_ratio"] = cell_stats["wafer_count"] / total_wafers if total_wafers else 0.0
return cell_stats
@staticmethod
def filter_by_cell_wafer_count(
df: pd.DataFrame,
n1_min_wafer: int,
cell_size_mm: float = 3.0,
) -> pd.DataFrame:
"""
Fine-grid 기반 n1 ν•„ν„°: μΆ©λΆ„ν•œ waferμ—μ„œ 곡톡 λ°œμƒν•œ cell의 κ²°ν•¨λ§Œ μœ μ§€.
'곡톡 μœ„μΉ˜μ— 반볡 λ°œμƒν•˜λŠ” κ²°ν•¨λ§Œ μœ μ˜λ―Έν•˜λ‹€'λŠ” 가정을 κ΅¬ν˜„.
unique wafer μˆ˜κ°€ `n1_min_wafer` 미만인 cell은 λ…Έμ΄μ¦ˆλ‘œ κ°„μ£Ό 제거.
Parameters
----------
df : pd.DataFrame
'coor_x', 'coor_y', 'WAF_ID' 포함 DF (cell 할당은 λ‚΄λΆ€μ—μ„œ μˆ˜ν–‰).
n1_min_wafer : int
cell이 μœ νš¨ν•˜κΈ° μœ„ν•΄ ν•„μš”ν•œ μ΅œμ†Œ unique wafer 수.
cell_size_mm : float
fine-grid cell 크기 (mm).
Returns
-------
pd.DataFrame
n1 쑰건을 ν†΅κ³Όν•œ cell의 κ²°ν•¨λ§Œ 포함. 'cell_wafer_count' 컬럼 μΆ”κ°€.
"""
df = WaferUtils.assign_fine_grid(df, cell_size_mm=cell_size_mm)
cell_stats = WaferUtils.get_cell_wafer_counts(df)
valid_cells = cell_stats[cell_stats["wafer_count"] >= n1_min_wafer].index
df_filtered = df[df["cell_id"].isin(valid_cells)].copy()
df_filtered = df_filtered.merge(
cell_stats[["wafer_count", "wafer_ratio"]],
left_on="cell_id", right_index=True, how="left",
)
df_filtered.rename(columns={"wafer_count": "cell_wafer_count"}, inplace=True)
return df_filtered
@staticmethod
def summarize_filtering_result(
df_original: pd.DataFrame,
df_filtered: pd.DataFrame,
) -> dict:
"""
필터링 μ „ν›„ 결함/Cell 수 μš”μ•½ 톡계.
Returns
-------
dict
original_defects, filtered_defects, removed_defects, removal_rate(%),
original_cells, valid_cells.
"""
orig = len(df_original)
filt = len(df_filtered)
removed = orig - filt
rate = (removed / orig * 100) if orig else 0.0
return {
"original_defects": orig,
"filtered_defects": filt,
"removed_defects": removed,
"removal_rate": round(rate, 2),
"original_cells": df_original["cell_id"].nunique() if "cell_id" in df_original.columns else 0,
"valid_cells": df_filtered["cell_id"].nunique() if "cell_id" in df_filtered.columns else 0,
}
# ------------------------------------------------------------------
# 5. μ‹œκ°ν™”
# ------------------------------------------------------------------
@staticmethod
def plot_wafer_map(
result_df: pd.DataFrame,
key: str,
pattern_list,
dominant_zone: str,
meta: Optional[dict] = None,
figsize: Tuple[int, int] = (8, 8),
save_path: Optional[str] = None,
show_mode: bool = False,
) -> None:
"""
웨이퍼 결함 λ§΅ μ‹œκ°ν™”.
ꡬ성 μš”μ†Œ
----------
1. λ°°κ²½ μ˜μ—­
- ν™˜ν˜• νŒ¨ν„΄: 전체 원 μ˜μ—­μ„ λ² μ΄μ§€μƒ‰μœΌλ‘œ ν‘œμ‹œ
- κ·Έ μ™Έ: dominant_zone에 ν•΄λ‹Ήν•˜λŠ” wedge만 베이지색 ν‘œμ‹œ
2. 결함 산점도
- inlier 컬럼이 있으면 inlier/outlier 색상 뢄리
- inlier 색상은 νŒ¨ν„΄μ— 따라 PATTERN_COLORS λ§€ν•‘
3. Centroid 마컀 (ν™˜ν˜• μ œμ™Έ)
- λΉ¨κ°„ 원(10mm) + X 마컀
4. 동심원: 30/45/60/90/120/150mm
5. μ‹œκ³„λ°©ν–₯ κ·Έλ¦¬λ“œ + 12μ‹œΒ·1μ‹œΒ·...Β·11μ‹œ 라벨
6. μΊ‘μ…˜: νŒ¨ν„΄/ꡬ역/κ²°ν•¨μˆ˜/μž₯λΉ„/웨이퍼
Parameters
----------
result_df : pd.DataFrame
'coor_x', 'coor_y', (선택) 'inlier', 'zone_label' 컬럼.
key : str
μ €μž₯ 파일λͺ…Β·μΊ‘μ…˜μ— μ‚¬μš©ν•  ν‚€.
pattern_list : list[str] | str
νŒ¨ν„΄λͺ…. ['ν™˜ν˜•','κ΅°μ§‘'] 같은 λ¦¬μŠ€νŠΈλ„ ν—ˆμš©.
dominant_zone : str
μ£Όμš” zone 라벨 (예: 'Inner_03'). 'N/A' 이면 λ―Έν‘œμ‹œ.
meta : dict, optional
'main_centroid_x', 'main_centroid_y', 'wafer_count', 'EQP_NM_8030' λ“±.
figsize : (int, int)
Figure 크기.
save_path : str, optional
μ €μž₯ 경둜. None이면 './result/result_figures/{key}.jpg'.
show_mode : bool
True 면 plt.show() 호좜.
"""
solid_radii = [45, 90, 150]
dashed_radii = [30, 60, 120]
# νŒ¨ν„΄ λ¬Έμžμ—΄ μ •κ·œν™”
if isinstance(pattern_list, list):
pattern_str = ", ".join(pattern_list)
first_pattern = pattern_list[0]
else:
pattern_str = str(pattern_list)
first_pattern = pattern_str.split(",")[0].strip()
color = PATTERN_COLORS.get(first_pattern, "steelblue")
fig, ax = plt.subplots(figsize=figsize)
# --- λ°°κ²½: ν™˜ν˜• β†’ 전체 원, κ·Έ μ™Έ β†’ dominant zone wedge ---
if "ν™˜ν˜•" in pattern_str:
ax.add_patch(Circle((0, 0), 150, facecolor="#F5F5DC",
edgecolor="none", alpha=0.8, zorder=1))
elif dominant_zone and dominant_zone != "N/A":
try:
for zone in [z.strip() for z in dominant_zone.split(",")]:
ztype, zclock = zone.split("_")
r_min = 0 if ztype == "Inner" else 105
r_max = 105 if ztype == "Inner" else 150
if zclock in CLOCK_LABELS:
idx = CLOCK_LABELS.index(zclock)
# μ‹œκ³„ 각도 β†’ μˆ˜ν•™ 각도 λ³€ν™˜ (WedgeλŠ” μˆ˜ν•™ 각도 μ‚¬μš©)
math_start = 90 - (idx + 1) * 30
math_end = 90 - idx * 30
ax.add_patch(Wedge((0, 0), r_max, math_start, math_end,
width=(r_max - r_min),
facecolor="#F5F5DC",
edgecolor="none", alpha=0.8, zorder=1))
except Exception:
# zone νŒŒμ‹± μ‹€νŒ¨ μ‹œ λ°°κ²½ μƒλž΅ (μ‹œκ°ν™”λŠ” 계속 μ§„ν–‰)
pass
# --- 결함 산점도 ---
if "inlier" in result_df.columns:
inliers = result_df[result_df["inlier"] == True]
outliers = result_df[result_df["inlier"] == False]
ax.scatter(outliers["coor_x"], outliers["coor_y"],
c="lightgray", s=15, alpha=0.3, zorder=4)
ax.scatter(inliers["coor_x"], inliers["coor_y"],
c=color, s=35, alpha=0.5,
label=f"Inlier ({pattern_str})", zorder=5)
else:
ax.scatter(result_df["coor_x"], result_df["coor_y"],
c=color, s=30, alpha=0.5, zorder=5)
# --- Centroid 마컀: ν™˜ν˜•μ΄λ©΄ μƒλž΅ (ring centerλŠ” 원점 근처라 정보 μ—†μŒ) ---
if meta and "ν™˜ν˜•" not in pattern_str:
cx = meta.get("main_centroid_x")
cy = meta.get("main_centroid_y")
if cx is not None and cy is not None:
ax.add_patch(Circle((cx, cy), radius=10, facecolor="none",
edgecolor="red", linewidth=2.5,
linestyle="-", alpha=0.9, zorder=7))
ax.scatter(cx, cy, c="red", s=80, marker="x",
linewidths=2.5, zorder=8, label="Centroid")
# --- 웨이퍼 동심원 ---
for r in solid_radii:
ax.add_patch(plt.Circle((0, 0), r, color="black", fill=False,
linestyle="-", linewidth=1.2, alpha=0.7, zorder=2))
for r in dashed_radii:
ax.add_patch(plt.Circle((0, 0), r, color="gray", fill=False,
linestyle="--", linewidth=0.8, alpha=0.5, zorder=2))
# --- μ‹œκ³„ λ°©ν–₯ κ·Έλ¦¬λ“œ + 라벨 ---
clock_angles = {0: "12μ‹œ", 30: "1μ‹œ", 60: "2μ‹œ", 90: "3μ‹œ",
120: "4μ‹œ", 150: "5μ‹œ", 180: "6μ‹œ", 210: "7μ‹œ",
240: "8μ‹œ", 270: "9μ‹œ", 300: "10μ‹œ", 330: "11μ‹œ"}
grid_end = max(solid_radii) + 12
label_r = grid_end * 0.93
for angle_deg, label_text in clock_angles.items():
# μ‹œκ³„ 각도 β†’ μˆ˜ν•™ 각도
math_rad = np.deg2rad(90 - angle_deg)
ax.plot([0, grid_end * np.cos(math_rad)],
[0, grid_end * np.sin(math_rad)],
color="gray", linestyle=":", linewidth=0.8, zorder=2)
ax.text(label_r * np.cos(math_rad), label_r * np.sin(math_rad),
label_text, color="darkblue", fontsize=8,
ha="center", va="center", weight="bold", alpha=0.75, zorder=3)
ax.axhline(0, color="k", linewidth=0.4, zorder=3)
ax.axvline(0, color="k", linewidth=0.4, zorder=3)
max_range = max(solid_radii) + 20
ax.set_xlim(-max_range, max_range)
ax.set_ylim(-max_range, max_range)
ax.set_aspect("equal", "box")
ax.set_xlabel("X (mm)")
ax.set_ylabel("Y (mm)")
ax.legend(loc="upper right", fontsize=8)
ax.grid(True, alpha=0.15)
# --- μΊ‘μ…˜ ---
total = len(result_df)
dom_cnt = 0
if "zone_label" in result_df.columns and dominant_zone != "N/A":
dom_zones = [z.strip() for z in dominant_zone.split(",")]
dom_cnt = result_df[result_df["zone_label"].isin(dom_zones)].shape[0]
ratio = (dom_cnt / total * 100) if total else 0.0
lines = [
f"Key: {key}",
f"νŒ¨ν„΄: {pattern_str} | λ°œμƒκ΅¬μ—­: {dominant_zone}",
f"전체 결함: {total}건 | μ£Όμš”μ˜μ—­ 결함: {dom_cnt}건 | λΉ„μœ¨: {ratio:.1f}%",
]
if meta:
lines.append(f"μž₯λΉ„: {meta.get('EQP_NM_8030', '-')} | 웨이퍼: {meta.get('wafer_count', '-')}λ§€")
ax.set_title("\n".join(lines), fontsize=9, loc="left", pad=8)
plt.tight_layout()
if save_path is None:
save_dir = "./result/result_figures"
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, f"{key}.jpg")
plt.savefig(save_path, dpi=150, bbox_inches="tight")
if show_mode:
plt.show()
plt.close()
# ======================================================================
# Backward-compat: κΈ°μ‘΄ λͺ¨λ“ˆ 레벨 ν•¨μˆ˜ alias
# (κΈ°μ‘΄ μ½”λ“œ `from utils import setup_korean_font, ...` ν˜•νƒœλ₯Ό κ·ΈλŒ€λ‘œ 지원)
# ======================================================================
setup_korean_font = WaferUtils.setup_korean_font
load_config = WaferUtils.load_config
map_roughbin_no = WaferUtils.map_roughbin_no
add_zone_labels = WaferUtils.add_zone_labels
assign_fine_grid = WaferUtils.assign_fine_grid
get_cell_wafer_counts = WaferUtils.get_cell_wafer_counts
filter_by_cell_wafer_count = WaferUtils.filter_by_cell_wafer_count
summarize_filtering_result = WaferUtils.summarize_filtering_result
plot_wafer_map = WaferUtils.plot_wafer_map