Files
UTS-Pengolahan-Citra-Digita…/Problem 3/pengolahan_citra.py
T

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_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()