import gradio as gr import numpy as np from PIL import Image import cv2 from sklearn.cluster import KMeans from scipy import ndimage as ndi import tempfile # =============================== # ابزارهای پایه # =============================== def pil_to_np_rgb(pil_image: Image.Image) -> np.ndarray: return np.array(pil_image.convert("RGB")) def save_as_bmp(np_img: np.ndarray, name_prefix: str) -> str: f = tempfile.NamedTemporaryFile(delete=False, suffix=".bmp", prefix=f"{name_prefix}_") Image.fromarray(np_img).save(f.name, format="BMP") return f.name # =============================== # KMeans: کاهش رنگ‌ها (15..30) # =============================== def quantize_colors_kmeans(img_rgb: np.ndarray, n_clusters: int): h, w, _ = img_rgb.shape pixels = img_rgb.reshape(-1, 3) kmeans = KMeans(n_clusters=n_clusters, n_init=10, random_state=42).fit(pixels) labels = kmeans.labels_.reshape(h, w) palette = kmeans.cluster_centers_.astype(np.uint8) reduced = np.zeros_like(img_rgb) for i in range(n_clusters): reduced[labels == i] = palette[i] return labels, palette, reduced # =============================== # حذف ریزناحیه‌ها (ادغام با همسایه غالب) # =============================== def clean_small_regions(labels: np.ndarray, min_area_px: int, connectivity: int = 8) -> np.ndarray: if min_area_px <= 0: return labels labels = labels.copy() kernel = np.ones((3, 3), np.uint8) for lbl in np.unique(labels): mask = (labels == lbl).astype(np.uint8) num, comp_map, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=connectivity) for comp_id in range(1, num): area = int(stats[comp_id, cv2.CC_STAT_AREA]) if area >= min_area_px: continue comp_mask = (comp_map == comp_id) dil = cv2.dilate(comp_mask.astype(np.uint8), kernel, iterations=1).astype(bool) border = dil & (~comp_mask) neighbor_labels = labels[border] neighbor_labels = neighbor_labels[neighbor_labels != lbl] if neighbor_labels.size == 0: dil2 = cv2.dilate(comp_mask.astype(np.uint8), kernel, iterations=2).astype(bool) border2 = dil2 & (~comp_mask) neighbor_labels = labels[border2] neighbor_labels = neighbor_labels[neighbor_labels != lbl] if neighbor_labels.size == 0: continue new_lbl = int(np.bincount(neighbor_labels).argmax()) labels[comp_mask] = new_lbl return labels # =============================== # نرم‌سازی برچسب‌ها با «اکثریت همسایه‌ها» # =============================== def smooth_labels_majority(labels: np.ndarray, radius: int) -> np.ndarray: if radius <= 0: return labels size = 2 * radius + 1 def mode_func(window): w = window.astype(np.int32) return np.bincount(w).argmax() return ndi.generic_filter(labels, mode_func, size=size, mode="nearest") # =============================== # مرز جهانی 1px از روی لیبل‌ها # =============================== def build_global_boundary_mask(labels: np.ndarray) -> np.ndarray: H, W = labels.shape boundary = np.zeros((H, W), dtype=np.uint8) h_diff = labels[:, 1:] != labels[:, :-1] v_diff = labels[1:, :] != labels[:-1, :] boundary[:, :-1] |= h_diff boundary[:, 1:] |= h_diff boundary[:-1, :] |= v_diff boundary[ 1:, :] |= v_diff return (boundary.astype(np.uint8) * 255) # =============================== # ابزار کانتور + Resample + پایین‌گذر فوریه # =============================== def dedupe_consecutive_points(cnt: np.ndarray): cnt = cnt.squeeze() if cnt.ndim != 2 or len(cnt) < 5: return None keep = np.ones(len(cnt), dtype=bool) keep[1:] = np.any(np.diff(cnt, axis=0) != 0, axis=1) cnt = cnt[keep] if len(cnt) < 5: return None return cnt def resample_by_arclength(points: np.ndarray, step: float = 1.0, closed: bool = True) -> np.ndarray: pts = points.astype(np.float64) if closed and not np.array_equal(pts[0], pts[-1]): pts = np.vstack([pts, pts[0]]) seg = np.sqrt(((pts[1:] - pts[:-1]) ** 2).sum(axis=1)) s = np.hstack([[0.0], np.cumsum(seg)]) total = s[-1] if total < step: return pts.astype(np.float32) n_new = int(np.floor(total / step)) s_new = np.linspace(0, total, n_new, endpoint=False) x = np.interp(s_new, s, pts[:, 0]) y = np.interp(s_new, s, pts[:, 1]) out = np.vstack([x, y]).T return out.astype(np.float32) def fourier_lowpass(points: np.ndarray, min_wavelength_px: int, upsample_factor: float = 2.0) -> np.ndarray: pts = points N = len(pts) if N < 8: return pts # سیگنال مختلط z = pts[:, 0].astype(np.float64) + 1j * pts[:, 1].astype(np.float64) Z = np.fft.fft(z) k_max = max(1, int(np.floor(N / max(1, min_wavelength_px)))) Z_lp = np.zeros_like(Z) Z_lp[:k_max + 1] = Z[:k_max + 1] if k_max > 0: Z_lp[-k_max:] = Z[-k_max:] z_s = np.fft.ifft(Z_lp) xs, ys = np.real(z_s), np.imag(z_s) # بازنمونه‌گیری نرم‌تر (upsample) idx = np.arange(N) N_new = max(N, int(N * upsample_factor)) idx_new = np.linspace(0, N - 1, N_new) x_new = np.interp(idx_new, idx, xs) y_new = np.interp(idx_new, idx, ys) return np.vstack([x_new, y_new]).T.astype(np.float32) # =============================== # رسم مرزهای نرم بدون اورلپ (روی خود ماسک مرز) # =============================== def render_boundaries_smooth_no_overlap(labels: np.ndarray, palette: np.ndarray, boundary_mask: np.ndarray, min_wavelength_px: int, arclen_step: float = 1.0, upsample_factor: float = 2.0) -> np.ndarray: H, W = labels.shape canvas = np.zeros((H, W, 3), dtype=np.uint8) occupied = np.zeros((H, W), dtype=bool) cnts_data = cv2.findContours(boundary_mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE) contours = cnts_data[0] if len(cnts_data) == 2 else cnts_data[1] kernel = np.ones((3, 3), np.uint8) for cnt in contours: cnt_xy = dedupe_consecutive_points(cnt) if cnt_xy is None or len(cnt_xy) < 12: continue # بازنمونه‌گیری به گام ثابت طول‌قوس + پایین‌گذر فوریه rs = resample_by_arclength(cnt_xy, step=arclen_step, closed=True) smooth = fourier_lowpass(rs, min_wavelength_px=min_wavelength_px, upsample_factor=upsample_factor) # رستر کردن روی ماسک موقت (خط 1px) temp = np.zeros((H, W), dtype=np.uint8) smooth_i = np.round(smooth).astype(np.int32) # ensure closed if not (smooth_i[0] == smooth_i[-1]).all(): smooth_i = np.vstack([smooth_i, smooth_i[0]]) cv2.polylines(temp, [smooth_i], isClosed=True, color=255, thickness=1, lineType=cv2.LINE_8) # فقط پیکسل‌های مرزی اجازه‌ی رسم دارند (کلاپ روی مرز) write_mask = (temp > 0) & (boundary_mask > 0) & (~occupied) if not np.any(write_mask): continue # تعیین رنگ هر نقطه‌ی مرز از اکثریت همسایه‌ها ring = cv2.dilate((write_mask).astype(np.uint8), kernel, iterations=1).astype(bool) ring = ring & (~write_mask) neigh_lbls = labels[ring] if neigh_lbls.size == 0: # fallback: از خود مرز، یک رشد کوچک ring2 = cv2.dilate((write_mask).astype(np.uint8), kernel, iterations=2).astype(bool) ring2 = ring2 & (~write_mask) neigh_lbls = labels[ring2] if neigh_lbls.size == 0: continue owner_lbl = int(np.bincount(neigh_lbls).argmax()) color = tuple(int(v) for v in palette[owner_lbl]) ys, xs = np.where(write_mask) canvas[ys, xs] = color occupied[ys, xs] = True return canvas # =============================== # ترکیب نهایی بدون تولید رنگ جدید # =============================== def overlay_contours_on_reduced(reduced_rgb: np.ndarray, contours_rgb: np.ndarray) -> np.ndarray: final = reduced_rgb.copy() mask = np.any(contours_rgb != 0, axis=2) final[mask] = contours_rgb[mask] return final # =============================== # پایپلاین اصلی Gradio # =============================== def process_pipeline(pil_image: Image.Image, n_clusters: int, min_area_px: int, label_smooth_r: int, min_wavelength_px: int, arc_step: float, upsample_factor: float): original = pil_to_np_rgb(pil_image) # 1) کوانتیزه labels_raw, palette, _ = quantize_colors_kmeans(original, n_clusters=n_clusters) # 2) حذف ریزناحیه‌ها labels = clean_small_regions(labels_raw, min_area_px=min_area_px, connectivity=8) # 3) نرم‌سازی برچسب‌ها با اکثریت همسایه‌ها (کاهش زیگزاگ پیکسلی) labels = smooth_labels_majority(labels, radius=label_smooth_r) # 4) یک پاسِ دیگر حذف ریزناحیه‌ها بعد از نرم‌سازی اختیاری labels = clean_small_regions(labels, min_area_px=min_area_px, connectivity=8) # 5) بازسازی تصویر کاهش‌یافته از روی لیبل‌های تمیز reduced = np.zeros_like(original) for i in range(len(palette)): reduced[labels == i] = palette[i] # 6) مرز 1px سراسری boundary_mask = build_global_boundary_mask(labels) # 7) مرزهای نرم، یکپارچه، بدون اورلپ، فقط روی مرز contours_img = render_boundaries_smooth_no_overlap( labels=labels, palette=palette, boundary_mask=boundary_mask, min_wavelength_px=min_wavelength_px, arclen_step=arc_step, upsample_factor=upsample_factor ) # 8) خروجی نهایی (نواحی رنگ + مرز 1px) final_img = overlay_contours_on_reduced(reduced, contours_img) # BMP برای دانلود p_ori = save_as_bmp(original, "original") p_red = save_as_bmp(reduced, "reduced_clean") p_cnt = save_as_bmp(contours_img, "contours_smooth_no_overlap") p_fin = save_as_bmp(final_img,"final") return ( Image.fromarray(original), Image.fromarray(reduced), Image.fromarray(contours_img), Image.fromarray(final_img), p_ori, p_red, p_cnt, p_fin ) # =============================== # UI با اسکرول + دانلود BMP # =============================== CSS = """ .scroll-pane { max-height: 720px; overflow: auto; } .scroll-pane img, .scroll-pane canvas { width: auto !important; max-width: none !important; } """ with gr.Blocks(css=CSS) as demo: gr.Markdown("## 🎨 مرزهای نرمِ پیوسته (Fourier + Arc-length) بدون اورلپ + حذف ریزناحیه‌ها (BMP)") with gr.Row(): inp = gr.Image(label="آپلود تصویر", type="pil") with gr.Row(): n_clusters = gr.Slider(15, 30, value=20, step=1, label="تعداد کلاسترهای رنگ (15–30)") min_area = gr.Slider(0, 20000, value=1500, step=100, label="حداقل مساحت ناحیه (پیکسل) – حذف ریزناحیه‌ها") label_smooth_r = gr.Slider(0, 5, value=2, step=1, label="نرم‌سازی برچسب‌ها (شعاع اکثریت همسایه)") with gr.Row(): min_wave = gr.Slider(10, 400, value=140, step=5, label="حداقل طول‌موج مرز (پیکسل) – صاف‌سازی فوریه") arc_step = gr.Slider(0.5, 3.0, value=1.0, step=0.5, label="گام بازنمونه‌گیری طول‌قوس (پیکسل)") upsample = gr.Slider(1.0, 4.0, value=2.0, step=0.5, label="Upsample منحنی پس از فیلتر") btn = gr.Button("🚀 پردازش") with gr.Row(): out_ori = gr.Image(label="۱) تصویر اصلی", elem_classes=["scroll-pane"], height=600) out_red = gr.Image(label="۲) تصویر کاهش‌یافته پس از پاک‌سازی", elem_classes=["scroll-pane"], height=600) with gr.Row(): out_cnt = gr.Image(label="۳) مرزهای نرمِ پیوسته (۱px، بدون اورلپ)", elem_classes=["scroll-pane"], height=600) out_fin = gr.Image(label="۴) خروجی نهایی (نواحی رنگ‌شده + مرز ۱px)", elem_classes=["scroll-pane"], height=600) with gr.Accordion("دانلود فایل‌های BMP", open=True): with gr.Row(): file_ori = gr.File(label="دانلود: تصویر اصلی (BMP)") file_red = gr.File(label="دانلود: تصویر کاهش‌یافته پاک‌سازی‌شده (BMP)") with gr.Row(): file_cnt = gr.File(label="دانلود: مرزهای نرمِ پیوسته (BMP)") file_fin = gr.File(label="دانلود: خروجی نهایی (BMP)") btn.click( process_pipeline, inputs=[inp, n_clusters, min_area, label_smooth_r, min_wave, arc_step, upsample], outputs=[out_ori, out_red, out_cnt, out_fin, file_ori, file_red, file_cnt, file_fin] ) demo.launch()