"""Menghitung jumlah koin untuk setiap kelompok ukuran. Pseudocode: 1. Baca gambar koin dan ubah menjadi grayscale. 2. Haluskan sedikit gambar, lalu lakukan threshold Otsu untuk memisahkan koin dari latar belakang yang seragam. 3. Tentukan polaritas objek dari piksel tepi gambar; pastikan koin berwarna putih pada mask. 4. Isi lubang di dalam setiap koin agar relief koin tidak memecah satu koin menjadi beberapa komponen. 5. Beri label pada setiap komponen yang terpisah, lalu buang komponen yang menyentuh tepi gambar (koin terpotong, ukurannya tidak utuh). 6. Gunakan regionprops untuk mengambil luas, luas convex hull, dan pusat setiap komponen; abaikan komponen yang lebih kecil dari batas minimum. Diameter koin = diameter lingkaran yang luasnya sama dengan convex hull. 7. Kelompokkan diameter yang cukup berdekatan sebagai ukuran koin yang sama. 8. Hitung dan tampilkan jumlah koin pada setiap kelompok, lalu simpan tabel hasil dan gambar anotasi. Cara menjalankan: python pengolahan_citra.py [jalur_gambar_koin] Jika argumen tidak diberikan, program membaca koin_100.png di folder skrip (dibuat oleh buat_gambar_koin.py). """ import argparse import csv import os from dataclasses import dataclass import cv2 import numpy as np from scipy.ndimage import binary_fill_holes from skimage.measure import label, regionprops from skimage.segmentation import clear_border BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DEFAULT_IMAGE_PATH = os.path.join(BASE_DIR, "koin_100.png") OUTPUT_DIR = os.path.join(BASE_DIR, "output") # Sesuaikan dengan resolusi foto. Komponen lebih kecil dari nilai ini # dianggap noise, bukan koin. MIN_COIN_AREA = 100 # Dua diameter dikelompokkan sebagai ukuran yang sama jika selisihnya # tidak lebih dari persentase ini terhadap diameter rata-rata kelompok. # Nilai harus lebih besar dari variasi diameter koin sejenis, tetapi lebih # kecil dari selisih antar-ukuran koin. Dengan 8%, ukuran yang berbeda ~7% # ikut tergabung; 3% tetap memisahkannya. SIZE_TOLERANCE_PERCENT = 3.0 EXPECTED_COIN_COUNT = 100 # Warna anotasi per kelompok ukuran (BGR), kontras terhadap koin dan latar GROUP_COLORS = [ (0, 0, 255), # merah (255, 128, 0), # biru (0, 200, 0), # hijau (255, 0, 255), # magenta (0, 255, 255), # kuning (255, 255, 0), # cyan ] @dataclass(frozen=True) class CoinMeasurement: label: int area: float diameter_px: float centroid: tuple[float, float] def read_image(path: str) -> np.ndarray: image = cv2.imread(path) if image is None: raise FileNotFoundError( f"Gambar tidak dapat dibaca: {path}. " "Sediakan gambar koin dan jalankan ulang program." ) return image def segment_coins(image_bgr: np.ndarray) -> np.ndarray: gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) _, mask = cv2.threshold( blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) # Latar belakang diasumsikan terlihat pada mayoritas piksel tepi. border = np.concatenate( (mask[0, :], mask[-1, :], mask[:, 0], mask[:, -1]) ) if np.mean(border == 255) > 0.5: mask = cv2.bitwise_not(mask) # Relief/pantulan pada koin bisa menjadi lubang setelah threshold dan # memecah satu koin menjadi beberapa komponen; isi lubang tersebut. mask = binary_fill_holes(mask > 0).astype(np.uint8) * 255 return mask def measure_coins(mask: np.ndarray) -> list[CoinMeasurement]: # Koin yang menyentuh tepi gambar terpotong sehingga diameternya tidak # utuh dan bisa masuk kelompok ukuran yang salah; buang komponen tersebut. labeled_mask = clear_border(label(mask > 0, connectivity=2)) measurements = [] for region in regionprops(labeled_mask): if region.area < MIN_COIN_AREA: continue measurements.append( CoinMeasurement( label=region.label, area=float(region.area), # Bagian koin yang gelap (bayangan di tepi) bisa terpotong saat # threshold. Convex hull menutup potongan itu, sehingga diameter # dihitung dari luas convex hull, bukan luas region. diameter_px=float(np.sqrt(4 * region.area_convex / np.pi)), centroid=(float(region.centroid[0]), float(region.centroid[1])), ) ) return measurements def group_by_size( measurements: list[CoinMeasurement], ) -> list[list[CoinMeasurement]]: groups: list[list[CoinMeasurement]] = [] for coin in sorted(measurements, key=lambda item: item.diameter_px): matching_groups = [] for group in groups: mean_diameter = float(np.mean([item.diameter_px for item in group])) difference_percent = ( abs(coin.diameter_px - mean_diameter) / mean_diameter * 100 ) if difference_percent <= SIZE_TOLERANCE_PERCENT: matching_groups.append((difference_percent, group)) if matching_groups: _, closest_group = min(matching_groups, key=lambda item: item[0]) closest_group.append(coin) else: groups.append([coin]) return sorted( groups, key=lambda group: float(np.mean([coin.diameter_px for coin in group])), ) def save_results( image_bgr: np.ndarray, mask: np.ndarray, groups: list[list[CoinMeasurement]], ) -> None: os.makedirs(OUTPUT_DIR, exist_ok=True) annotated = image_bgr.copy() csv_path = os.path.join(OUTPUT_DIR, "jumlah_koin_per_ukuran.csv") with open(csv_path, "w", newline="", encoding="utf-8") as result_file: writer = csv.writer(result_file) writer.writerow(["Ukuran", "Jumlah koin", "Diameter rata-rata (piksel)"]) for size_number, group in enumerate(groups, start=1): mean_diameter = float(np.mean([coin.diameter_px for coin in group])) writer.writerow([size_number, len(group), f"{mean_diameter:.2f}"]) color = GROUP_COLORS[(size_number - 1) % len(GROUP_COLORS)] for coin in group: row, column = coin.centroid center = (round(column), round(row)) radius = max(1, round(coin.diameter_px / 2)) # Tebal garis dan ukuran teks mengikuti ukuran koin agar # tetap terbaca pada gambar beresolusi tinggi thickness = max(2, round(coin.diameter_px / 40)) font_scale = coin.diameter_px / 60 text = str(size_number) (text_w, text_h), _ = cv2.getTextSize( text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness ) text_origin = (center[0] - text_w // 2, center[1] + text_h // 2) cv2.circle(annotated, center, radius, color, thickness) cv2.putText( annotated, text, text_origin, cv2.FONT_HERSHEY_SIMPLEX, font_scale, color, thickness, cv2.LINE_AA, ) cv2.imwrite(os.path.join(OUTPUT_DIR, "mask_koin.png"), mask) cv2.imwrite(os.path.join(OUTPUT_DIR, "anotasi_koin.jpg"), annotated) print(f"Tabel hasil disimpan di: {csv_path}") print(f"Mask segmentasi dan anotasi disimpan di: {OUTPUT_DIR}") def main() -> None: parser = argparse.ArgumentParser( description="Menghitung jumlah koin untuk setiap kelompok ukuran." ) parser.add_argument( "image", nargs="?", default=DEFAULT_IMAGE_PATH, help="jalur gambar koin (default: koin_100.png di folder skrip)", ) args = parser.parse_args() image = read_image(args.image) mask = segment_coins(image) measurements = measure_coins(mask) groups = group_by_size(measurements) print(f"Jumlah koin terdeteksi: {len(measurements)}") if len(measurements) != EXPECTED_COIN_COUNT: print( f"Perhatian: jumlah terdeteksi berbeda dari asumsi " f"{EXPECTED_COIN_COUNT} koin. Periksa mask dan MIN_COIN_AREA." ) print("\nHasil pengelompokan ukuran:") print("Ukuran | Jumlah koin | Diameter rata-rata (piksel)") for size_number, group in enumerate(groups, start=1): mean_diameter = float(np.mean([coin.diameter_px for coin in group])) print(f"{size_number:>6} | {len(group):>11} | {mean_diameter:>28.2f}") save_results(image, mask, groups) if __name__ == "__main__": main()