commit 003b2c544cadef961aea1ec98287f8866f060f93 Author: MelvynFaith Date: Thu Oct 22 23:43:56 2026 +0700 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba11717 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +venv/ +.venv/ + +# Bukan source code: soal ujian dan laporan Word +*.pdf +*.docx +~$* diff --git a/Problem 1/hasil_selisih.png b/Problem 1/hasil_selisih.png new file mode 100644 index 0000000..6e015b1 Binary files /dev/null and b/Problem 1/hasil_selisih.png differ diff --git a/Problem 1/im1.png b/Problem 1/im1.png new file mode 100644 index 0000000..5e440ae Binary files /dev/null and b/Problem 1/im1.png differ diff --git a/Problem 1/im2.png b/Problem 1/im2.png new file mode 100644 index 0000000..7868120 Binary files /dev/null and b/Problem 1/im2.png differ diff --git a/Problem 1/rom.jpeg b/Problem 1/rom.jpeg new file mode 100644 index 0000000..80a8d8f Binary files /dev/null and b/Problem 1/rom.jpeg differ diff --git a/Problem 1/soal1.py b/Problem 1/soal1.py new file mode 100644 index 0000000..cfdcbad --- /dev/null +++ b/Problem 1/soal1.py @@ -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) \ No newline at end of file diff --git a/Problem 1/soal1_penjelasan.md b/Problem 1/soal1_penjelasan.md new file mode 100644 index 0000000..a3f8452 --- /dev/null +++ b/Problem 1/soal1_penjelasan.md @@ -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σ. diff --git a/Problem 1/ss_hasil.png b/Problem 1/ss_hasil.png new file mode 100644 index 0000000..704bef3 Binary files /dev/null and b/Problem 1/ss_hasil.png differ diff --git a/Problem 2/Problem 2 Image.png b/Problem 2/Problem 2 Image.png new file mode 100644 index 0000000..8bbf55e Binary files /dev/null and b/Problem 2/Problem 2 Image.png differ diff --git a/Problem 2/karakter/karakter_01.png b/Problem 2/karakter/karakter_01.png new file mode 100644 index 0000000..cf20875 Binary files /dev/null and b/Problem 2/karakter/karakter_01.png differ diff --git a/Problem 2/karakter/karakter_02.png b/Problem 2/karakter/karakter_02.png new file mode 100644 index 0000000..3ad3d32 Binary files /dev/null and b/Problem 2/karakter/karakter_02.png differ diff --git a/Problem 2/karakter/karakter_03.png b/Problem 2/karakter/karakter_03.png new file mode 100644 index 0000000..5a49bdb Binary files /dev/null and b/Problem 2/karakter/karakter_03.png differ diff --git a/Problem 2/karakter/karakter_04.png b/Problem 2/karakter/karakter_04.png new file mode 100644 index 0000000..10af248 Binary files /dev/null and b/Problem 2/karakter/karakter_04.png differ diff --git a/Problem 2/karakter/karakter_05.png b/Problem 2/karakter/karakter_05.png new file mode 100644 index 0000000..42f50a7 Binary files /dev/null and b/Problem 2/karakter/karakter_05.png differ diff --git a/Problem 2/karakter/karakter_06.png b/Problem 2/karakter/karakter_06.png new file mode 100644 index 0000000..62696c4 Binary files /dev/null and b/Problem 2/karakter/karakter_06.png differ diff --git a/Problem 2/karakter/karakter_07.png b/Problem 2/karakter/karakter_07.png new file mode 100644 index 0000000..116384c Binary files /dev/null and b/Problem 2/karakter/karakter_07.png differ diff --git a/Problem 2/karakter/karakter_08.png b/Problem 2/karakter/karakter_08.png new file mode 100644 index 0000000..5756adb Binary files /dev/null and b/Problem 2/karakter/karakter_08.png differ diff --git a/Problem 2/karakter/karakter_09.png b/Problem 2/karakter/karakter_09.png new file mode 100644 index 0000000..839f298 Binary files /dev/null and b/Problem 2/karakter/karakter_09.png differ diff --git a/Problem 2/karakter/karakter_10.png b/Problem 2/karakter/karakter_10.png new file mode 100644 index 0000000..734a012 Binary files /dev/null and b/Problem 2/karakter/karakter_10.png differ diff --git a/Problem 2/karakter/karakter_11.png b/Problem 2/karakter/karakter_11.png new file mode 100644 index 0000000..c942b1e Binary files /dev/null and b/Problem 2/karakter/karakter_11.png differ diff --git a/Problem 2/karakter/karakter_12.png b/Problem 2/karakter/karakter_12.png new file mode 100644 index 0000000..45cf0f5 Binary files /dev/null and b/Problem 2/karakter/karakter_12.png differ diff --git a/Problem 2/karakter/karakter_13.png b/Problem 2/karakter/karakter_13.png new file mode 100644 index 0000000..a58021e Binary files /dev/null and b/Problem 2/karakter/karakter_13.png differ diff --git a/Problem 2/karakter/karakter_14.png b/Problem 2/karakter/karakter_14.png new file mode 100644 index 0000000..0a6a1a9 Binary files /dev/null and b/Problem 2/karakter/karakter_14.png differ diff --git a/Problem 2/karakter/karakter_15.png b/Problem 2/karakter/karakter_15.png new file mode 100644 index 0000000..7019c48 Binary files /dev/null and b/Problem 2/karakter/karakter_15.png differ diff --git a/Problem 2/karakter/karakter_16.png b/Problem 2/karakter/karakter_16.png new file mode 100644 index 0000000..a103ffd Binary files /dev/null and b/Problem 2/karakter/karakter_16.png differ diff --git a/Problem 2/karakter/karakter_17.png b/Problem 2/karakter/karakter_17.png new file mode 100644 index 0000000..02418af Binary files /dev/null and b/Problem 2/karakter/karakter_17.png differ diff --git a/Problem 2/karakter/karakter_18.png b/Problem 2/karakter/karakter_18.png new file mode 100644 index 0000000..487b7e6 Binary files /dev/null and b/Problem 2/karakter/karakter_18.png differ diff --git a/Problem 2/soal2.py b/Problem 2/soal2.py new file mode 100644 index 0000000..368a2b4 --- /dev/null +++ b/Problem 2/soal2.py @@ -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}") diff --git a/Problem 2/soal2_penjelasan.md b/Problem 2/soal2_penjelasan.md new file mode 100644 index 0000000..7cabc67 --- /dev/null +++ b/Problem 2/soal2_penjelasan.md @@ -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. diff --git a/Problem 3/buat_gambar_koin.py b/Problem 3/buat_gambar_koin.py new file mode 100644 index 0000000..acedb92 --- /dev/null +++ b/Problem 3/buat_gambar_koin.py @@ -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() diff --git a/Problem 3/koin.png b/Problem 3/koin.png new file mode 100644 index 0000000..ecd0606 Binary files /dev/null and b/Problem 3/koin.png differ diff --git a/Problem 3/koin_100.png b/Problem 3/koin_100.png new file mode 100644 index 0000000..c8a4e1f Binary files /dev/null and b/Problem 3/koin_100.png differ diff --git a/Problem 3/output/anotasi_koin.jpg b/Problem 3/output/anotasi_koin.jpg new file mode 100644 index 0000000..01f1def Binary files /dev/null and b/Problem 3/output/anotasi_koin.jpg differ diff --git a/Problem 3/output/jumlah_koin_per_ukuran.csv b/Problem 3/output/jumlah_koin_per_ukuran.csv new file mode 100644 index 0000000..6a26c58 --- /dev/null +++ b/Problem 3/output/jumlah_koin_per_ukuran.csv @@ -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 diff --git a/Problem 3/output/mask_koin.png b/Problem 3/output/mask_koin.png new file mode 100644 index 0000000..42bb6d8 Binary files /dev/null and b/Problem 3/output/mask_koin.png differ diff --git a/Problem 3/pengolahan_citra.py b/Problem 3/pengolahan_citra.py new file mode 100644 index 0000000..8046995 --- /dev/null +++ b/Problem 3/pengolahan_citra.py @@ -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() diff --git a/Problem 3/requirements.txt b/Problem 3/requirements.txt new file mode 100644 index 0000000..8308ce8 --- /dev/null +++ b/Problem 3/requirements.txt @@ -0,0 +1,4 @@ +numpy +opencv-python +scikit-image +scipy diff --git a/Problem 3/soal3_penjelasan.md b/Problem 3/soal3_penjelasan.md new file mode 100644 index 0000000..2306374 --- /dev/null +++ b/Problem 3/soal3_penjelasan.md @@ -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 +``` + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd19d48 --- /dev/null +++ b/README.md @@ -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.