Setiap proyek klasifikasi gambar dimulai dari pertanyaan yang sama: dari mana data berasal dan seperti apa isinya. Dataset foto daun nyata seperti PlantVillage berukuran puluhan gigabyte dan membutuhkan waktu unduhan serta pelatihan yang lama, jadi kita membangun dataset sintetis yang meniru struktur masalahnya — daun sehat dengan pola vena rapi versus daun berbintak dengan bercak gelap. Generator berjalan dalam hitungan detik, hasilnya identik setiap eksekusi, dan pola kodenya tetap berlaku ketika nanti Anda menukar data sintetis dengan foto nyata.

Membangun Dataset Sintetis #

Tiga fungsi inti pada data/generate_data.py membangun gambar dasar yang sama untuk kedua kelas, lalu berbeda pada gangguan yang ditambahkan:

python data/generate_data.py — fungsi dasar_daun, gambar_daun_sehat, gambar_daun_berbintak
def dasar_daun(rng):
    """Gambar dasar yang dipakai kedua kelas: latar, helai, dan vena."""
    pusat_x = 7.5 + rng.uniform(-0.5, 0.5)
    pusat_y = 7.5 + rng.uniform(-0.5, 0.5)
    mask = mask_daun(pusat_x, pusat_y,
                     6.3 + rng.uniform(-0.4, 0.4),
                     7.2 + rng.uniform(-0.4, 0.4))

    img = np.full((UKURAN, UKURAN), rng.uniform(18, 30), dtype=np.float64)
    img[mask] = rng.uniform(140, 165)          # helai daun lebih terang
    vena = pola_vena()
    img[vena & mask] = rng.uniform(195, 215)   # vena paling terang
    return img, mask, vena


def gambar_daun_sehat(rng):
    """Kelas 0: vena rapi; kadang ada 1-2 piksel noise gelap (bukan bercak)."""
    img, mask, vena = dasar_daun(rng)
    if rng.random() < 0.22:                    # noise gelap sesekali
        for _ in range(int(rng.integers(1, 3))):
            y, x = int(rng.integers(4, 12)), int(rng.integers(4, 12))
            if mask[y, x] and not vena[y, x]:
                img[y, x] -= rng.uniform(35, 60)
    img += rng.normal(0, 5.0, (UKURAN, UKURAN))
    return np.clip(img, 0, 255), 0, 0


def gambar_daun_berbintak(rng):
    """Kelas 1: vena yang sama + 1-4 bercak gelap bundar posisi seeded."""
    img, mask, vena = dasar_daun(rng)

    jumlah = int(rng.integers(1, 5))           # 1-4 bercak
    kontras_rendah = rng.random() < 0.15       # kasus sulit: 1 bercak samar
    if kontras_rendah:
        jumlah = 1

    terpasang = 0
    percobaan = 0
    while terpasang < jumlah and percobaan < 50:
        percobaan += 1
        y = int(rng.integers(2, 14))
        x = int(rng.integers(2, 14))
        if not mask[y, x] or vena[y, x]:
            continue
        radius = 1 if kontras_rendah else int(rng.choice([1, 2], p=[0.4, 0.6]))
        nilai = rng.uniform(65, 85) if kontras_rendah else rng.uniform(38, 70)
        yy, xx = np.mgrid[0:UKURAN, 0:UKURAN]
        bercak = (xx - x) ** 2 + (yy - y) ** 2 <= radius ** 2
        img[bercak & mask] = nilai
        terpasang += 1

    img += rng.normal(0, 5.0, (UKURAN, UKURAN))
    return np.clip(img, 0, 255), 1, terpasang

Mari kita telaah fungsi di atas baris per baris:

  1. rng.uniform(-0.5, 0.5) pada dasar_daun: pusat dan ukuran helai daun digeser sedikit pada setiap gambar sehingga model tidak menghafal satu posisi tetap — variasi kecil yang meniru foto nyata yang jarang terpusat sempurna.
  2. img[mask] = rng.uniform(140, 165): mask elips memisahkan tiga tingkat kecerahan — latar gelap, helai daun, dan vena paling terang — sehingga struktur daun terbaca tanpa warna.
  3. rng.random() < 0.22 pada gambar_daun_sehat: sekitar 22 persen daun sehat membawa satu-dua piksel noise gelap yang menyerupai bercak sangat kecil. Gangguan halus ini membuat sebagian kecil daun sehat sulit dibedakan — dan akan muncul kembali sebagai tiga false positive pada analisis Bagian 5.
  4. kontras_rendah = rng.random() < 0.15: sekitar 15 persen daun berbintak hanya mendapat satu bercak dengan nilai piksel 65-85 (lebih samar daripada bercak biasa yang bernilai 38-70). Kasus sulit ini sengaja dipertahankan agar proyek memiliki kesalahan nyata untuk dianalisis pada Bagian 5.
  5. bercak = (xx - x) ** 2 + (yy - y) ** 2 <= radius ** 2: bercak digambar sebagai lingkaran — cakram piksel gelap beradius 1 atau 2 — pada posisi acak yang tetap berada di dalam helai daun.
  6. rng.normal(0, 5.0, ...): noise sensor ditambahkan terakhir agar kedua kelas tidak terlihat artifaktual sempurna; np.clip(0, 255) menjaga nilai tetap sahih untuk uint8.

Seluruh angka acak berasal dari satu generator np.random.default_rng(42), sehingga dataset yang dihasilkan identik pada setiap eksekusi di mesin mana pun — prasyarat hasil pelatihan yang dapat direproduksi.

Menjalankan Generator #

Dari folder proyek, dataset dibangun dengan satu perintah:

bash
python3 data/generate_data.py

Output yang Dihasilkan:

text
berkas   : /tmp/daun_sintetis.npz
isi      : 300 gambar 16x16 grayscale (150 sehat, 150 berbintak)
piksel   : min 0, maks 229 (uint8 0-255)
bercak   : 1-4 per daun berbintak

Berkas .npz berukuran belasan kilobyte dan berisi tiga array sekaligus:

ArrayBentukIsi
X(300, 16, 16) uint8intensitas piksel 0-255
y(300,) int64label 0 = sehat, 1 = berbintak
jumlah_bintak(300,) int64metadata jumlah bercak per gambar

Array metadata jumlah_bintak tidak dipakai model saat pelatihan — metadata ini menunggu perannya pada Bagian 5, ketika kita menguji hipotesis bahwa kesalahan model terkonsentrasi pada daun dengan sedikit bercak.

Memeriksa Isi Data #

Pemeriksaan ringkas memastikan data sesuai harapan sebelum melatih model — pola yang dibahas chapter Analisis Kesalahan sebagai langkah paling murah namun sering dilewatkan:

python Pemeriksaan isi dataset daun
import numpy as np

data = np.load("/tmp/daun_sintetis.npz")
X, y, jumlah_bintak = data["X"], data["y"], data["jumlah_bintak"]

print("bentuk X :", X.shape, X.dtype)
print("label    :", np.bincount(y).tolist(), "(0 = sehat, 1 = berbintak)")
print("bercak   :", jumlah_bintak[y == 1].min(), "-",
      jumlah_bintak[y == 1].max(), "per daun berbintak")

for indeks in (0, 200):
    print(f"\ngambar #{indeks} (label {y[indeks]}, {jumlah_bintak[indeks]} bercak):")
    for baris in X[indeks]:
        print("".join("#" if v > 185 else ("+" if v > 120 else ("." if v > 60 else " "))
                     for v in baris))

Output yang Dihasilkan:

text
bentuk X : (300, 16, 16) uint8
label    : [150, 150] (0 = sehat, 1 = berbintak)
bercak   : 1 - 4 per daun berbintak

gambar #0 (label 0, 0 bercak):
                
     +++#++     
    ++#+#+#++   
   ++++###++++  
  ++++++#+++++  
  +++#++#++#++  
  ++++#+#+#++++ 
  +++++###+++++ 
  ++++++#++++++ 
  ++++++#++++++ 
  ++++#+#+#+++  
   ++++###++++  
   +++++#++++   
    ++++#++++   
     +++#++     
                

gambar #200 (label 1, 3 bercak):
                
    ++++#++     
   +++ +#+#+    
  +++   ##+++   
  ++     ++++   
 ++++   #++#++  
 +++   +#+#+++  
 ++     #+++++  
 +++   +#++++.  
 ++++ ++#+++..  
 +++++#+#+#...  
  +++++###++.   
  ++++++#++++   
   +++++#+++    
     +++#+      
                

Kedua kelas berjumlah sama (150 dan 150) sehingga akurasi sudah layak dipakai sebagai metrik utama — baseline tebakan acak berada di 0.50. Peta karakter memperlihatkan vena tengah dan cabang simetris pada daun sehat, serta tiga area gelap (karakter titik dan spasi) pada daun berbintak di posisi acak. Bercak gelap inilah satu-satunya sinyal pembeda antar kelas — persis seperti foto nyata, tempat gejala penyakit muncul sebagai area lokal, bukan perubahan seluruh gambar.

Data kini terkumpul dan sudah diperiksa: 300 gambar seimbang, dua kelas dengan satu sinyal pembeda, plus metadata jumlah bercak. Pada bagian berikutnya kita mengubah tumpukan uint8 ini menjadi tensor float yang siap dikonsumsi CNN.