Jawaban UTS Pengolahan Citra Digital Kelompok I

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.
This commit is contained in:
MelvynFaith
2026-10-22 23:43:56 +07:00
commit 003b2c544c
39 changed files with 1191 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
venv/
.venv/
# Bukan source code: soal ujian dan laporan Word
*.pdf
*.docx
~$*
Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

+64
View File
@@ -0,0 +1,64 @@
import os
import numpy as np
from PIL import Image
from scipy.signal import convolve2d
# Semua file dibaca dan disimpan di folder yang sama dengan program ini,
# sehingga program bisa dijalankan di komputer mana pun.
FOLDER = os.path.dirname(os.path.abspath(__file__))
def kernel_log(sigma, ukuran=9):
"""Membuat kernel Laplacian of Gaussian (LoG) berukuran ukuran x ukuran."""
r = ukuran // 2
y, x = np.mgrid[-r:r + 1, -r:r + 1].astype(np.float64)
rr = x**2 + y**2
k = -(1.0 / (np.pi * sigma**4)) * (1 - rr / (2 * sigma**2)) * np.exp(-rr / (2 * sigma**2))
k -= k.mean() # jumlah elemen kernel dibuat 0
return k
def terapkan_log(img, sigma):
return convolve2d(img, kernel_log(sigma), mode="same", boundary="symm")
def normalisasi(a):
"""Skala hasil ke rentang 0-255 agar bisa disimpan sebagai citra."""
a = a - a.min()
if a.max() > 0:
a = a / a.max() * 255
return a.astype(np.uint8)
def simpan(arr, nama):
Image.fromarray(normalisasi(arr)).save(os.path.join(FOLDER, nama))
# a. Baca citra (rom.jpeg harus berada di folder yang sama dengan program ini)
nama_file = "rom.jpeg"
path_citra = os.path.join(FOLDER, nama_file)
if not os.path.exists(path_citra):
raise FileNotFoundError(f"{nama_file} tidak ditemukan. Taruh di folder: {FOLDER}")
img = np.array(Image.open(path_citra).convert("L"), dtype=np.float64)
# b. Filter LoG sigma = 0,1 -> im1
im1 = terapkan_log(img, 0.1)
simpan(im1, "im1.png")
# c. Filter LoG sigma = 0,2 -> im2
im2 = terapkan_log(img, 0.2)
simpan(im2, "im2.png")
# d. Selisih kedua citra -> citra hasil
hasil = im1 - im2
simpan(hasil, "hasil_selisih.png")
# Bukti bahwa im1, im2, dan hasil hampir identik setelah dinormalisasi
n1, n2, nh = normalisasi(im1), normalisasi(im2), normalisasi(hasil)
print("Korelasi im1 vs im2 :", np.corrcoef(n1.ravel(), n2.ravel())[0, 1])
print("Korelasi im1 vs hasil :", np.corrcoef(n1.ravel(), nh.ravel())[0, 1])
print("Selisih rata-rata im1 vs im2 (0-255):", np.abs(n1.astype(int) - n2.astype(int)).mean())
# Sebelum dinormalisasi, ketiga citra hanya berbeda skala
for nama, arr in (("im1", im1), ("im2", im2), ("hasil", hasil)):
print(f"Rentang nilai mentah {nama:<5}: {arr.min():.1f} s.d. {arr.max():.1f}")
print("Selesai. File tersimpan di:", FOLDER)
+190
View File
@@ -0,0 +1,190 @@
# Soal 1 — Selisih Laplacian of Gaussian (LoG)
Program: `soal1.py` · Input: `rom.jpeg` · Output: `im1.png`, `im2.png`, `hasil_selisih.png`
Cara menjalankan: `python soal1.py` (library: `numpy`, `pillow`, `scipy`)
Referensi teori: Gonzalez & Woods, *Digital Image Processing*, edisi ke-4 (Pearson, 2018).
## 1. Tujuan dan Asumsi
Soal meminta program yang menjalankan algoritma berikut:
1. Baca citra.
2. Terapkan filter LoG dengan simpangan baku σ = 0,1, simpan sebagai **im1**.
3. Terapkan filter LoG dengan σ = 0,2, simpan sebagai **im2**.
4. Hitung selisih kedua citra, simpan sebagai **citra hasil**.
Kelompok memakai nilai σ persis seperti tertulis di soal (0,1 dan 0,2 piksel). Seperti dijelaskan di bagian 4, kedua nilai ini jauh lebih kecil dari jarak antar-piksel, sehingga kernel LoG tidak terbentuk utuh dan ketiga citra output menjadi hampir identik. Bagian 4 menganalisis hal ini secara matematis dan membuktikannya dengan angka hasil program.
## 2. Alur Program
| Langkah soal | Kode di `soal1.py` |
|---|---|
| a. Baca citra | `Image.open(...).convert("L")`: citra dibaca lalu diubah ke grayscale bertipe `float64` |
| b. LoG σ = 0,1 → im1 | `im1 = terapkan_log(img, 0.1)`, disimpan sebagai `im1.png` |
| c. LoG σ = 0,2 → im2 | `im2 = terapkan_log(img, 0.2)`, disimpan sebagai `im2.png` |
| d. Selisih → hasil | `hasil = im1 - im2`, disimpan sebagai `hasil_selisih.png` |
Fungsi pendukung:
- `kernel_log(sigma, ukuran=9)`: membuat kernel LoG 9 × 9, lalu membuat jumlah elemennya nol.
- `terapkan_log(img, sigma)`: mengonvolusikan citra dengan kernel LoG.
- `normalisasi(a)`: menskalakan nilai ke 0–255 agar bisa disimpan sebagai citra 8-bit.
Selisih dihitung dari nilai **mentah** im1 dan im2, sebelum normalisasi. Jika dihitung dari citra yang sudah dinormalisasi, informasi skala dan tanda hilang dan selisihnya tidak lagi bermakna.
## 3. Dasar Teori
### 3.1 Laplacian (Bab 3)
Laplacian adalah operator turunan kedua yang isotropik:
```
∇²f = ∂²f/∂x² + ∂²f/∂y²
```
Laplacian bernilai nol di daerah intensitas konstan dan memberi respons kuat di tepi dan detail halus. Kelemahannya, turunan kedua sangat peka terhadap noise.
### 3.2 Laplacian of Gaussian
Untuk mengurangi kepekaan terhadap noise, citra dihaluskan dulu dengan Gaussian, lalu diberi Laplacian. Karena konvolusi dan turunan sama-sama operasi linear:
```
∇²[G(x, y) ★ f(x, y)] = [∇²G(x, y)] ★ f(x, y)
```
Jadi cukup satu kernel, yaitu LoG = ∇²G. Kernel yang dipakai program:
```
LoG(x, y) = −(1 / πσ⁴) · [1 − (x² + y²) / 2σ²] · e^(−(x² + y²) / 2σ²)
```
Bentuk ini sama dengan bentuk ∇²G di buku, [(x² + y² − 2σ²) / σ⁴] · e^(−(x² + y²) / 2σ²), dikalikan konstanta normalisasi Gaussian 1/(2πσ²). Nilai σ menentukan skala: σ besar menghaluskan lebih kuat sehingga hanya detail besar yang terdeteksi.
Kernel LoG yang ideal berjumlah nol, sehingga respons di daerah konstan tepat nol. Kernel hasil sampling pada grid terbatas umumnya tidak tepat berjumlah nol, jadi program mengurangkan rata-rata kernel dari setiap elemen (`k -= k.mean()`).
Catatan: LoG sebagai detektor tepi (metode Marr-Hildreth) dibahas di Bab 10. Komponennya, yaitu Laplacian dan penghalusan Gaussian, sudah dibahas di Bab 3.
### 3.3 Konvolusi spasial (Bab 3)
```
g(x, y) = Σₛ Σₜ w(s, t) · f(x − s, y − t)
```
`convolve2d(..., mode="same", boundary="symm")` menghasilkan citra berukuran sama dengan input. Di tepi citra dipakai padding cermin, yaitu piksel tepi dicerminkan ke luar. Kernel LoG simetris, jadi konvolusi dan korelasi memberi hasil yang sama.
### 3.4 Penskalaan intensitas (Bab 2)
Output LoG berisi nilai negatif dan positif dengan rentang besar, jadi perlu diskalakan sebelum disimpan sebagai citra 8-bit:
```
f_m = f − min(f)
f_s = 255 · f_m / max(f_m)
```
Fungsi `normalisasi` menjalankan persis rumus ini.
## 4. Analisis: Kernel LoG dengan σ = 0,1 dan σ = 0,2
### 4.1 Kernel hanya bernilai di piksel tengah
Jarak antar-piksel adalah 1. Pada tetangga terdekat (r = 1), faktor Gaussian bernilai:
| σ | e^(−1/2σ²) di r = 1 | Nilai di pusat sebelum dikurangi rata-rata, −1/(πσ⁴) |
|---|---|---|
| 0,1 | 1,9 × 10⁻²² | −3183,10 |
| 0,2 | 3,7 × 10⁻⁶ | −198,94 |
Seluruh bobot Gaussian berada di piksel tengah. Kernel sebelum dikurangi rata-rata praktis berupa impuls: K ≈ c_σ · δ, dengan c_σ = −1/(πσ⁴).
### 4.2 Bentuk kernel ditentukan oleh langkah "jumlah = 0"
Setelah `k -= k.mean()`, setiap elemen dikurangi c_σ/81. Isi kernel 9 × 9 menjadi:
| σ | Pusat | Tetangga (r = 1) | Diagonal (r = √2) | Pojok |
|---|---|---|---|---|
| 0,1 | −3143,80 | 39,30 | 39,30 | 39,30 |
| 0,2 | −196,49 | 2,46 | 2,46 | 2,46 |
Kedua kernel hanyalah kelipatan dari matriks yang sama:
```
K_σ ≈ c_σ · (δ − J/81)
```
dengan δ = impuls di pusat dan J = matriks 9 × 9 berisi 1. Konvolusi dengan J/81 adalah filter rata-rata 9 × 9, sehingga:
```
im_σ ≈ c_σ · (f − f̄₉ₓ₉)
```
f − f̄ adalah *unsharp mask* (Bab 3): citra dikurangi versi kaburnya, yang menonjolkan detail lokal. Jadi im1 dan im2 sama-sama berupa unsharp mask 9 × 9 dengan skala berbeda, bukan LoG yang sebenarnya. Korelasi nilai mentah dengan (f − f̄₉ₓ₉) pada `rom.jpeg` membuktikannya:
| Citra | Korelasi dengan (f − f̄₉ₓ₉) |
|---|---|
| im1 | −1,0000000000 |
| im2 | −0,9999999986 |
| hasil | −0,9999999999 |
Tanda negatif berasal dari c_σ yang negatif.
### 4.3 Akibatnya pada selisih
Karena im_σ sebanding dengan 1/σ⁴:
```
im1 / im2 = (σ₂/σ₁)⁴ = (0,2/0,1)⁴ = 16
hasil = im1 − im2 = [1 − (σ₁/σ₂)⁴] · im1 = 0,9375 · im1
```
Hasil program sesuai dengan perhitungan ini. Median rasio im1/im2 = 16,002, dan median rasio hasil/im1 = 0,9375. Rentang nilai mentahnya:
| Citra | Minimum | Maksimum |
|---|---|---|
| im1 | −589.187,7 | 513.736,4 |
| im2 | −36.820,3 | 32.106,2 |
| hasil | −552.367,3 | 481.630,2 |
Normalisasi min-max menghapus faktor skala, sehingga ketiga citra yang disimpan praktis identik:
| Perbandingan (setelah normalisasi 0–255) | Korelasi | Piksel berbeda | Beda maksimum |
|---|---|---|---|
| im1 vs im2 | 0,9999973 | 2886 dari 1.440.000 | 1 tingkat abu-abu |
| im1 vs hasil | 0,99999999 | 16 dari 1.440.000 | 1 tingkat abu-abu |
Perbedaan kecil antara im1 dan im2 berasal dari sisa nilai Gaussian di r = 1 pada σ = 0,2 (sekitar 4 × 10⁻⁶) dan dari pembulatan ke 8-bit.
### 4.4 Ukuran kernel
Gonzalez & Woods menyarankan ukuran kernel LoG berupa bilangan ganjil terkecil yang ≥ 6σ. Untuk σ = 0,1 dan 0,2, aturan itu memberi kernel 1 × 1. Kernel 1 × 1 setelah dikurangi rata-ratanya bernilai 0, sehingga outputnya citra hitam seluruhnya. Karena itu program memakai ukuran tetap 9 × 9. Pada σ sekecil ini, ukuran kernel inilah yang menentukan bentuk hasil: hasil = unsharp mask dengan filter rata-rata seukuran kernel.
## 5. Hasil
Output program:
```
Korelasi im1 vs im2 : 0.9999973316611581
Korelasi im1 vs hasil : 0.9999999851773528
Selisih rata-rata im1 vs im2 (0-255): 0.0020041666666666667
Rentang nilai mentah im1 : -589187.7 s.d. 513736.4
Rentang nilai mentah im2 : -36820.3 s.d. 32106.2
Rentang nilai mentah hasil: -552367.3 s.d. 481630.2
```
| Citra input (`rom.jpeg`) | im1 (σ = 0,1) |
|---|---|
| ![rom](rom.jpeg) | ![im1](im1.png) |
| im2 (σ = 0,2) | Citra hasil (im1 − im2) |
|---|---|
| ![im2](im2.png) | ![hasil](hasil_selisih.png) |
Ketiga output berwarna abu-abu rata dengan tepi dan detail yang menonjol, ciri khas unsharp mask setelah normalisasi. Daerah halus seperti langit bernilai sekitar abu-abu tengah, karena f − f̄ ≈ 0 di daerah tersebut.
## 6. Kesimpulan
- Program menjalankan langkah a–d sesuai soal dengan rumus LoG yang benar.
- Dengan σ = 0,1 dan 0,2 piksel, Gaussian jauh lebih sempit dari satu piksel. Kernel LoG yang disampling tidak lagi berbentuk LoG, melainkan unsharp mask 9 × 9 yang dikalikan −1/(πσ⁴).
- im1, im2, dan citra hasil hanya berbeda faktor skala (16 : 1 : 15), sehingga setelah dinormalisasi ketiganya praktis identik.
- Agar selisih LoG menunjukkan perbedaan antar-skala, σ perlu jauh lebih besar dari 1 piksel, misalnya σ = 1 dan σ = 2 dengan ukuran kernel ≥ 6σ.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 B

+89
View File
@@ -0,0 +1,89 @@
"""
UTS INF301 Pengolahan Citra Digital - Soal 2
Memecah teks menjadi karakter individual menggunakan regionprops,
lalu menyimpan setiap karakter sebagai gambar terpisah.
"""
import os
import numpy as np
from skimage import io, color, filters, measure, morphology, transform, util
# Input dan output relatif terhadap folder program ini, sehingga program
# bisa dijalankan dari folder mana pun.
FOLDER = os.path.dirname(os.path.abspath(__file__))
INPUT = os.path.join(FOLDER, "Problem 2 Image.png")
OUTPUT_DIR = os.path.join(FOLDER, "karakter")
MIN_AREA = 20 # region lebih kecil dari ini dianggap noise
# Radius dilasi untuk menggabungkan huruf menjadi kata. Pada citra ini celah
# antar-huruf dalam satu kata <= 87 piksel dan celah antar-kata >= 104 piksel,
# sehingga disk radius 48 (menutup celah ~96 piksel) memisahkan keduanya.
WORD_RADIUS = 48
# Huruf satu goresan (seperti I) sangat memanjang. Untuk huruf ini sumbu
# utamanya sendiri lebih tepat dipakai daripada sudut lengkung kata. Pada
# citra ini eccentricity I = 0.97, huruf lain <= 0.88.
ELONGATED_ECC = 0.95
# 1. Baca citra lalu ubah ke grayscale (citra asli RGBA)
img = io.imread(INPUT)
if img.ndim == 3 and img.shape[2] == 4:
img = color.rgba2rgb(img)
gray = color.rgb2gray(img) if img.ndim == 3 else util.img_as_float(img)
# 2. Segmentasi: threshold Otsu memisahkan teks putih dari latar hitam
thresh = filters.threshold_otsu(gray)
binary = gray > thresh
binary = morphology.remove_small_objects(binary, max_size=MIN_AREA)
# 3. Labeling: setiap komponen terhubung (8-connectivity) = satu karakter
labels = measure.label(binary, connectivity=2)
# 4. regionprops: ambil bounding box setiap karakter
regions = measure.regionprops(labels, intensity_image=gray)
print(f"Jumlah karakter terdeteksi: {len(regions)}")
# 5. Kelompokkan karakter menjadi kata: dilasi menyatukan huruf yang
# berdekatan, lalu labeling memberi satu label per kata.
words = measure.label(morphology.dilation(binary, morphology.disk(WORD_RADIUS)))
word_of = [words[tuple(region.coords[0])] for region in regions]
# 6. Sudut kemiringan setiap huruf. Baseline kata bisa melengkung
# (contoh: TWINPEAK), jadi sudut dihitung per huruf dari garis singgung
# lengkung yang melalui centroid huruf-huruf dalam katanya.
char_angle = [0.0] * len(regions)
for w in set(word_of):
idx = [i for i, k in enumerate(word_of) if k == w]
pts = np.array([regions[i].centroid for i in idx])
pts = pts - pts.mean(axis=0)
# Sumbu utama (PCA) kumpulan centroid = arah umum kata
_, _, vt = np.linalg.svd(pts)
u_dir, v_dir = vt[0], vt[1] # vektor (baris, kolom)
if u_dir[1] < 0: # teks dibaca dari kiri ke kanan
u_dir = -u_dir
u, v = pts @ u_dir, pts @ v_dir
# Lengkung kuadratik v = a u^2 + b u + c, kemiringan lokal dv/du = 2au + b
a, b, _ = np.polyfit(u, v, 2)
for i, ui in zip(idx, u):
dr, dc = u_dir + (2 * a * ui + b) * v_dir
# baris citra bertambah ke bawah, maka -dr agar sudut berlawanan jarum jam positif
char_angle[i] = np.degrees(np.arctan2(-dr, dc))
# 7. Crop, luruskan, dan simpan setiap karakter.
os.makedirs(OUTPUT_DIR, exist_ok=True)
for i, (region, w) in enumerate(zip(regions, word_of), start=1):
angle = char_angle[i - 1]
if region.eccentricity > ELONGATED_ECC:
# orientation = sudut sumbu utama terhadap sumbu vertikal (baris);
# putar sebesar itu agar goresan menjadi vertikal
angle = np.degrees(region.orientation)
# image_intensity = piksel karakter ini saja; karakter tetangga yang
# masuk ke dalam kotak tidak ikut tersimpan
# Putar balik sebesar sudut kata agar huruf tegak; resize=True agar
# ujung huruf tidak terpotong.
upright = transform.rotate(region.image_intensity, -angle, resize=True)
# Kotak baru mengikuti huruf yang sudah tegak
rows, cols = np.nonzero(upright > thresh)
upright = upright[rows.min():rows.max() + 1, cols.min():cols.max() + 1]
char_img = util.img_as_ubyte(np.clip(upright, 0, 1))
filename = os.path.join(OUTPUT_DIR, f"karakter_{i:02d}.png")
io.imsave(filename, char_img)
print(f"{filename}: kata={w}, sudut={angle:.1f} derajat, area={region.area}")
+215
View File
@@ -0,0 +1,215 @@
# Soal 2 — Memecah Teks Menjadi Karakter Individual dengan `regionprops`
Program: `soal2.py` · Input: `Problem 2 Image.png` · Output: folder `karakter/` (18 file PNG)
Cara menjalankan: `python soal2.py` (library: `numpy`, `scikit-image`)
Referensi teori: Gonzalez & Woods, *Digital Image Processing*, edisi ke-4 (Pearson, 2018).
## 1. Tujuan
Citra input berisi tiga kata putih di atas latar hitam: **ZEUS** (horizontal), **TWINPEAK** (miring naik), dan **ACOSTA** (miring tajam). Program harus memisahkan setiap huruf dan menyimpannya sebagai gambar tersendiri. Huruf pada hasil harus tegak, yaitu kotak potongannya mengikuti kemiringan tulisan, lalu huruf diputar balik hingga lurus.
![Citra input](Problem%202%20Image.png)
## 2. Alur Program
1. Baca citra, ubah ke grayscale.
2. Segmentasi dengan threshold Otsu menjadi citra biner.
3. Buang noise kecil.
4. Labeling komponen terhubung: satu komponen = satu karakter.
5. `regionprops`: ambil properti setiap karakter.
6. Dilasi untuk mengelompokkan karakter menjadi kata.
7. Hitung sudut kemiringan setiap huruf dari lengkung yang melalui centroid huruf-huruf dalam katanya. Huruf satu goresan (I) memakai sumbu utamanya sendiri.
8. Putar setiap karakter sebesar sudutnya, potong ulang, simpan.
## 3. Penjelasan Setiap Langkah
### 3.1 Representasi citra dan konversi grayscale (Bab 2 dan Bab 7)
Citra digital adalah fungsi dua dimensi f(x, y) yang disimpan sebagai matriks piksel. File input berformat RGBA berukuran 762 × 860 × 4. Kanal alpha dibuang (`rgba2rgb`), lalu citra RGB diubah menjadi satu kanal intensitas (`rgb2gray`) dengan kombinasi linear:
```
Y = 0.2125 R + 0.7154 G + 0.0721 B
```
Hasilnya matriks bernilai [0, 1]. Tulisan bernilai mendekati 1 (putih), latar mendekati 0 (hitam).
### 3.2 Segmentasi dengan threshold Otsu
Thresholding mengubah citra grayscale menjadi citra biner:
```
g(x, y) = 1 jika f(x, y) > T
g(x, y) = 0 jika f(x, y) ≤ T
```
Nilai T dipilih otomatis dengan metode Otsu. Otsu mencoba setiap nilai k dan memilih k yang memaksimalkan varians antar-kelas:
```
σ²_B(k) = [m_G · P₁(k) − m(k)]² / [P₁(k) · (1 − P₁(k))]
```
dengan P₁(k) = proporsi piksel ≤ k, m(k) = rata-rata kumulatif intensitas sampai k, dan m_G = rata-rata intensitas global. Histogram citra ini bimodal (tulisan dan latar), jadi Otsu memisahkan keduanya dengan baik. Pada citra ini T = 0,342 (skala 0–1).
Catatan: metode Otsu dibahas di Bab 10 (Segmentasi), setelah materi UTS. Konsep dasarnya, yaitu fungsi thresholding sebagai transformasi intensitas, sudah dibahas di Bab 3.
### 3.3 Pembuangan noise
`remove_small_objects` menghapus komponen dengan luas ≤ 20 piksel. Komponen sekecil itu bukan huruf, melainkan noise sisa kompresi citra. Pada citra ini tidak ada komponen yang terbuang, tetapi langkah ini menjaga agar noise tidak tersimpan sebagai "karakter".
### 3.4 Labeling komponen terhubung (Bab 2 dan Bab 9)
**Ketetanggaan (Bab 2).** Piksel p di (x, y) memiliki:
- 4-tetangga N₄(p): (x±1, y) dan (x, y±1).
- 8-tetangga N₈(p): N₄(p) ditambah 4 tetangga diagonal.
Dua piksel foreground termasuk satu komponen jika ada jalur piksel foreground di antara keduanya. Program memakai **8-connectivity** (`connectivity=2`), sehingga goresan diagonal huruf seperti A, K, dan W tetap tersambung sebagai satu komponen.
**Ekstraksi komponen terhubung (Bab 9).** Secara morfologi, komponen yang memuat piksel awal X₀ diperoleh dengan iterasi:
```
X_k = (X_{k−1} ⊕ B) ∩ I, k = 1, 2, 3, ...
```
dengan I = citra biner dan B = elemen penstruktur 3 × 3 berisi 1 (untuk 8-connectivity). Iterasi berhenti saat X_k = X_{k−1}. `measure.label` menjalankan proses yang setara untuk setiap komponen dan memberi label 1, 2, 3, dan seterusnya. Hasilnya **18 label**, sesuai jumlah huruf: ZEUS (4) + TWINPEAK (8) + ACOSTA (6).
Pemisahan ini berhasil karena setiap huruf tidak bersentuhan dengan huruf lain. Huruf "I" juga tidak bermasalah karena kapital dan tidak memiliki titik.
### 3.5 Cara `regionprops` digunakan
`measure.regionprops(labels, intensity_image=gray)` menghasilkan satu objek per label. Properti yang dipakai program:
| Properti | Arti | Kegunaan di program |
|---|---|---|
| `coords` | Daftar koordinat (baris, kolom) piksel region | Mencari label kata dari region (langkah 3.6) |
| `centroid` | Titik pusat region: rata-rata koordinat piksel | Menghitung lengkung kata dan sudut setiap huruf (langkah 3.7) |
| `eccentricity` | Seberapa memanjang region: 0 = lingkaran, mendekati 1 = garis | Mengenali huruf satu goresan seperti I (langkah 3.7) |
| `orientation` | Sudut sumbu utama region terhadap sumbu vertikal | Sudut putar huruf satu goresan (langkah 3.7) |
| `image_intensity` | Potongan citra grayscale seukuran bounding box; piksel di luar region bernilai 0 | Gambar karakter yang diputar dan disimpan (langkah 3.8) |
| `area` | Jumlah piksel region | Informasi pada output |
`image_intensity` dipakai alih-alih memotong citra langsung. Pada huruf yang miring, bounding box satu huruf bisa memuat sebagian huruf tetangga. Dengan `image_intensity`, hanya piksel milik huruf itu sendiri yang ikut tersimpan.
Centroid dihitung dari momen orde nol dan orde satu region R:
```
area = M₀₀ = Σ 1
r̄ = M₁₀ / M₀₀ = (1/area) Σ r
c̄ = M₀₁ / M₀₀ = (1/area) Σ c
```
Catatan: deskriptor region seperti area, centroid, momen, dan komponen utama dibahas lebih lanjut di Bab 11. Program memakainya lewat `regionprops`, sesuai petunjuk soal.
### 3.6 Pengelompokan karakter menjadi kata dengan dilasi (Bab 9)
Huruf harus diputar sesuai kemiringan **katanya**, jadi program perlu tahu setiap huruf milik kata yang mana. Dilasi A oleh elemen penstruktur B didefinisikan:
```
A ⊕ B = { z | (B̂)_z ∩ A ≠ ∅ }
```
Dilasi menebalkan setiap huruf. Dengan B berupa disk radius r, dua huruf menyatu jika celah di antaranya kurang dari sekitar 2r. Hasil pengukuran jarak terdekat antar-piksel huruf pada citra ini:
- Celah antar-huruf dalam satu kata: paling besar **87 piksel** (pada ZEUS).
- Celah antar-kata: paling kecil **104 piksel** (antara E pada TWINPEAK dan A pada ACOSTA).
Radius r = 48 (menutup celah sekitar 96 piksel) berada di antara kedua nilai itu. Dilasi menyatukan huruf dalam satu kata tetapi tidak menyatukan kata yang berbeda. Labeling ulang pada citra hasil dilasi menghasilkan **3 label**, satu per kata. Label kata sebuah huruf dibaca dari citra label kata pada salah satu koordinat (`coords`) huruf tersebut.
### 3.7 Sudut kemiringan setiap huruf
Huruf-huruf dalam satu kata tersusun di sepanjang garis dasar (baseline). Baseline tidak selalu lurus. Pada TWINPEAK, kemiringan antar-centroid naik dari sekitar 2° (T–W) menjadi sekitar 27° (A–K), jadi baseline-nya melengkung. Satu sudut rata-rata per kata akan membuat huruf di ujung kata diputar terlalu jauh atau kurang jauh. Karena itu sudut dihitung **per huruf**, dari garis singgung lengkung baseline di posisi huruf tersebut.
**Langkah 1: arah umum kata (PCA).** Arah umum sebaran centroid dicari dengan analisis komponen utama:
1. Kurangi setiap centroid dengan rata-ratanya: **p**ᵢ − **p̄**.
2. Bentuk matriks kovarians C = (1/n) Σ (**p**ᵢ − **p̄**)(**p**ᵢ − **p̄**)ᵀ.
3. Eigenvektor C dengan eigenvalue terbesar = arah umum kata **u**. Eigenvektor kedua **v** tegak lurus terhadap **u**.
Program menghitungnya dengan SVD, yang memberi vektor yang sama. Arah **u** dipilih agar komponen kolomnya positif (teks dibaca dari kiri ke kanan).
**Langkah 2: lengkung baseline.** Setiap centroid diproyeksikan ke sumbu **u** dan **v**, lalu dicocokkan lengkung kuadratik dengan metode kuadrat terkecil:
```
v = a·u² + b·u + c
```
Kemiringan lengkung di posisi huruf ke-i adalah turunannya, dv/du = 2a·uᵢ + b. Arah garis singgung dalam koordinat citra:
```
(Δr, Δc) = u + (2a·uᵢ + b) · v
```
**Langkah 3: sudut.** Sudut terhadap sumbu horizontal:
```
θ = arctan2(−Δr, Δc)
```
Tanda minus diperlukan karena indeks baris citra bertambah ke bawah. Dengan begitu, θ positif berarti tulisan naik (berlawanan arah jarum jam). Untuk kata yang lurus seperti ZEUS, a ≈ 0 dan semua huruf mendapat sudut ≈ 0°.
**Pengecualian: huruf satu goresan.** Huruf I pada TWINPEAK di citra asli condong sekitar 10,7°. Nilai ini hampir sama dengan kemiringan italic font (sekitar 9,5°, diukur dari batang huruf E pada ZEUS). Artinya, I di citra asli praktis tidak diputar mengikuti lengkung, padahal lengkung memberi sudut 6,2°. Jika diputar 6,2°, I justru menjadi semakin condong (sekitar 17°).
Untuk huruf yang berupa satu goresan, arah huruf bisa dibaca langsung dari bentuknya. `regionprops` menghitung elips yang memiliki momen pusat orde dua sama dengan region:
```
μ_pq = Σ (r − r̄)^p (c − c̄)^q
```
Dari matriks kovarians [μ₂₀ μ₁₁; μ₁₁ μ₀₂] / M₀₀ diperoleh eigenvalue λ₁ ≥ λ₂. Panjang sumbu elips sebanding dengan √λ, sehingga:
- `eccentricity` = √(1 − λ₂/λ₁). Nilainya 0 untuk lingkaran dan mendekati 1 untuk garis.
- `orientation` = sudut eigenvektor λ₁ (sumbu utama) terhadap sumbu vertikal.
Eccentricity huruf I = 0,97, sedangkan huruf lain paling tinggi 0,88. Program memakai batas 0,95: huruf dengan eccentricity di atas batas itu diputar sebesar `orientation`-nya sendiri sehingga goresannya tepat vertikal. Huruf lain memakai sudut lengkung.
### 3.8 Rotasi dan pemotongan ulang (Bab 2)
Rotasi adalah transformasi geometri affine. Dalam koordinat homogen:
```
[x'] [cos θ −sin θ 0] [x]
[y'] = [sin θ cos θ 0] [y]
[1 ] [ 0 0 1] [1]
```
Setiap huruf diputar sebesar **−θ** (searah jarum jam sebesar θ) agar baseline-nya kembali horizontal. `transform.rotate` memakai *inverse mapping*: setiap piksel output (x', y') dipetakan balik ke posisi asal (x, y) dengan matriks invers. Posisi asal umumnya bukan bilangan bulat, sehingga nilainya dihitung dengan **interpolasi bilinear** dari 4 piksel terdekat:
```
v(x, y) = ax + by + cxy + d
```
Opsi `resize=True` memperbesar kanvas agar ujung huruf tidak terpotong saat diputar. Setelah rotasi, kotak dipotong ulang ke baris dan kolom yang masih memuat piksel huruf (intensitas > T Otsu). Hasilnya kotak rapat di sekitar huruf yang sudah tegak, lalu disimpan sebagai PNG 8-bit.
## 4. Hasil
Penamaan file mengikuti urutan label dari `regionprops`, yaitu urutan scan dari atas ke bawah. Karena itu urutan file tidak sama dengan urutan baca untuk TWINPEAK dan ACOSTA.
| File | Hasil | Huruf | Kata | Sudut | Sumber sudut |
|---|---|---|---|---|---|
| karakter_01.png | ![](karakter/karakter_01.png) | Z | ZEUS | −0,3° | lengkung kata |
| karakter_02.png | ![](karakter/karakter_02.png) | E | ZEUS | −0,1° | lengkung kata |
| karakter_03.png | ![](karakter/karakter_03.png) | U | ZEUS | 0,1° | lengkung kata |
| karakter_04.png | ![](karakter/karakter_04.png) | S | ZEUS | 0,3° | lengkung kata |
| karakter_05.png | ![](karakter/karakter_05.png) | K | TWINPEAK | 26,9° | lengkung kata |
| karakter_06.png | ![](karakter/karakter_06.png) | A | TWINPEAK | 22,9° | lengkung kata |
| karakter_07.png | ![](karakter/karakter_07.png) | E | TWINPEAK | 18,6° | lengkung kata |
| karakter_08.png | ![](karakter/karakter_08.png) | P | TWINPEAK | 14,4° | lengkung kata |
| karakter_09.png | ![](karakter/karakter_09.png) | N | TWINPEAK | 10,1° | lengkung kata |
| karakter_10.png | ![](karakter/karakter_10.png) | I | TWINPEAK | −10,7° | sumbu utama huruf |
| karakter_11.png | ![](karakter/karakter_11.png) | W | TWINPEAK | 2,1° | lengkung kata |
| karakter_12.png | ![](karakter/karakter_12.png) | T | TWINPEAK | −2,1° | lengkung kata |
| karakter_13.png | ![](karakter/karakter_13.png) | A | ACOSTA | 55,5° | lengkung kata |
| karakter_14.png | ![](karakter/karakter_14.png) | T | ACOSTA | 56,1° | lengkung kata |
| karakter_15.png | ![](karakter/karakter_15.png) | S | ACOSTA | 56,8° | lengkung kata |
| karakter_16.png | ![](karakter/karakter_16.png) | O | ACOSTA | 57,4° | lengkung kata |
| karakter_17.png | ![](karakter/karakter_17.png) | C | ACOSTA | 58,0° | lengkung kata |
| karakter_18.png | ![](karakter/karakter_18.png) | A | ACOSTA | 58,6° | lengkung kata |
## 5. Batasan
- **Font italic.** Setelah diluruskan, huruf masih tampak sedikit condong (sekitar 8–10°) karena fontnya italic. Ini bentuk huruf, bukan rotasi: ZEUS (sudut 0°) juga italic pada citra asli. Pengecualiannya huruf I: karena diluruskan dengan sumbu utamanya sendiri, I menjadi tepat vertikal.
- **Parameter disesuaikan dengan citra ini.** Radius dilasi 48 dan batas eccentricity 0,95 dipilih dari nilai yang diukur pada citra ini. Citra lain bisa membutuhkan nilai lain.
- **Kata minimal tiga huruf.** Lengkung kuadratik membutuhkan minimal tiga centroid.
- **Huruf tidak boleh bersentuhan.** Jika dua huruf bersentuhan, keduanya menjadi satu komponen dan tersimpan sebagai satu gambar.
+85
View File
@@ -0,0 +1,85 @@
"""Membuat gambar uji koin_100.png dari koin.png.
Soal mengandaikan gambar berisi 100 koin berbagai ukuran yang tidak saling
bersentuhan di atas latar belakang seragam. koin.png hanya berisi satu koin,
jadi koin tersebut diperkecil ke beberapa ukuran lalu ditempel 100 kali pada
posisi acak di atas latar seragam. Setiap koin diputar acak dan seluruh gambar
diberi sedikit noise agar koin sejenis tidak identik piksel per piksel, seperti
pada foto asli. Jumlah koin per ukuran diketahui, sehingga hasil
pengolahan_citra.py dapat diperiksa.
Cara menjalankan:
python buat_gambar_koin.py
"""
import os
import cv2
import numpy as np
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SOURCE_PATH = os.path.join(BASE_DIR, "koin.png")
OUTPUT_PATH = os.path.join(BASE_DIR, "koin_100.png")
CANVAS_SIZE = 3000
BACKGROUND_BGR = (40, 40, 40) # latar abu-abu gelap yang seragam
MIN_GAP = 10 # jarak minimum antar-tepi koin (piksel), agar tidak bersentuhan
# (diameter koin dalam piksel, jumlah koin); total 100 koin
COIN_SIZES = [(140, 30), (170, 27), (205, 25), (245, 18)]
NOISE_STD = 3.0 # simpangan baku noise Gaussian (tingkat keabuan)
SEED = 0
def load_coin() -> np.ndarray:
coin = cv2.imread(SOURCE_PATH, cv2.IMREAD_UNCHANGED)
if coin is None or coin.shape[2] != 4:
raise FileNotFoundError(f"{SOURCE_PATH} harus berupa PNG dengan kanal alpha.")
# Potong ke area koin saja (alpha > 0)
rows, cols = np.nonzero(coin[:, :, 3])
return coin[rows.min():rows.max() + 1, cols.min():cols.max() + 1]
def main() -> None:
rng = np.random.default_rng(SEED)
coin = load_coin()
canvas = np.full((CANVAS_SIZE, CANVAS_SIZE, 3), BACKGROUND_BGR, np.float64)
diameters = [d for d, n in COIN_SIZES for _ in range(n)]
rng.shuffle(diameters)
# Tempatkan koin besar lebih dulu agar mudah mendapat tempat
placed = [] # (baris pusat, kolom pusat, jari-jari)
for d in sorted(diameters, reverse=True):
r = d / 2
for _ in range(100000):
y, x = rng.uniform(r + MIN_GAP, CANVAS_SIZE - r - MIN_GAP, size=2)
if all(np.hypot(y - py, x - px) >= r + pr + MIN_GAP for py, px, pr in placed):
placed.append((y, x, r))
break
else:
raise RuntimeError("Tidak cukup ruang untuk menempatkan semua koin.")
for y, x, r in placed:
d = round(2 * r)
small = cv2.resize(coin, (d, d), interpolation=cv2.INTER_AREA)
# Putar koin dengan sudut acak; area di luar koin tetap transparan
rotation = cv2.getRotationMatrix2D(((d - 1) / 2, (d - 1) / 2), rng.uniform(0, 360), 1.0)
small = cv2.warpAffine(small, rotation, (d, d), flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0))
small = small.astype(np.float64)
top, left = round(y - r), round(x - r)
region = canvas[top:top + d, left:left + d]
# Alpha blending: tepi koin menyatu halus dengan latar
alpha = small[:, :, 3:4] / 255
region[:] = alpha * small[:, :, :3] + (1 - alpha) * region
canvas += rng.normal(0, NOISE_STD, canvas.shape)
cv2.imwrite(OUTPUT_PATH, np.clip(np.round(canvas), 0, 255).astype(np.uint8))
print(f"Gambar uji disimpan di: {OUTPUT_PATH}")
for d, n in COIN_SIZES:
print(f"Diameter {d} piksel: {n} koin")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

@@ -0,0 +1,5 @@
Ukuran,Jumlah koin,Diameter rata-rata (piksel)
1,30,137.17
2,27,167.29
3,25,202.27
4,18,242.21
1 Ukuran Jumlah koin Diameter rata-rata (piksel)
2 1 30 137.17
3 2 27 167.29
4 3 25 202.27
5 4 18 242.21
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+239
View File
@@ -0,0 +1,239 @@
"""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()
+4
View File
@@ -0,0 +1,4 @@
numpy
opencv-python
scikit-image
scipy
+240
View File
@@ -0,0 +1,240 @@
# Soal 3 — Menghitung Jumlah Koin untuk Setiap Ukuran dengan `regionprops`
Program: `pengolahan_citra.py` · Input: `koin_100.png` (dibuat oleh `buat_gambar_koin.py` dari `koin.png`) · Output: folder `output/`
Cara menjalankan:
```
pip install -r requirements.txt
python buat_gambar_koin.py # membuat koin_100.png
python pengolahan_citra.py # atau: python pengolahan_citra.py <gambar_koin>
```
Referensi teori: Gonzalez & Woods, *Digital Image Processing*, edisi ke-4 (Pearson, 2018).
## 1. Tujuan dan Asumsi
Sebuah gambar berisi 100 koin dengan berbagai ukuran di atas latar belakang seragam, dan koin-koin tidak saling bersentuhan. Program harus menentukan jumlah koin untuk setiap ukuran.
Asumsi yang dipakai:
- Koin tidak bersentuhan, sesuai soal. Akibatnya satu komponen terhubung = satu koin.
- Latar belakang seragam dan kontras terhadap koin.
- Jumlah dan nilai ukuran koin **tidak** diketahui sebelumnya. Program mengelompokkan ukuran secara otomatis dari data diameter.
## 2. Gambar Uji
Soal tidak menyertakan gambar. `koin.png` hanya berisi satu koin, jadi `buat_gambar_koin.py` menyusun gambar uji `koin_100.png` (3000 × 3000 piksel):
- Koin diperkecil ke 4 ukuran dan ditempel 100 kali di posisi acak.
- Jarak antar-tepi koin minimal 10 piksel, jadi tidak ada yang bersentuhan.
- Latar abu-abu gelap seragam (nilai 40).
- Setiap koin diputar acak dan seluruh gambar diberi noise Gaussian (σ = 3), supaya koin sejenis tidak identik piksel per piksel, seperti pada foto asli.
- Seed acak tetap, jadi gambar yang sama bisa dibuat ulang.
| Diameter tempel | Jumlah koin |
|---|---|
| 140 piksel | 30 |
| 170 piksel | 27 |
| 205 piksel | 25 |
| 245 piksel | 18 |
| **Total** | **100** |
Karena jumlah per ukuran diketahui, hasil program dapat diperiksa.
![Gambar uji koin_100.png](koin_100.png)
## 3. Kode Semu
```
INPUT : citra RGB berisi koin
OUTPUT: daftar (ukuran, jumlah koin, diameter rata-rata)
1. gray ← konversi citra ke grayscale
2. gray ← Gaussian blur 5×5 (gray)
3. T ← threshold Otsu dari histogram gray
4. mask ← (gray > T)
5. JIKA mayoritas piksel tepi gambar bernilai 1 MAKA mask ← NOT mask
(agar koin = 1 dan latar = 0)
6. mask ← isi lubang (mask)
7. label ← labeling komponen terhubung 8-ketetanggaan (mask)
8. label ← buang komponen yang menyentuh tepi gambar (label)
9. daftar_koin ← kosong
10. UNTUK SETIAP region DALAM regionprops(label):
JIKA region.area < MIN_AREA: lanjutkan (noise)
d ← √(4 · region.area_convex / π) (diameter koin)
tambahkan (region.label, d, region.centroid) ke daftar_koin
11. kelompok ← kosong
12. UNTUK SETIAP koin DALAM daftar_koin, terurut dari diameter terkecil:
cari kelompok k dengan |d − rata2(k)| / rata2(k) ≤ TOLERANSI
JIKA ada: masukkan koin ke kelompok yang paling dekat
JIKA tidak: buat kelompok baru berisi koin tersebut
13. urutkan kelompok berdasarkan diameter rata-rata
14. UNTUK SETIAP kelompok: tampilkan (nomor ukuran, jumlah anggota, diameter rata-rata)
15. simpan tabel CSV, mask, dan gambar anotasi
```
## 4. Penjelasan Setiap Langkah
### 4.1 Grayscale dan penghalusan (Bab 2, Bab 3, Bab 7)
Citra warna BGR diubah ke satu kanal intensitas (`cv2.cvtColor`) dengan kombinasi linear:
```
Y = 0,299 R + 0,587 G + 0,114 B
```
Lalu citra dihaluskan dengan kernel Gaussian 5 × 5 (`cv2.GaussianBlur`). Ini filter penghalus spasial linear dari Bab 3:
```
G(s, t) = K · e^(−(s² + t²) / 2σ²)
```
Dengan argumen σ = 0, OpenCV menghitung σ dari ukuran kernel, yaitu σ = 0,3·((5 − 1)·0,5 − 1) + 0,8 = 1,1. Penghalusan meredam noise sehingga threshold tidak menghasilkan bintik-bintik di latar.
### 4.2 Threshold Otsu
Citra biner diperoleh dengan:
```
mask(x, y) = 1 jika f(x, y) > T
mask(x, y) = 0 jika f(x, y) ≤ T
```
Nilai T dipilih otomatis dengan metode Otsu, yaitu nilai k yang memaksimalkan varians antar-kelas:
```
σ²_B(k) = [m_G · P₁(k) − m(k)]² / [P₁(k) · (1 − P₁(k))]
```
dengan P₁(k) = proporsi piksel ≤ k, m(k) = rata-rata kumulatif sampai k, dan m_G = rata-rata global. Latar yang seragam membuat histogram bimodal (latar dan koin), sehingga Otsu cocok. Pada `koin_100.png`, T = 126.
Catatan: metode Otsu dibahas di Bab 10 (Segmentasi), setelah materi UTS. Konsep fungsi thresholding sudah dibahas di Bab 3.
### 4.3 Polaritas
Koin bisa lebih terang atau lebih gelap dari latar. Program memeriksa piksel di keempat tepi gambar: jika lebih dari separuhnya bernilai 1, berarti latar ikut terdeteksi sebagai objek, dan mask dibalik. Pada `koin_100.png`, 0% piksel tepi bernilai 1, jadi mask tidak dibalik.
### 4.4 Pengisian lubang (Bab 9)
Relief koin (bintang, simbol $) dan pantulan cahaya bisa membuat sebagian piksel di dalam koin berada di bawah T. Akibatnya koin berlubang, atau bahkan terpecah menjadi beberapa komponen. Lubang adalah daerah latar yang dikelilingi objek. Lubang diisi dengan algoritma morfologi:
```
X_k = (X_{k−1} ⊕ B) ∩ Iᶜ, k = 1, 2, 3, ...
```
dengan I = mask, Iᶜ = komplemennya, dan B = elemen penstruktur. Iterasi berhenti saat X_k = X_{k−1}, lalu hasilnya X_k ∪ I. `binary_fill_holes` menjalankan versi otomatisnya: setiap daerah latar yang tidak terhubung ke tepi gambar dianggap lubang dan diisi. Pada `koin_100.png`, 31.941 piksel lubang terisi.
### 4.5 Labeling komponen terhubung (Bab 2 dan Bab 9)
`label(mask, connectivity=2)` memberi nomor unik pada setiap komponen terhubung dengan 8-ketetanggaan: piksel p terhubung dengan 8 tetangganya, yaitu 4 horizontal/vertikal dan 4 diagonal. Secara morfologi, satu komponen yang memuat titik awal X₀ diekstraksi dengan:
```
X_k = (X_{k−1} ⊕ B) ∩ I
```
Karena koin tidak bersentuhan, setiap koin menjadi tepat satu komponen. Pada `koin_100.png` terbentuk 450 komponen: 100 koin dan 350 fragmen kecil, yaitu potongan tepi koin yang terpisah oleh bagian bayangan.
### 4.6 Membuang koin di tepi gambar (Bab 9)
Koin yang terpotong tepi gambar luasnya tidak utuh, sehingga diameternya terukur lebih kecil dan bisa masuk ke kelompok ukuran yang salah. `clear_border` membuang setiap komponen yang menyentuh tepi gambar. Di buku, operasi ini disebut *border clearing* dan dilakukan dengan rekonstruksi morfologi. Pada `koin_100.png` tidak ada koin di tepi. Pada uji D (bagian 6), langkah ini membuang satu koin yang terpotong.
### 4.7 Cara `regionprops` digunakan
`regionprops(label)` menghasilkan satu objek pengukuran per komponen. Properti yang dipakai:
| Properti | Arti | Kegunaan di program |
|---|---|---|
| `area` | Jumlah piksel region | Membuang noise: region dengan area < 100 piksel diabaikan |
| `area_convex` | Jumlah piksel convex hull region | Menghitung diameter koin |
| `centroid` | Titik pusat region (rata-rata koordinat piksel) | Posisi lingkaran dan nomor ukuran pada gambar anotasi |
| `label` | Nomor region | Identitas koin |
**Filter area.** Pada `koin_100.png`, fragmen terbesar berukuran 37 piksel dan koin terkecil 13.484 piksel. Batas 100 piksel memisahkan keduanya dengan aman, jadi 350 fragmen terbuang dan tepat 100 koin tersisa.
**Diameter dari convex hull.** Bayangan di tepi koin cukup gelap sehingga berada di bawah T. Mask koin menjadi lingkaran yang terpotong berbentuk sabit di satu sisi. Potongan ini terbuka ke latar, jadi tidak terisi oleh pengisian lubang. Convex hull H dari region adalah himpunan konveks terkecil yang memuat region tersebut (algoritma morfologi di Bab 9). Convex hull menutup potongan sabit, sehingga luasnya mendekati luas lingkaran utuh. Diameter dihitung sebagai diameter lingkaran yang luasnya sama dengan luas convex hull:
```
A_H = π (d/2)² → d = √(4 · A_H / π)
```
Perbandingan pada `koin_100.png`:
| Diameter tempel | d dari `area` | d dari `area_convex` |
|---|---|---|
| 140 | 131,1 (93,6%) | 137,2 (98,0%) |
| 170 | 159,4 (93,7%) | 167,3 (98,4%) |
| 205 | 192,4 (93,8%) | 202,3 (98,7%) |
| 245 | 230,2 (93,9%) | 242,2 (98,9%) |
Catatan: deskriptor region seperti area dan centroid dibahas lebih lanjut di Bab 11. Program memakainya lewat `regionprops`, sesuai petunjuk soal.
### 4.8 Pengelompokan ukuran
Diameter diurutkan dari terkecil. Setiap koin dibandingkan dengan diameter rata-rata setiap kelompok yang sudah ada. Koin masuk ke kelompok terdekat jika selisih relatifnya ≤ toleransi:
```
|d − d̄_k| / d̄_k × 100% ≤ 3%
```
Jika tidak ada kelompok yang memenuhi, koin membentuk kelompok baru. Jumlah kelompok tidak ditentukan di awal, tetapi muncul dari data.
**Pemilihan toleransi.** Toleransi harus lebih besar dari variasi diameter koin sejenis, tetapi lebih kecil dari selisih antar-ukuran:
- Variasi diameter koin sejenis pada `koin_100.png` paling besar **0,21%** dari rata-rata kelompoknya.
- Selisih antar-ukuran yang berdekatan **19,7–22,0%**.
- Pada uji C (bagian 6), ukuran berbeda sekitar 7%. Toleransi 8% menggabungkan ukuran-ukuran itu, sedangkan 3% memisahkannya dengan benar.
## 5. Hasil
Output program pada `koin_100.png`:
```
Jumlah koin terdeteksi: 100
Hasil pengelompokan ukuran:
Ukuran | Jumlah koin | Diameter rata-rata (piksel)
1 | 30 | 137.17
2 | 27 | 167.29
3 | 25 | 202.27
4 | 18 | 242.21
```
| Ukuran | Diameter tempel | Jumlah sebenarnya | Jumlah dari program |
|---|---|---|---|
| 1 | 140 | 30 | 30 |
| 2 | 170 | 27 | 27 |
| 3 | 205 | 25 | 25 |
| 4 | 245 | 18 | 18 |
Semua jumlah tepat. File output di folder `output/`:
- `jumlah_koin_per_ukuran.csv`: tabel hasil.
- `mask_koin.png`: hasil segmentasi setelah pengisian lubang.
- `anotasi_koin.jpg`: setiap koin dilingkari dan diberi nomor ukuran, dengan warna berbeda per ukuran.
| Mask segmentasi | Anotasi |
|---|---|
| ![mask](output/mask_koin.png) | ![anotasi](output/anotasi_koin.jpg) |
## 6. Pengujian Tambahan
Untuk menguji ketahanan program, dibuat 4 gambar sintetis tambahan, masing-masing berisi koin berbentuk lingkaran yang tidak bersentuhan dengan noise Gaussian. Setiap gambar diuji dengan versi awal program (tanpa pengisian lubang, tanpa `clear_border`, toleransi 8%) dan versi akhir.
| Uji | Kondisi | Jumlah sebenarnya | Versi awal | Versi akhir |
|---|---|---|---|---|
| A | Koin polos, 3 ukuran | 40 / 35 / 25 | 40 / 35 / 25 | 40 / 35 / 25 |
| B | Koin dengan relief gelap di dalam | 40 / 35 / 25 | 200 koin, 6 ukuran | 40 / 35 / 25 |
| C | 4 ukuran, selisih sekitar 7% | 30 / 25 / 25 / 20 | 55 / 45 | 30 / 25 / 25 / 20 |
| D | 3 ukuran, ditambah 1 koin terpotong tepi | 40 / 35 / 24 + 1 terpotong | 40 / 36 / 24 | 40 / 35 / 24 |
- **Uji B** menunjukkan pentingnya pengisian lubang. Relief membelah setiap koin menjadi cincin luar dan bagian dalam, sehingga satu koin terhitung dua.
- **Uji C** menunjukkan pentingnya toleransi yang tepat.
- **Uji D** menunjukkan pentingnya `clear_border`. Pada versi akhir, program juga mencetak peringatan karena jumlah koin utuh (99) berbeda dari 100.
## 7. Batasan
- **Koin bersentuhan.** Koin yang bersentuhan menjadi satu komponen dan terhitung satu koin besar. Ini sesuai asumsi soal. Untuk kasus bersentuhan diperlukan teknik pemisahan seperti watershed.
- **Parameter bergantung pada gambar.** `MIN_COIN_AREA` (100 piksel) bergantung pada resolusi. Toleransi 3% bergantung pada seberapa mirip ukuran-ukuran koin dan seberapa besar variasi pengukuran. Pada foto asli, kedua nilai ini perlu dicek ulang.
- **Ukuran dalam piksel.** Diameter dinyatakan dalam piksel. Untuk ukuran dalam milimeter diperlukan benda acuan berukuran diketahui di dalam gambar.
- **Perspektif.** Jika foto diambil miring, koin tampak elips dan koin yang lebih jauh tampak lebih kecil. Foto sebaiknya diambil tegak lurus dari atas.
+51
View File
@@ -0,0 +1,51 @@
# UTS Pengolahan Citra Digital (INF301) — Kelompok I
Jawaban Ujian Tengah Semester Gasal 2026/2027, Program Studi Informatika, Fakultas Teknik, Universitas Katolik Widya Mandala Surabaya.
Dosen pengampu: Peter Rhatodirdjo Angka
| No | NRP | Nama | Soal |
|---|---|---|---|
| 1 | 5103023003 | Rovino Avianto | Soal 1 |
| 2 | 5803024009 | Efraim Melvyn Rafelino Lahai | Soal 2 |
| 3 | 5803024011 | Benedictus Erlangga | Soal 3 |
## Struktur
```
Problem 1/ Selisih Laplacian of Gaussian (LoG)
soal1.py program
rom.jpeg citra input
im1.png, im2.png hasil LoG sigma 0,1 dan 0,2
hasil_selisih.png citra hasil (im1 - im2)
soal1_penjelasan.md penjelasan
Problem 2/ Memecah teks menjadi karakter dengan regionprops
soal2.py program
Problem 2 Image.png citra input
karakter/ 18 karakter hasil, sudah diluruskan
soal2_penjelasan.md penjelasan
Problem 3/ Menghitung jumlah koin per ukuran dengan regionprops
pengolahan_citra.py program
buat_gambar_koin.py pembuat gambar uji koin_100.png dari koin.png
koin.png, koin_100.png gambar sumber dan gambar uji (100 koin)
output/ tabel CSV, mask, dan gambar anotasi
requirements.txt dependensi soal 3
soal3_penjelasan.md penjelasan dan kode semu
```
## Menjalankan
Dibutuhkan Python 3.10 atau lebih baru.
```
pip install numpy pillow scipy scikit-image opencv-python
python "Problem 1/soal1.py"
python "Problem 2/soal2.py"
python "Problem 3/buat_gambar_koin.py"
python "Problem 3/pengolahan_citra.py"
```
Setiap program membaca input dan menyimpan output di foldernya sendiri, sehingga bisa dijalankan dari folder mana pun.