Soal 1: selisih Laplacian of Gaussian (sigma 0,1 dan 0,2). Soal 2: memecah teks menjadi karakter individual dengan regionprops. Soal 3: menghitung jumlah koin per ukuran dengan regionprops.
240 lines
8.4 KiB
Python
240 lines
8.4 KiB
Python
"""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.jpg 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()
|