BAB 27: Dari Skrip ke Produk: Dasbor, Peta Web, Model 3D, Alat Processing, dan Plugin
Studi kasus: "Hasilnya harus bisa dipakai orang lain" #
Analis Muda sudah pandai menghitung. Kepala Seksi kini meminta hal lain: ringkasan satu halaman untuk rapat, peta yang bisa dibuka dari ponsel, model 3D lahan, dan alat hitung yang bisa dipakai rekan kerja tanpa menyalin skrip. Semua itu bukan analisis baru. Itu pengemasan. Bab ini menunjukkan lima cara mengemas hasil kerja Anda, lengkap dengan skrip yang membuatnya.
Konsep: skrip sebagai pabrik kecil #
Bayangkan skrip sebagai pabrik kecil. Bahan mentahnya data KPH. Mesinnya Python. Produknya bisa berupa halaman HTML, berkas peta, berkas 3D, alat baru di QGIS, atau satu paket plugin. Anda tidak mengubah cara menghitung. Anda mengubah bentuk keluaran.

Lima istilah baru untuk bab ini:
- HTML: berkas teks yang dibuka di peramban (browser) sebagai halaman web.
- GeoJSON: berkas teks berisi objek peta dan atributnya, mudah dibaca peramban.
- Algoritma Processing: alat yang tampil di Processing Toolbox QGIS dan bisa dipanggil dari Python.
- Plugin: paket kecil yang menambah menu dan tombol di QGIS.
- Kelas dan dekorator: dua cara menulis alat; keduanya dibahas di Bab 11 dan dibandingkan di bab ini.
Menyiapkan dulu: folder kerja dengan struktur penulis #
Skrip M4 punya satu kebiasaan khusus. Mereka menghitung lokasi data dari lokasi skripnya sendiri: naik tiga folder, lalu masuk ke data-sintetis. Karena itu skrip hanya jalan bila struktur foldernya sama dengan milik penulis. Jangan ubah skripnya. Cukup bangun struktur yang sama di komputer Anda.

Langkahnya:
- Buat folder kerja, misalnya
D:/Latihan. - Di dalamnya buat
seri/M4/skrip, lalu salin semua skrip bab ini ke sana. Salin juga folderplugin_ringkas_kph. - Di folder kerja yang sama buat folder
data-sintetis. - Salin folder
paket-m4kedata-sintetis/paket-m4. - Salin folder
paket-datakedata-sintetis/paket-data.
Pada uji bab ini, struktur di atas dibangun di folder sementara dan semua skrip dijalankan dengan perintah python-qgis.bat (lihat Bab 3). Di macOS dan Ubuntu, perintahnya berbeda. [CEK]
cd D:/Latihan/seri/M4/skrip
"C:/Program Files/QGIS 4.0.2/bin/python-qgis.bat" buat_dasbor.pyJalur C:/Program Files/QGIS 4.0.2 adalah lokasi QGIS di komputer uji. Ganti sesuai pemasangan Anda. Setiap skrip membuat folder keluaran sendiri di samping dirinya.
Skrip 27.1: Dasbor satu halaman #
Berkas buat_dasbor.py. Bagi pembaca buku M4, ini skrip dasbor di Bab 4.
Tujuan. Membaca petak dan data titik panas harian, lalu menulis satu halaman HTML berisi empat kartu angka dan dua grafik.
Bahan. Paket paket-m4: Petak_KPH.gpkg (16 petak) dan Hotspot_Harian.csv (titik panas per petak per hari, 1 sampai 30 September 2026, data sintetis). Skrip tidak memakai qgis.core. Ia hanya butuh GDAL, jadi cukup dijalankan dengan python-qgis.bat.
Skrip ini bekerja dalam enam tahap.

Skrip utuh:
# -*- coding: utf-8 -*-
"""Dasbor HTML satu berkas (tanpa pustaka luar) untuk KPH Contoh. Penulis: Badar Mubarok Yogaswara
Membaca Petak_KPH.gpkg dan Hotspot_Harian.csv, menulis keluaran/dasbor/dasbor.html. Jalankan: python-qgis.bat buat_dasbor.py
"""
import csv, json, os
from collections import defaultdict
from osgeo import ogr
ogr.UseExceptions()
BULAN = ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"]
AQ = os.path.dirname(os.path.abspath(__file__))
PAKET = os.path.abspath(os.path.join(AQ, "..", "..", "..", "data-sintetis", "paket-m4"))
OUT = os.path.join(AQ, "keluaran", "dasbor")
def hitung():
petak = {}
ds = ogr.Open(os.path.join(PAKET, "Petak_KPH.gpkg"))
for f in ds.GetLayer(0):
petak[f["ID_Petak"]] = {"kph": f["NAMA_KPH"], "luas": f["Luas_Ha"]}
per_hari = defaultdict(int)
per_petak = defaultdict(int)
per_kph = defaultdict(int)
with open(os.path.join(PAKET, "Hotspot_Harian.csv"), encoding="utf-8") as fh:
for b in csv.DictReader(fh):
j = int(b["Jumlah"])
if b["ID_Petak"] not in petak:
raise ValueError("ID_Petak %s di Hotspot_Harian.csv tidak ada di Petak_KPH.gpkg" % b["ID_Petak"])
per_hari[b["Tanggal"]] += j
per_petak[b["ID_Petak"]] += j
per_kph[petak[b["ID_Petak"]]["kph"]] += j
luas_kph = defaultdict(float)
for p in petak.values():
luas_kph[p["kph"]] += p["luas"]
hari_puncak = max(per_hari, key=lambda k: (per_hari[k], k))
teratas = sorted(per_petak.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
return {
"total": sum(per_hari.values()), "hari": sorted(per_hari.items()), "hari_puncak": hari_puncak,
"jumlah_puncak": per_hari[hari_puncak], "per_kph": dict(per_kph), "luas_kph": dict(luas_kph),
"teratas": teratas, "jumlah_petak": len(petak),
}
def label_tgl(tanggal):
"""'2026-09-30' -> '30 Sep' (hari tanpa nol di depan)."""
return "%d %s" % (int(tanggal[8:10]), BULAN[int(tanggal[5:7]) - 1])
def garis_svg(hari, w=520, h=160):
mx = max(v for _, v in hari) or 1
pts = " ".join("%.1f,%.1f" % (10 + i * (w - 20) / max(len(hari) - 1, 1), h - 15 - v * (h - 30) / mx) for i, (_, v) in enumerate(hari))
return ('<svg viewBox="0 0 %d %d" role="img" aria-label="Titik panas per hari"><polyline fill="none" stroke="#C0392B" '
'stroke-width="2.5" points="%s"/><text x="10" y="%d" font-size="11" fill="#555">%s</text>'
'<text x="%d" y="%d" font-size="11" fill="#555" text-anchor="end">%s</text></svg>') % (w, h, pts, h - 2, label_tgl(hari[0][0]), w - 10, h - 2, label_tgl(hari[-1][0]))
def batang_svg(data, w=520):
mx = max(data.values()) or 1
baris = []
for i, (k, v) in enumerate(sorted(data.items())):
y = 8 + i * 34
baris.append('<text x="0" y="%d" font-size="13" fill="#333">%s</text><rect x="90" y="%d" width="%.1f" height="20" fill="#2B6CB0"/>'
'<text x="%.1f" y="%d" font-size="13" fill="#333">%d</text>' % (y + 15, k, y, v * (w - 150) / mx, 96 + v * (w - 150) / mx, y + 15, v))
return '<svg viewBox="0 0 %d %d" role="img" aria-label="Titik panas per KPH">%s</svg>' % (w, 8 + len(data) * 34, "".join(baris))
def tulis(d):
os.makedirs(OUT, exist_ok=True)
kartu = [("Total titik panas (30 hari)", d["total"]), ("Hari puncak", "%s (%d titik)" % (d["hari_puncak"][-2:] + " " + BULAN[int(d["hari_puncak"][5:7]) - 1], d["jumlah_puncak"])),
("Jumlah petak", d["jumlah_petak"]), ("Petak teratas", "%s (%d)" % d["teratas"][0])]
html = """<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dasbor KPH Contoh</title><style>
body{font-family:system-ui,sans-serif;margin:0;background:#f4f6f9;color:#1F3A5F}
main{max-width:900px;margin:auto;padding:16px}.kartu{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
.k{background:#fff;border-radius:8px;padding:12px}.k b{display:block;font-size:22px}
.p{background:#fff;border-radius:8px;padding:12px;margin-top:12px}svg{width:100%%;height:auto}
</style></head><body><main><h1>Dasbor KPH Contoh: titik panas September</h1>
<div class="kartu">%s</div>
<div class="p"><h2>Titik panas per hari</h2>%s</div>
<div class="p"><h2>Titik panas per KPH</h2>%s</div>
<p>Data sintetis. Bukan kondisi lapangan sebenarnya.</p></main></body></html>""" % (
"".join('<div class="k">%s<b>%s</b></div>' % (a, b) for a, b in kartu), garis_svg(d["hari"]), batang_svg(d["per_kph"]))
with open(os.path.join(OUT, "dasbor.html"), "w", encoding="utf-8") as fh:
fh.write(html)
if __name__ == "__main__":
d = hitung()
tulis(d)
print(json.dumps({k: d[k] for k in ("total", "hari_puncak", "jumlah_puncak", "per_kph", "luas_kph", "teratas", "jumlah_petak")}, ensure_ascii=False))Penjelasan baris demi baris. Bagian pertama: perkakas dan jalur (baris 1-12).
| Baris | Tulisan | Artinya |
|---|---|---|
| 1 | # -*- coding: utf-8 -*- | Komentar penanda: berkas ini ditulis dengan huruf UTF-8. |
| 2-4 | """...""" | Teks penjelasan di awal berkas (docstring). Python tidak menjalankannya. |
| 5 | import csv, json, os | Panggil tiga perkakas sekaligus: csv membaca tabel teks, json merakit teks JSON, os mengurus jalur folder (Bab 8). |
| 6 | from collections import defaultdict | Panggil kamus khusus yang otomatis mulai dari nol bila kunci belum ada. |
| 7-8 | from osgeo import ogr dan ogr.UseExceptions() | Panggil pembaca data vektor dari GDAL. Baris 8 membuatnya berteriak (galat) bila gagal, bukan diam saja. |
| 9 | BULAN = ["Jan", "Feb", ..., "Des"] | Daftar dua belas singkatan bulan. Nomor bulan 1 sampai 12 dipakai untuk memilih namanya (urutan daftar mulai dari 0, jadi bulan 9 ada di nomor 8). |
| 10 | AQ = os.path.dirname(os.path.abspath(__file__)) | Isi kotak AQ dengan lokasi folder skrip ini. __file__ adalah nama berkas yang sedang berjalan. |
| 11 | PAKET = os.path.abspath(os.path.join(AQ, "..", "..", "..", "data-sintetis", "paket-m4")) | Dari AQ naik tiga folder (tiga kali ".."), lalu masuk data-sintetis/paket-m4. Ini sebab struktur folder harus sama. |
| 12 | OUT = ... | Lokasi folder keluaran: keluaran/dasbor di samping skrip. |
Bagian kedua: menghitung (baris 15-40). Fungsi hitung() membuat tiga "buku catatan" (kamus) lalu mengisinya.
| Baris | Tulisan | Artinya |
|---|---|---|
| 16-19 | petak = {} lalu for f in ds.GetLayer(0) | Buka GeoPackage, lalu catat tiap petak: kodenya jadi kunci, isinya nama KPH dan luas. Kamus di dalam kamus. |
| 20-22 | defaultdict(int) tiga kali | Siapkan tiga catatan jumlah: per hari, per petak, per KPH. Isi awalnya otomatis 0. |
| 23-24 | with open(...) dan csv.DictReader(fh) | Buka CSV. DictReader memberi tiap baris sebagai kamus, kolomnya dipanggil dengan nama (Bab 9). |
| 25 | j = int(b["Jumlah"]) | Ubah teks "1" menjadi angka 1 agar bisa dijumlah. |
| 26-27 | if b["ID_Petak"] not in petak: raise ValueError(...) | Pengaman: sebelum menjumlah, cek apakah kode petak di CSV ada di GeoPackage. Bila tidak ada, skrip berhenti dengan pesan yang menyebut kode petaknya. raise ValueError dibahas di Bab 10. |
| 28-30 | per_hari[...] += j | Tambahkan jumlah ke catatan hari, petak, dan KPH. Baris 30 mencari KPH petak itu lewat kamus petak, jadi baris 26-27 harus lebih dulu. |
| 31-33 | luas_kph | Jumlahkan luas petak per KPH. |
| 34 | max(per_hari, key=lambda k: (per_hari[k], k)) | Cari hari dengan titik terbanyak. lambda adalah fungsi mini (Bab 7). Bila ada dua hari sama banyak, tanggal yang lebih akhir menang. |
| 35 | sorted(..., key=lambda kv: (-kv[1], kv[0]))[:3] | Urutkan petak dari yang terbanyak. Tanda minus membalik urutan. [:3] mengambil tiga pertama. |
| 36-40 | return {...} | Kembalikan semua hasil dalam satu kamus. |
Bagian ketiga: menggambar (baris 43-63). Grafik di sini bukan gambar, melainkan teks SVG, yaitu bahasa gambar yang dipahami peramban.
| Baris | Tulisan | Artinya |
|---|---|---|
| 43-45 | def label_tgl(tanggal) dan return "%d %s" % (...) | Ubah tanggal 2026-09-30 menjadi 30 Sep. Hari ada di huruf ke-9 dan ke-10 (tanggal[8:10]), bulan di huruf ke-6 dan ke-7 (tanggal[5:7]). Nomor bulan dikurangi 1 lalu dicari namanya di BULAN. int(...) membuang angka nol di depan hari. |
| 48-49 | def garis_svg(hari, w=520, h=160) dan mx = ... | Fungsi grafik garis. Lebar w dan tinggi h punya nilai bawaan. mx adalah nilai terbesar, untuk skala. |
| 50 | pts = " ".join(... for i, (_, v) in enumerate(hari)) | Hitung titik koordinat tiap hari lalu sambung dengan spasi. i adalah urutan hari, v jumlah titik panas. Tanda _ berarti tanggal diabaikan. Jarak antartitik dibagi max(len(hari) - 1, 1): max memilih yang terbesar, jadi pembaginya paling kecil 1 dan tidak pernah 0. |
| 51-53 | teks <svg ...> dengan % | Rakit teks gambar: garis merah dan dua label tanggal. Label kiri memakai label_tgl(hari[0][0]) (hari pertama), label kanan label_tgl(hari[-1][0]) (hari terakhir). Tanda % di ujung mengisi %d dan %s dengan nilai (Bab 9). |
| 56-63 | def batang_svg(data, w=520) | Sama, tetapi untuk batang. Tiap KPH mendapat satu batang biru, panjangnya sebanding dengan jumlahnya. |
Bagian keempat: merakit dan menjalankan (baris 66-89).
| Baris | Tulisan | Artinya |
|---|---|---|
| 67 | os.makedirs(OUT, exist_ok=True) | Buat folder keluaran. Bila sudah ada, tidak apa-apa. |
| 68-69 | kartu = [...] | Daftar empat kartu: judul dan isi. Baris 68 memotong tanggal puncak dengan [-2:] (dua huruf terakhir, yaitu tanggalnya) lalu menambah nama bulan dari tanggal itu sendiri, diambil dari BULAN. |
| 70-81 | html = """...""" % (...) | Teks HTML panjang dengan tiga lubang %s: kartu, grafik garis, grafik batang. Tanda %% di CSS dibaca sebagai satu tanda persen. |
| 82-83 | with open(..., "w", encoding="utf-8") | Tulis HTML ke berkas dasbor.html. |
| 86 | if __name__ == "__main__": | Jalankan bagian di bawahnya hanya bila berkas ini dijalankan langsung. |
| 87-89 | d = hitung(), tulis(d), print(json.dumps(...)) | Hitung, tulis, lalu cetak ringkasan sebagai teks JSON. ensure_ascii=False menjaga huruf biasa tampil apa adanya. |
Hasil. Layar menampilkan satu baris panjang (berdasarkan uji pada QGIS 4.0.2). Baris itu dipecah di sini agar terbaca.
{"total": 385, "hari_puncak": "2026-09-21", "jumlah_puncak": 34,
"per_kph": {"KPH Alpha": 91, "KPH Beta": 65, "KPH Gamma": 229},
"luas_kph": {"KPH Alpha": 4.0, "KPH Beta": 4.0, "KPH Gamma": 8.0},
"teratas": [["P-14", 33], ["P-12", 31], ["P-13", 31]], "jumlah_petak": 16}Berkas keluaran/dasbor/dasbor.html terbentuk (2.309 byte). Isinya empat kartu: total titik panas 385, hari puncak 21 Sep (34 titik), jumlah petak 16, dan petak teratas P-14 (33). Buka dengan klik dua kali di peramban. Halaman ini tidak memuat berkas lain, jadi tidak perlu internet. Halaman ini sudah dibuka di peramban (dilayani lewat server lokal; klik dua kali pada berkas tidak diuji). Yang tampak: judul "Dasbor KPH Contoh: titik panas September", empat kartu (385; 21 Sep (34 titik); 16; P-14 (33)), grafik garis merah berlabel "1 Sep" dan "30 Sep", tiga batang biru (91, 65, 229), dan catatan data sintetis. Pada layar sempit kartu keempat turun ke baris kedua.
Bagian yang boleh diubah.
- Baris 35, angka 3 dalam
[:3]. Jumlah petak teratas dalam ringkasan. Akibatnya hanya daftar yang dicetak, kartu tetap menampilkan petak pertama. - Baris 51, warna
#C0392B. Warna garis grafik. Kode warna enam huruf. - Baris 61, warna
#2B6CB0. Warna batang. - Baris 48 dan 56,
wdanh. Ukuran grafik. - Baris 68-69 dan 76, judul dan teks kartu. Ganti sesuai laporan Anda. Baris 76 memuat kata "September" pada judul halaman.
- Baris 9, daftar
BULAN. Ganti singkatan bila Anda ingin nama penuh, misalnya "Januari".
Jebakan.
- Jalur data salah. Bila skrip dipindah ke folder lain, galatnya:
RuntimeError: ...\data-sintetis\paket-m4\Petak_KPH.gpkg: No such file or directory. Jalur lengkap yang dicari tercetak, jadi mudah dilacak. - Petak yang tak dikenal. Bila CSV memuat petak yang tidak ada di GeoPackage, misalnya
P-99, skrip berhenti di baris 27 dengan pesanValueError: ID_Petak P-99 di Hotspot_Harian.csv tidak ada di Petak_KPH.gpkg. (Diuji dengan menambah satu baris ke salinan CSV.) Tanpa pengaman di baris 26-27, galatnya hanyaKeyError: 'P-99'di baris 30, yang tidak menyebut berkas mana yang bermasalah. Pesan yang menyebut kode dan berkas membuat Anda langsung tahu apa yang harus dibetulkan: tambahkan petaknya ke GeoPackage, atau hapus barisnya dari CSV. - Hanya satu hari data. Jarak antartitik grafik dibagi
max(len(hari) - 1, 1)di baris 50. Satu hari berartilen(hari) - 1sama dengan 0, dan pembagian nol menimbulkanZeroDivisionError. Angka1darimaxmencegahnya: fungsigaris_svgyang diuji dengan satu hari menghasilkan gambar berisi satu titik. Satu titik tidak tampak sebagai garis, jadi grafiknya kosong secara kasat mata. Ini diuji di peramban: kotak grafik tampil kosong, hanya dua label tanggal yang tertinggal, dan keduanya sama (misalnya "21 Sep" di kiri dan di kanan). - Label tanggal membaca bentuk tetap.
label_tglmengambil huruf ke-9 dan ke-10 sebagai hari, ke-6 dan ke-7 sebagai bulan. Maka kolomTanggalharus berbentuk2026-09-30. Bentuk lain, misalnya30/09/2026, menghasilkan label yang salah atau galat. - Sebagian tulisan masih tetap. Label grafik dan nama bulan di kartu kini mengikuti data. Namun judul kartu "Total titik panas (30 hari)" (baris 68) dan judul halaman "titik panas September" (baris 76) tetap tertulis. Untuk data Oktober, ubah keduanya sendiri.
Latihan variasi.
- Cetak lima petak teratas, bukan tiga.
- Ubah warna garis menjadi hijau tua
#2E7D32.
Contoh jawaban, hanya bagian yang berubah:
teratas = sorted(per_petak.items(), key=lambda kv: (-kv[1], kv[0]))[:5]Baris 51 ditulis ulang dengan stroke="#2E7D32". Hasil uji: JSON memuat lima petak, yaitu P-14 (33), P-12 (31), P-13 (31), P-16 (30), dan P-15 (28). Berkas HTML memuat stroke="#2E7D32".
Skrip 27.2: Peta web dari data QGIS #
Berkas buat_peta_web.py. Bagi pembaca buku M4, ini skrip Bab 3.
Tujuan. Mengekspor petak dan titik panas menjadi GeoJSON berkoordinat lintang-bujur, lalu menulis halaman peta web (Leaflet) yang menggambarnya.
Bahan. paket-m4/Petak_KPH.gpkg dan paket-data/Hotspot_Nasional.gpkg. Skrip memakai QGIS penuh, jadi jalankan dengan python-qgis.bat.
Analogi: GeoJSON adalah lembar pengumuman, Leaflet adalah papan yang menempelkannya. Papannya tidak ikut dalam skrip. Papan itu diambil dari internet saat halaman dibuka.
Skrip utuh:
# -*- coding: utf-8 -*-
"""Membuat web map statis (Leaflet + GeoJSON) dari data KPH Contoh. Penulis: Badar Mubarok Yogaswara
Hanya membuat berkas lokal di folder keluaran/peta_web. Tidak mengunggah apa pun.
Jalankan: python-qgis.bat buat_peta_web.py
"""
import json, math, os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (QgsApplication, QgsVectorLayer, QgsCoordinateReferenceSystem,
QgsVectorFileWriter, QgsCoordinateTransform, QgsCoordinateTransformContext)
AQ = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.abspath(os.path.join(AQ, "..", "..", "..", "data-sintetis"))
OUT = os.path.join(AQ, "keluaran", "peta_web")
def ke_tile(lon, lat, zoom):
"""Nomor ubin XYZ (skema web: x ke timur, y ke selatan) untuk satu titik."""
n = 2 ** zoom
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n)
return x, y
def ekspor(layer_path, nama_layer, berkas):
lyr = QgsVectorLayer(layer_path, nama_layer, "ogr")
assert lyr.isValid(), layer_path
opsi = QgsVectorFileWriter.SaveVectorOptions()
opsi.driverName = "GeoJSON"
opsi.fileEncoding = "UTF-8"
opsi.ct = QgsCoordinateTransform(
lyr.crs(), QgsCoordinateReferenceSystem("EPSG:4326"), QgsCoordinateTransformContext())
opsi.layerOptions = ["COORDINATE_PRECISION=6", "RFC7946=YES"]
hasil = QgsVectorFileWriter.writeAsVectorFormatV3(lyr, berkas, QgsCoordinateTransformContext(), opsi)
assert hasil[0] == QgsVectorFileWriter.WriterError.NoError, hasil
return lyr.featureCount()
HTML = """<!doctype html>
<html lang="id"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Peta KPH Contoh</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>html,body,#peta{height:100%;margin:0}</style></head>
<body><div id="peta"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
var peta = L.map('peta');
fetch('petak.geojson').then(r => r.json()).then(function (d) {
var lap = L.geoJSON(d, {
style: {color: '#2B6CB0', weight: 2, fillOpacity: 0.15},
onEachFeature: function (f, l) {
l.bindPopup('<b>' + f.properties.ID_Petak + '</b><br>' + f.properties.NAMA_KPH +
'<br>' + f.properties.Jenis + ', ' + f.properties.Luas_Ha + ' ha');
}
}).addTo(peta);
peta.fitBounds(lap.getBounds());
});
fetch('hotspot.geojson').then(r => r.json()).then(function (d) {
L.geoJSON(d, {pointToLayer: function (f, p) {
return L.circleMarker(p, {radius: 6, color: '#C0392B', fillOpacity: 0.8});
}}).addTo(peta);
});
</script></body></html>
"""
if __name__ == "__main__":
app = QgsApplication([], False)
app.initQgis()
os.makedirs(OUT, exist_ok=True)
n1 = ekspor(os.path.join(DATA, "paket-m4", "Petak_KPH.gpkg"), "Petak_KPH", os.path.join(OUT, "petak.geojson"))
n2 = ekspor(os.path.join(DATA, "paket-data", "Hotspot_Nasional.gpkg"), "Hotspot", os.path.join(OUT, "hotspot.geojson"))
with open(os.path.join(OUT, "index.html"), "w", encoding="utf-8") as fh:
fh.write(HTML)
print("petak:", n1, "hotspot:", n2)
for b in ("petak.geojson", "hotspot.geojson"):
d = json.load(open(os.path.join(OUT, b), encoding="utf-8"))
c = d["features"][0]["geometry"]["coordinates"]
while isinstance(c[0], list):
c = c[0]
print(b, "fitur:", len(d["features"]), "koordinat pertama (lon, lat):", c)
print("ubin zoom 0 untuk (0,0):", ke_tile(0, 0, 0), "| zoom 1 untuk (0,0):", ke_tile(0, 0, 1),
"| zoom 10 Jakarta -6.2,106.85:", ke_tile(106.85, -6.2, 10))
app.exitQgis()Penjelasan baris demi baris. Bagian pertama: perkakas dan penghitung ubin (baris 1-21).
| Baris | Tulisan | Artinya |
|---|---|---|
| 6 | import json, math, os | Tiga perkakas: JSON, hitungan matematika, dan jalur folder. |
| 7 | os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") | Pengaturan agar QGIS jalan tanpa membuka jendela. setdefault hanya mengisi bila belum ada isinya. |
| 8-9 | from qgis.core import (...) | Panggil enam perkakas QGIS. Tanda kurung memungkinkan daftar dipecah ke dua baris. |
| 11-13 | AQ, DATA, OUT | Sama seperti Skrip 27.1: lokasi skrip, folder data-sintetis (naik tiga folder), dan folder keluaran peta_web. |
| 16-21 | def ke_tile(lon, lat, zoom) | Hitung nomor ubin peta web untuk satu titik. Ubin adalah kepingan gambar peta dasar. Fungsi ini hanya dicetak sebagai contoh di akhir skrip dan tidak dipakai oleh halaman peta. |
Bagian kedua: mengekspor (baris 24-35).
| Baris | Tulisan | Artinya |
|---|---|---|
| 24-26 | def ekspor(...), lyr = QgsVectorLayer(..., "ogr"), assert lyr.isValid(), layer_path | Buka layer. assert menghentikan skrip bila layer gagal dibuka, dan mencetak jalurnya (Bab 10). |
| 27-29 | opsi = QgsVectorFileWriter.SaveVectorOptions() dst. | Siapkan "formulir pilihan" penulisan: format GeoJSON, huruf UTF-8. |
| 30-31 | opsi.ct = QgsCoordinateTransform(lyr.crs(), QgsCoordinateReferenceSystem("EPSG:4326"), ...) | Atur perubahan koordinat dari CRS layer (UTM) ke EPSG:4326 (lintang-bujur), karena GeoJSON standar memakainya. |
| 32 | opsi.layerOptions = [...] | Pilihan tambahan: ketelitian enam desimal dan mengikuti standar GeoJSON (RFC 7946). |
| 33-35 | writeAsVectorFormatV3(...), assert ..., return lyr.featureCount() | Tulis berkas. Hasilnya diperiksa: bukan NoError berarti gagal. Fungsi mengembalikan jumlah fitur. |
Bagian ketiga: halaman HTML (baris 38-64). Teksnya panjang, tetapi hanya ada empat hal yang perlu dikenali.
| Baris | Tulisan | Artinya |
|---|---|---|
| 42, 45 | <link ... leaflet.css> dan <script src=".../leaflet.js"> | Dua alamat internet tempat Leaflet diambil. |
| 47 | var peta = L.map('peta'); | Siapkan papan peta kosong. Ini JavaScript, bukan Python. |
| 48-57 | fetch('petak.geojson')... | Ambil berkas petak, gambar sebagai poligon biru, beri jendela info (popup) berisi ID, KPH, jenis, dan luas, lalu zoom ke seluruh petak. |
| 58-62 | fetch('hotspot.geojson')... | Ambil titik panas lalu gambar sebagai lingkaran merah berjari-jari 6. |
Bagian keempat: menjalankan (baris 66-83).
| Baris | Tulisan | Artinya |
|---|---|---|
| 67-68 | app = QgsApplication([], False) dan app.initQgis() | Nyalakan mesin QGIS tanpa jendela. |
| 69-71 | os.makedirs(...), n1 = ekspor(...), n2 = ekspor(...) | Buat folder, lalu ekspor dua layer. |
| 72-73 | with open(... "index.html" ...) | Tulis teks HTML ke index.html. |
| 75-80 | for b in ("petak.geojson", "hotspot.geojson"): | Baca balik kedua berkas dan cetak koordinat pertama sebagai pemeriksaan. while isinstance(c[0], list) turun lapis demi lapis sampai menemukan angka. |
| 81-82 | print("ubin zoom 0 ...", ke_tile(...)) | Cetak tiga contoh nomor ubin. |
| 83 | app.exitQgis() | Matikan mesin QGIS. |
Hasil. Layar menampilkan (berdasarkan uji pada QGIS 4.0.2):
petak: 16 hotspot: 12
petak.geojson fitur: 16 koordinat pertama (lon, lat): [109.310738, -0.036173]
hotspot.geojson fitur: 12 koordinat pertama (lon, lat): [109.31149, -0.03301]
ubin zoom 0 untuk (0,0): (0, 0) | zoom 1 untuk (0,0): (1, 1) | zoom 10 Jakarta -6.2,106.85: (815, 529)Dari hasil ini Anda tahu: ada 16 petak dan 12 titik panas, koordinatnya sudah berupa lintang-bujur, dan fungsi ke_tile bekerja. Tiga berkas terbentuk di keluaran/peta_web: petak.geojson, hotspot.geojson, dan index.html.
Untuk membuka halaman, buku M4 menyarankan melayani folder itu lewat server kecil di komputer sendiri, karena banyak peramban menolak membaca berkas lokal lewat fetch bila halaman dibuka langsung dengan klik dua kali. Itu perilaku peramban, tidak saya uji. [CEK] Bab ini tidak menjalankan server apa pun.
Bagian yang boleh diubah.
- Baris 32,
COORDINATE_PRECISION=6. Jumlah desimal koordinat. Lebih kecil berarti berkas lebih ringan, tetapi letak kurang teliti. - Baris 50, gaya petak.
color,weight(tebal garis), danfillOpacity(kepekatan isi). - Baris 60,
radius: 6dancolor. Ukuran dan warna titik panas. - Baris 52-53, isi popup. Ganti dengan kolom lain, misalnya
f.properties.Tahun_Tanam. Nama kolom ditulis persis. - Baris 70-71, berkas sumber. Ganti untuk memetakan layer lain.
Jebakan.
- Jalur data salah. Galatnya
AssertionError: ...\data-sintetis\paket-m4\Petak_KPH.gpkg. Jalur yang gagal ikut tercetak berkat pesan di baris 26. (Diuji dengan menaruh skrip di folder tanpadata-sintetis.) - Butuh internet. Tanpa internet, Leaflet tidak termuat dan halaman kosong.
- Tanpa peta dasar. JavaScript hanya menggambar petak dan titik. Latar peta (jalan, sungai) tidak ada.
- Nama kolom di JavaScript.
f.properties.Jenisharus sama dengan nama kolom di GeoJSON, termasuk huruf besar dan kecil.
Latihan variasi.
- Kurangi ketelitian koordinat menjadi tiga desimal.
- Ganti warna petak menjadi hijau tua
#1B5E20dan jari-jari titik panas menjadi 10.
Contoh jawaban, hanya bagian yang berubah:
opsi.layerOptions = ["COORDINATE_PRECISION=3", "RFC7946=YES"]Pada HTML (baris 50 dan 60), tulis color: '#1B5E20' dan radius: 10. Hasil uji: koordinat pertama petak menjadi [109.311, -0.036] dan titik panas [109.311, -0.033]. Berkas index.html memuat '#1B5E20' dan radius: 10.
Skrip 27.3: Model 3D dari DSM #
Berkas dsm_ke_obj.py. Bagi pembaca buku M4, ini skrip Bab 5.
Tujuan. Mengubah DSM drone (peta tinggi permukaan) menjadi jaring segitiga 3D berformat .obj, lalu memeriksa titik awan LAZ dan pengaturan peta 3D QGIS.
Bahan. paket-data/DSM_Drone.tif (800 x 800 piksel, 0,5 m) dan Hutan_Contoh.laz (15.000 titik). Jalankan dengan python-qgis.bat.
Analogi: model 3D adalah maket dari karton. Titik sudut adalah paku-pakunya. Segitiga adalah lembar karton yang menghubungkan tiga paku. Makin banyak paku, makin halus maketnya, makin berat pula beratnya.
Skrip utuh:
# -*- coding: utf-8 -*-
"""Mengubah DSM menjadi jaring segitiga 3D (.obj) dan memeriksa pengaturan 3D QGIS. Penulis: Badar Mubarok Yogaswara
Jalankan: python-qgis.bat dsm_ke_obj.py
"""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from osgeo import gdal
from qgis.core import (QgsApplication, QgsRasterLayer, QgsPointCloudLayer, QgsProject)
gdal.UseExceptions()
AQ = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.abspath(os.path.join(AQ, "..", "..", "..", "data-sintetis", "paket-data"))
OUT = os.path.join(AQ, "keluaran", "tiga_d")
def dsm_ke_obj(tif, obj, langkah=16, skala_z=2.0):
"""Ambil tiap `langkah` piksel; x,y relatif terhadap sudut barat-daya (meter); z dikali skala_z."""
ds = gdal.Open(tif)
gt = ds.GetGeoTransform()
band = ds.GetRasterBand(1)
data = band.ReadAsArray()
nodata = band.GetNoDataValue()
baris = list(range(0, ds.RasterYSize, langkah))
kolom = list(range(0, ds.RasterXSize, langkah))
idx = {}
n = 0
with open(obj, "w", encoding="utf-8") as fh:
fh.write("# DSM sintetis KPH Contoh; skala vertikal x%.1f\n" % skala_z)
for j, r in enumerate(baris):
for i, c in enumerate(kolom):
z = float(data[r, c])
if nodata is not None and z == nodata:
z = 0.0
x = i * langkah * gt[1]
y = (len(baris) - 1 - j) * langkah * abs(gt[5]) # utara di atas: baris 0 = utara
n += 1
idx[(j, i)] = n
fh.write("v %.3f %.3f %.3f\n" % (x, z * skala_z, y))
muka = 0
for j in range(len(baris) - 1):
for i in range(len(kolom) - 1):
a, b, c, d = idx[(j, i)], idx[(j, i + 1)], idx[(j + 1, i + 1)], idx[(j + 1, i)]
fh.write("f %d %d %d\nf %d %d %d\n" % (a, d, c, a, c, b))
muka += 2
return n, muka, float(data.min()), float(data.max())
if __name__ == "__main__":
app = QgsApplication([], False)
app.initQgis()
os.makedirs(OUT, exist_ok=True)
v, f, zmin, zmax = dsm_ke_obj(os.path.join(DATA, "DSM_Drone.tif"), os.path.join(OUT, "dsm.obj"))
print("OBJ: titik sudut", v, "segitiga", f, "| z min/maks DSM:", round(zmin, 2), round(zmax, 2))
vs = [l.split()[1:] for l in open(os.path.join(OUT, "dsm.obj"), encoding="utf-8") if l.startswith("v ")]
xs, ys = [float(a[0]) for a in vs], [float(a[1]) for a in vs]
print("OBJ: x dari %.0f ke %.0f m | tinggi (sudah x2) dari %.2f ke %.2f" % (min(xs), max(xs), min(ys), max(ys)))
r = QgsRasterLayer(os.path.join(DATA, "DSM_Drone.tif"), "DSM")
print("DSM valid:", r.isValid(), "ukuran:", r.width(), "x", r.height(), "CRS:", r.crs().authid())
pc = QgsPointCloudLayer(os.path.join(DATA, "Hutan_Contoh.laz"), "Hutan", "pdal")
print("point cloud valid:", pc.isValid(), "jumlah titik:", pc.pointCount())
from qgis._3d import Qgs3DMapSettings, QgsDemTerrainSettings
s = Qgs3DMapSettings()
s.setCrs(r.crs())
s.setExtent(r.extent())
t = QgsDemTerrainSettings()
t.setLayer(r)
t.setVerticalScale(2.0)
s.setTerrainSettings(t)
print("3D: terrain =", s.terrainSettings().type() if hasattr(s.terrainSettings(), "type") else "?",
"| skala vertikal:", s.terrainSettings().verticalScale(), "| ekstensi:", s.extent().toString(1))
app.exitQgis()Penjelasan baris demi baris. Bagian pertama: persiapan (baris 5-12).
| Baris | Tulisan | Artinya |
|---|---|---|
| 5-6 | import os dan os.environ.setdefault(...) | Perkakas jalur, dan pengaturan QGIS tanpa jendela (sama seperti Skrip 27.2). |
| 7-9 | from osgeo import gdal, from qgis.core import (...), gdal.UseExceptions() | Panggil GDAL untuk membaca raster, dan empat perkakas QGIS. QgsProject ikut dipanggil tetapi tidak dipakai. |
| 10-12 | AQ, DATA, OUT | Lokasi skrip, folder paket-data (naik tiga folder), dan folder keluaran tiga_d. |
Bagian kedua: fungsi pembuat .obj (baris 15-44). Ia bekerja dalam dua langkah: tulis semua titik sudut, lalu tulis segitiganya.
| Baris | Tulisan | Artinya |
|---|---|---|
| 15 | def dsm_ke_obj(tif, obj, langkah=16, skala_z=2.0) | Fungsi dengan dua pengaturan bawaan: ambil satu titik tiap 16 piksel, dan tinggi dikali 2 agar bukit tampak jelas. |
| 17-21 | gdal.Open(tif) dst. | Buka raster. gt adalah ukuran piksel dan letaknya. data adalah larik angka tinggi. nodata adalah angka penanda "tidak ada data". |
| 22-23 | baris = list(range(0, ds.RasterYSize, langkah)) | Daftar nomor baris yang dipilih: 0, 16, 32, dan seterusnya. Kolom dibuat sama. |
| 24-25 | idx = {} dan n = 0 | idx mencatat nomor urut tiap titik sudut. n penghitungnya. |
| 26-27 | with open(obj, "w", ...) dan fh.write(...) | Buka berkas .obj dan tulis baris komentar di awalnya. |
| 28-30 | for j, r in enumerate(baris): lalu for i, c in enumerate(kolom): | Dua perulangan bersarang menyapu baris dan kolom terpilih. z adalah tinggi di titik itu. |
| 31-32 | if nodata is not None and z == nodata: z = 0.0 | Bila piksel tak berdata, tingginya dianggap 0. |
| 33-34 | x = ... dan y = ... | Hitung letak datar dalam meter, dihitung dari sudut barat daya. Baris 0 raster adalah utara, jadi nilainya dibalik. |
| 35-37 | n += 1, idx[(j, i)] = n, fh.write("v %.3f %.3f %.3f\n" ...) | Beri nomor, catat di kamus (kuncinya pasangan (j, i)), dan tulis satu baris v (titik sudut). |
| 38-43 | for j in range(len(baris) - 1) dst. | Untuk tiap petak persegi empat titik, ambil keempat nomornya (a, b, c, d), lalu tulis dua baris f (segitiga). |
| 44 | return n, muka, float(data.min()), float(data.max()) | Kembalikan empat nilai: jumlah titik, jumlah segitiga, tinggi terendah dan tertinggi. |
Bagian ketiga: menjalankan dan memeriksa (baris 47-70).
| Baris | Tulisan | Artinya |
|---|---|---|
| 48-50 | app = QgsApplication([], False), app.initQgis(), os.makedirs(...) | Nyalakan QGIS tanpa jendela, siapkan folder. |
| 51-52 | v, f, zmin, zmax = dsm_ke_obj(...) lalu print(...) | Panggil fungsi. Empat nilai hasilnya dibongkar ke empat kotak. |
| 53-55 | vs = [l.split()[1:] for l in open(...) if l.startswith("v ")] | Baca balik berkas .obj: ambil hanya baris v, buang kata v. Huruf l mudah dikira angka 1, sebaiknya diganti. |
| 56-57 | r = QgsRasterLayer(...) | Buka DSM sebagai layer QGIS lalu cetak ukuran dan CRS-nya. |
| 58-59 | QgsPointCloudLayer(..., "pdal") | Buka titik awan LAZ lalu cetak jumlah titiknya. Pembaca "pdal" harus tersedia di QGIS Anda. |
| 60-69 | from qgis._3d import ... dst. | Buat pengaturan peta 3D: CRS dan batas dari DSM, medan dari DSM dengan skala tinggi 2. Baris 68 mencetak jenis medan dengan hasattr sebagai pengaman. |
Hasil. Layar menampilkan (berdasarkan uji pada QGIS 4.0.2):
OBJ: titik sudut 2500 segitiga 4802 | z min/maks DSM: 15.03 38.8
OBJ: x dari 0 ke 392 m | tinggi (sudah x2) dari 33.26 ke 64.42
DSM valid: True ukuran: 800 x 800 CRS: EPSG:32749
point cloud valid: True jumlah titik: 15000
3D: terrain = dem | skala vertikal: 2.0 | ekstensi: 312000.0,9996000.0 : 312400.0,9996400.0Berkas keluaran/tiga_d/dsm.obj berukuran 143.790 byte. Lima baris pertamanya:
# DSM sintetis KPH Contoh; skala vertikal x2.0
v 0.000 44.025 392.000
v 8.000 44.822 392.000
v 16.000 45.605 392.000
v 24.000 46.359 392.000Perhatikan: angka kedua tiap baris v adalah tinggi, bukan utara. Format OBJ menaruh tinggi di sumbu kedua. Berkas ini bisa dibuka di program 3D yang membaca .obj. [CEK: tidak dibuka pada uji ini]
Dari hasil ini Anda juga bisa menghitung sendiri: 800 piksel dibagi 16 menjadi 50 titik per sisi, jadi 50 x 50 = 2.500 titik sudut. Segitiganya 2 x 49 x 49 = 4.802.
Bagian yang boleh diubah.
- Baris 15,
langkah=16. Makin kecil, jaring makin rapat dan berkas makin besar. - Baris 15,
skala_z=2.0. Pengali tinggi. 1,0 berarti tinggi asli. Baris 66 punya angka 2.0 sendiri untuk peta 3D, ubah juga bila perlu. - Baris 51, nama berkas. Ganti
DSM_Drone.tifdengan DSM Anda. Pastikan band 1 adalah tinggi. - Baris 32,
z = 0.0. Nilai pengganti untuk piksel tanpa data.
Jebakan.
- Jalur salah. Galatnya
RuntimeError: ...\data-sintetis\paket-data\DSM_Drone.tif: No such file or directory. (Diuji.) - NoData jadi nol. Lubang data berubah menjadi dasar datar bertinggi 0, padahal sekitarnya bisa 30 m.
- Langkah terlalu besar. Detail kecil hilang karena banyak piksel dilewati.
- Sumbu tertukar. Di OBJ, tinggi ada di sumbu kedua. Bila dibuka di program lain, periksa arah "atas".
Latihan variasi.
- Jadikan jaring lebih jarang:
langkah=32. - Gunakan tinggi asli:
skala_z=1.0.
Contoh jawaban, hanya bagian yang berubah:
def dsm_ke_obj(tif, obj, langkah=32, skala_z=1.0):Hasil uji: untuk langkah=32, titik sudut 625 dan segitiga 1.152, berkas jauh lebih kecil (32.651 byte). Untuk skala_z=1.0, tinggi berkisar 16,63 sampai 32,21 dan baris v kedua menjadi v 0.000 22.012 392.000.
Skrip 27.4: Alat Processing buatan sendiri, gaya kelas #
Berkas kerapatan_hotspot.py. Bagi pembaca buku M4, ini skrip Bab 2.
Tujuan. Membuat alat baru di Processing Toolbox yang menghitung jumlah titik panas dan kerapatannya (titik per hektare) di tiap petak, lalu menandai petak yang melewati batas.
Bahan. paket-m4/Petak_KPH.gpkg (poligon, CRS UTM) dan paket-data/Hotspot_Nasional.gpkg (12 titik).
Analogi: alat Processing adalah formulir resmi. Siapa pun yang mengisi isiannya akan mendapat hasil yang sama. Gaya kelas adalah formulir lengkap dengan semua kolom tertulis. Kelas (class) dibahas di Bab 11: sebuah cetakan benda dengan metode, yaitu fungsi yang melekat padanya.
Alat ini bekerja dalam tiga tahap: identitas (nama dan kelompok), formulir (parameter), dan pekerjaan (isi). Tiga tahap itu juga muncul di alat lain.
Skrip utuh:
# -*- coding: utf-8 -*-
"""Processing script: kerapatan titik panas per petak. Penulis: Badar Mubarok Yogaswara"""
from qgis.core import (QgsProcessingAlgorithm, QgsProcessingParameterFeatureSource,
QgsProcessingParameterFeatureSink, QgsProcessingParameterNumber,
QgsProcessing, QgsFeatureSink, QgsFeature, QgsField, QgsFields,
QgsFeatureRequest, QgsCoordinateTransform, QgsProject,
QgsProcessingException, QgsSpatialIndex, Qgis)
from qgis.PyQt.QtCore import QMetaType
class KerapatanHotspot(QgsProcessingAlgorithm):
PETAK = "PETAK"
TITIK = "TITIK"
BATAS = "BATAS"
HASIL = "HASIL"
def createInstance(self):
return KerapatanHotspot()
def name(self):
return "kerapatan_hotspot"
def displayName(self):
return "Kerapatan titik panas per petak"
def group(self):
return "Kehutanan"
def groupId(self):
return "kehutanan"
def shortHelpString(self):
return ("Menghitung jumlah titik panas di tiap petak dan kerapatannya (titik per hektare). "
"Petak dengan kerapatan di atas batas diberi nilai Rawan = 1.")
def initAlgorithm(self, config=None):
self.addParameter(QgsProcessingParameterFeatureSource(
self.PETAK, "Layer petak (poligon)", [QgsProcessing.SourceType.TypeVectorPolygon]))
self.addParameter(QgsProcessingParameterFeatureSource(
self.TITIK, "Layer titik panas (titik)", [QgsProcessing.SourceType.TypeVectorPoint]))
self.addParameter(QgsProcessingParameterNumber(
self.BATAS, "Batas rawan (titik per hektare)",
type=QgsProcessingParameterNumber.Type.Double, defaultValue=2.0, minValue=0.0))
self.addParameter(QgsProcessingParameterFeatureSink(self.HASIL, "Petak dengan kerapatan"))
def processAlgorithm(self, parameters, context, feedback):
petak = self.parameterAsSource(parameters, self.PETAK, context)
titik = self.parameterAsSource(parameters, self.TITIK, context)
batas = self.parameterAsDouble(parameters, self.BATAS, context)
if petak is None or titik is None:
raise QgsProcessingException("Layer petak atau titik tidak terbaca.")
if petak.sourceCrs().mapUnits() != Qgis.DistanceUnit.Meters:
raise QgsProcessingException("CRS layer petak harus bersatuan meter (luas dibagi 10000 untuk hektare). Proyeksikan ulang dulu, misalnya ke UTM.")
# titik disamakan ke CRS petak lebih dulu
ke_petak = QgsCoordinateTransform(titik.sourceCrs(), petak.sourceCrs(), context.transformContext())
indeks = QgsSpatialIndex()
geom = {}
for i, f in enumerate(titik.getFeatures()):
g = f.geometry()
g.transform(ke_petak)
fb = QgsFeature(i)
fb.setGeometry(g)
indeks.addFeature(fb)
geom[i] = g
kolom = QgsFields(petak.fields())
kolom.append(QgsField("Jml_Titik", QMetaType.Type.Int))
kolom.append(QgsField("Titik_per_Ha", QMetaType.Type.Double))
kolom.append(QgsField("Rawan", QMetaType.Type.Int))
sink, dest = self.parameterAsSink(parameters, self.HASIL, context, kolom,
petak.wkbType(), petak.sourceCrs())
if sink is None:
raise QgsProcessingException("Keluaran tidak bisa dibuat.")
total = petak.featureCount() or 1
for n, f in enumerate(petak.getFeatures()):
if feedback.isCanceled():
break
pg = f.geometry()
kandidat = indeks.intersects(pg.boundingBox())
jumlah = sum(1 for k in kandidat if pg.contains(geom[k]))
luas_ha = pg.area() / 10000.0
kerapatan = jumlah / luas_ha if luas_ha > 0 else 0.0
baru = QgsFeature(kolom)
baru.setGeometry(pg)
baru.setAttributes(f.attributes() + [jumlah, round(kerapatan, 3), 1 if kerapatan > batas else 0])
sink.addFeature(baru, QgsFeatureSink.Flag.FastInsert)
feedback.setProgress(100.0 * (n + 1) / total)
return {self.HASIL: dest}Penjelasan baris demi baris. Bagian pertama: perkakas dan identitas (baris 1-34).
| Baris | Tulisan | Artinya |
|---|---|---|
| 3-8 | from qgis.core import (...) dan QMetaType | Panggil 15 perkakas QGIS. Dua di antaranya, QgsFeatureRequest dan QgsProject, tidak dipakai. Yang terakhir, Qgis, dipakai untuk memeriksa satuan peta di baris 52. |
| 11 | class KerapatanHotspot(QgsProcessingAlgorithm): | Buat kelas baru yang mewarisi semua sifat algoritma Processing bawaan. Tanda kurung berarti "turunan dari". |
| 12-15 | PETAK = "PETAK" dst. | Empat nama tetap untuk isian formulir. Dipakai berulang di bawah agar tidak salah ketik. |
| 17-18 | def createInstance(self) | Pabrik: setiap kali QGIS butuh satu salinan alat, ia memanggil ini. self adalah benda yang sedang dibicarakan. |
| 20-24 | name dan displayName | name adalah nama teknis untuk Python. displayName adalah nama yang terlihat di Toolbox. |
| 26-30 | group dan groupId | Nama kelompok tempat alat muncul ("Kehutanan"), dan kodenya. |
| 32-34 | shortHelpString | Teks bantuan singkat di kolom kanan jendela alat. |
Bagian kedua: formulir (baris 36-44).
| Baris | Tulisan | Artinya |
|---|---|---|
| 36 | def initAlgorithm(self, config=None): | Metode yang QGIS panggil untuk membangun formulir. |
| 37-38 | QgsProcessingParameterFeatureSource(self.PETAK, "Layer petak (poligon)", [...TypeVectorPolygon]) | Isian layer masukan. Teks di tengah adalah label di formulir. Daftar di ujung membatasi jenis layer: poligon. |
| 39-40 | idem untuk TITIK | Isian layer titik panas, jenis titik. |
| 41-43 | QgsProcessingParameterNumber(... defaultValue=2.0, minValue=0.0) | Isian angka: bawaan 2,0 titik per hektare, tidak boleh di bawah 0. |
| 44 | QgsProcessingParameterFeatureSink(self.HASIL, ...) | Isian keluaran. Sink adalah tempat hasil ditulis. |
Bagian ketiga: persiapan pekerjaan (baris 46-74).
| Baris | Tulisan | Artinya |
|---|---|---|
| 46 | def processAlgorithm(self, parameters, context, feedback): | Metode inti. parameters berisi isian pengguna, context berisi pengaturan QGIS, feedback adalah saluran pesan dan kemajuan. |
| 47-49 | self.parameterAsSource(...) dan parameterAsDouble(...) | Ambil isian formulir menjadi benda Python: dua layer dan satu angka. |
| 50-51 | if petak is None or titik is None: raise QgsProcessingException(...) | Bila layer tidak terbaca, hentikan dengan pesan jelas. raise melempar galat (Bab 10). |
| 52-53 | if petak.sourceCrs().mapUnits() != Qgis.DistanceUnit.Meters: raise QgsProcessingException(...) | Pengaman satuan peta. mapUnits() menyebut satuan CRS layer petak: meter atau derajat. Bila bukan meter, alat berhenti dengan pesan yang meminta petak diproyeksikan ulang, misalnya ke UTM. Alasannya: baris 83 membagi luas dengan 10000, dan itu hanya benar bila luas dalam meter persegi. |
| 56 | ke_petak = QgsCoordinateTransform(...) | Siapkan "penerjemah" CRS dari titik ke petak. |
| 57-65 | indeks = QgsSpatialIndex() dan for i, f in enumerate(titik.getFeatures()) | Bangun indeks spasial: semacam daftar isi yang mempercepat pencarian titik. Tiap titik diterjemahkan CRS-nya, dimasukkan ke indeks, dan geometrinya disimpan di kamus geom. |
| 67-70 | kolom = QgsFields(petak.fields()) lalu tiga kolom.append(...) | Salin kolom petak, tambah tiga kolom baru: Jml_Titik, Titik_per_Ha, Rawan. |
| 71-72 | sink, dest = self.parameterAsSink(...) | Minta QGIS menyiapkan tempat keluaran dengan kolom itu. Dua nilai dikembalikan: sink untuk menulis, dest untuk laporan. |
| 73-74 | if sink is None: raise QgsProcessingException(...) | Bila tempat keluaran gagal dibuat, hentikan dengan pesan jelas. |
Bagian keempat: pekerjaan inti (baris 76-90).
| Baris | Tulisan | Artinya |
|---|---|---|
| 76 | total = petak.featureCount() or 1 | Jumlah petak. or 1 mencegah pembagian nol di baris 89 bila layer kosong. |
| 77-79 | for n, f in enumerate(petak.getFeatures()): dan if feedback.isCanceled(): break | Telusuri tiap petak. Bila pengguna menekan Batal, berhenti. |
| 81 | kandidat = indeks.intersects(pg.boundingBox()) | Tanya indeks: titik mana yang jatuh di dalam kotak pembatas petak. Ini saringan kasar. |
| 82 | jumlah = sum(1 for k in kandidat if pg.contains(geom[k])) | Saringan halus: hitung hanya titik yang benar-benar di dalam poligon. |
| 83-84 | luas_ha dan kerapatan | Luas dalam hektare (dibagi 10000). Kerapatan adalah jumlah dibagi luas, dengan pengaman bila luas 0. |
| 85-87 | baru = QgsFeature(kolom), setGeometry, setAttributes(...) | Rakit fitur baru: bentuk lama, atribut lama ditambah tiga nilai baru. Kolom Rawan bernilai 1 bila kerapatan lebih besar dari batas, selain itu 0. |
| 88-89 | sink.addFeature(...) dan feedback.setProgress(...) | Tulis fitur dan perbarui bilah kemajuan. |
| 90 | return {self.HASIL: dest} | Serahkan hasil ke QGIS. |
Cara menjalankan. Ada dua jalan.
- Dari QGIS. Simpan berkas ke folder skrip Processing profil Anda:
%APPDATA%\QGIS\QGIS4\profiles\default\processing\scripts. Alat muncul di Processing Toolbox, kelompok Kehutanan. Letak dan kemunculan di jendela QGIS tidak saya uji dari sini. [CEK] - Dari terminal, dengan
qgis_process. Untuk skrip berbentuk kelas, jalur berkas boleh langsung diberikan:
cd D:/Latihan/seri/M4/skrip
"C:/Program Files/QGIS 4.0.2/bin/qgis_process-qgis.bat" run kerapatan_hotspot.py -- PETAK=../../../data-sintetis/paket-m4/Petak_KPH.gpkg TITIK=../../../data-sintetis/paket-data/Hotspot_Nasional.gpkg BATAS=2 HASIL=hasil.gpkgHasil. Layar qgis_process menampilkan isian, bilah kemajuan, dan hasilnya (berdasarkan uji pada QGIS 4.0.2):
Inputs
BATAS: 2
HASIL: hasil.gpkg
PETAK: ../../../data-sintetis/paket-m4/Petak_KPH.gpkg
TITIK: ../../../data-sintetis/paket-data/Hotspot_Nasional.gpkg
0...10...20...30...40...50...60...70...80...90...100 - done.
Results
HASIL: hasil.gpkgUntuk melihat isinya dari Python, dengan alat yang sudah terdaftar di folder skrip (cara uji bab ini memakai folder skrip sementara):
import processing
D = "D:/Latihan/data-sintetis"
r = processing.run("script:kerapatan_hotspot", {
"PETAK": D + "/paket-m4/Petak_KPH.gpkg",
"TITIK": D + "/paket-data/Hotspot_Nasional.gpkg",
"BATAS": 2, "HASIL": "TEMPORARY_OUTPUT"})
hasil = r["HASIL"]
print([f.name() for f in hasil.fields()])
print([f["ID_Petak"] for f in hasil.getFeatures() if f["Rawan"] == 1])['fid', 'ID_Petak', 'NAMA_KPH', 'Jenis', 'Tahun_Tanam', 'Luas_Ha', 'Jml_Titik', 'Titik_per_Ha', 'Rawan']
['P-07']Hasilnya 16 petak dengan 12 titik terhitung. Hanya P-07 yang rawan (3 titik per hektare). Dengan BATAS=0.5, sepuluh petak rawan, karena setiap petak yang punya satu titik sudah melewati 0,5.
Bagian yang boleh diubah.
- Baris 43,
defaultValue=2.0. Batas rawan bawaan. - Baris 21, 24, 27, 30. Nama alat, nama tampil, dan kelompok. Nama teknis (baris 21) jangan memakai spasi.
- Baris 68-70, nama kolom keluaran. Ganti
"Rawan"dengan nama lain. Skrip lain yang membaca hasilnya harus ikut diganti. - Baris 83, pembagi
10000.0. Ubah satuan luas. Bila Anda mengubahnya, ubah juga pesan di baris 53 agar tetap cocok. - Baris 87,
> batas. Ganti menjadi>= batasbila Anda ingin nilai yang tepat sama dengan batas ikut rawan.
Jebakan.
- Satuan peta harus meter, dan kini diperiksa. Luas diambil dari
pg.area(), dalam satuan CRS petak. Pembagi 10000 hanya benar bila CRS dalam meter. Bila petak di EPSG:4326 (derajat), alat berhenti di baris 52-53 dengan pesanCRS layer petak harus bersatuan meter .... Tanpa pengaman itu, hasilnya diam-diam salah: kerapatan menjadi belasan miliar titik per hektare (misalnya12309965392.232untuk satu petak, dan36929896525.894untuk P-07) dan sepuluh petak rawan, tanpa pesan galat. Bila muncul pesan itu, proyeksikan ulang petak ke UTM (misalnya EPSG:32749) lalu jalankan lagi. Pengaman ini hanya memeriksa layer petak. Layer titik boleh memakai CRS apa saja, karena baris 56 menerjemahkannya. - Tepat di batas tidak rawan. Uji dengan
BATAS=1: hanya P-07 rawan, padahal sembilan petak lain tepat 1,0. Alasannyakerapatan > batas, bukan>=. - Batas negatif ditolak.
BATAS=-1berhenti denganIncorrect parameter value for BATAS, karenaminValue=0.0. - Layer tertukar diterima. Dari Python, jenis layer di baris 38 dan 40 tidak diperiksa. Bila titik dan petak tertukar, alat jalan tanpa galat dan hasilnya tidak berarti.
- Titik di tepi.
containstidak menghitung titik yang tepat di garis tepi atau di sudut petak. Diuji pada poligon contoh: titik di dalam bernilaiTrue, titik di tepi dan titik di sudut bernilaiFalse. Data bawaan tidak punya titik di tepi, jadi pengaruhnya pada hasil alat tidak terlihat di bab ini.
Latihan variasi.
- Jalankan dengan batas 1,0, lalu cek berapa petak yang rawan.
- Ganti nama kolom
RawanmenjadiWaspada.
Contoh jawaban: untuk nomor 1, ganti "BATAS": 2 menjadi "BATAS": 1. Hasil uji: tetap hanya ['P-07']. Untuk nomor 2, ubah baris 70 menjadi:
kolom.append(QgsField("Waspada", QMetaType.Type.Int))Hasil uji: daftar kolom berakhir 'Titik_per_Ha', 'Waspada', dan petak waspada tetap ['P-07']. Hanya satu baris perlu diubah, karena baris 87 mengisi nilai berdasarkan urutan, bukan nama. Teks bantuan di baris 34 masih menyebut "Rawan"; ganti juga bila perlu.
Skrip 27.5: Alat Processing gaya dekorator #
Berkas tambah_luas_ha.py. Bagi pembaca buku M4, ini bagian "Cara ringkas: dekorator @alg" di Bab 2.
Tujuan. Alat yang menambah satu kolom berisi luas dalam hektare, dihitung di elipsoid WGS 84 (bentuk bumi yang dipakai GPS), pada layer poligon. Ini versi ringkas: 29 baris melawan 90 baris.
Bahan. paket-m4/Petak_KPH.gpkg.
Analogi: kalau gaya kelas adalah formulir resmi lengkap, dekorator adalah kartu isian satu lembar. Dekorator (Bab 11) adalah baris berawalan @ yang dipasang di atas sebuah fungsi untuk "membungkusnya" dengan tugas tambahan. Di sini bungkusnya mengubah fungsi biasa menjadi alat Processing.
Skrip utuh:
# -*- coding: utf-8 -*-
"""Processing script gaya dekorator @alg. Penulis: Badar Mubarok Yogaswara"""
from qgis.processing import alg
from qgis.core import QgsFeature, QgsField, QgsFields, QgsFeatureSink, QgsDistanceArea, QgsProcessingException, QgsWkbTypes, Qgis
from qgis.PyQt.QtCore import QMetaType
@alg(name="tambah_luas_ha", label="Tambah kolom luas (hektare)", group="kehutanan", group_label="Kehutanan")
@alg.input(type=alg.SOURCE, name="INPUT", label="Layer poligon", types=[2])
@alg.input(type=alg.SINK, name="OUTPUT", label="Hasil")
def tambah_luas_ha(instance, parameters, context, feedback, inputs):
"""Menambah kolom Luas_Ha_Hitung (hektare, dihitung di elipsoid WGS 84)."""
sumber = instance.parameterAsSource(parameters, "INPUT", context)
if sumber is None or QgsWkbTypes.geometryType(sumber.wkbType()) != Qgis.GeometryType.Polygon:
raise QgsProcessingException("Layer masukan harus bertipe poligon.")
kolom = QgsFields(sumber.fields())
kolom.append(QgsField("Luas_Ha_Hitung", QMetaType.Type.Double))
sink, dest = instance.parameterAsSink(parameters, "OUTPUT", context, kolom, sumber.wkbType(), sumber.sourceCrs())
if sink is None:
raise QgsProcessingException("Keluaran tidak bisa dibuat.")
da = QgsDistanceArea()
da.setSourceCrs(sumber.sourceCrs(), context.transformContext())
da.setEllipsoid("WGS84")
for f in sumber.getFeatures():
baru = QgsFeature(kolom)
baru.setGeometry(f.geometry())
baru.setAttributes(f.attributes() + [round(da.measureArea(f.geometry()) / 10000.0, 3)])
sink.addFeature(baru, QgsFeatureSink.Flag.FastInsert)
return {"OUTPUT": dest}Penjelasan baris demi baris.
| Baris | Tulisan | Artinya |
|---|---|---|
| 3 | from qgis.processing import alg | Panggil dekorator alg. |
| 4-5 | from qgis.core import (...) dan QMetaType | Perkakas fitur, kolom, pengukur luas, pelempar galat Processing (QgsProcessingException), dan pemeriksa jenis bentuk (QgsWkbTypes dan Qgis). |
| 8 | @alg(name="tambah_luas_ha", label=..., group=..., group_label=...) | Identitas alat dalam satu baris: nama teknis, nama tampil, kelompok. Padanan baris 17-34 pada Skrip 27.4. |
| 9 | @alg.input(type=alg.SOURCE, name="INPUT", label="Layer poligon", types=[2]) | Isian layer masukan. Angka 2 artinya poligon (diperiksa: TypeVectorPolygon bernilai 2, titik 0, garis 1). |
| 10 | @alg.input(type=alg.SINK, name="OUTPUT", label="Hasil") | Isian keluaran. |
| 11 | def tambah_luas_ha(instance, parameters, context, feedback, inputs): | Fungsi pekerjaannya. Isinya padanan processAlgorithm. |
| 12 | """...""" | Docstring, juga dipakai sebagai teks bantuan. |
| 13 | sumber = instance.parameterAsSource(...) | Ambil layer masukan dari isian formulir. |
| 14-15 | if sumber is None or QgsWkbTypes.geometryType(...) != Qgis.GeometryType.Polygon: raise QgsProcessingException(...) | Pengaman jenis layer. Bila layer tidak terbaca, atau bukan poligon (titik atau garis), alat berhenti dengan pesan "Layer masukan harus bertipe poligon." Pengaman ini perlu karena types=[2] di baris 9 hanya mengatur pilihan di jendela QGIS, dan tidak menolak layer yang dikirim lewat Python. |
| 16-18 | kolom = ..., kolom.append(...), sink, dest = ... | Salin kolom, tambah Luas_Ha_Hitung, siapkan keluaran. Sama seperti Skrip 27.4. |
| 19-20 | if sink is None: raise QgsProcessingException(...) | Bila tempat keluaran gagal dibuat, hentikan dengan pesan jelas. Sama seperti baris 73-74 pada Skrip 27.4. |
| 21-23 | da = QgsDistanceArea(), setSourceCrs, setEllipsoid("WGS84") | Siapkan pengukur luas di permukaan bumi yang melengkung. Hasilnya sedikit berbeda dari luas datar area(). |
| 24-28 | for f in sumber.getFeatures(): dst. | Untuk tiap fitur: ukur luas, bagi 10000, bulatkan tiga desimal, tambahkan ke atribut, tulis. |
| 29 | return {"OUTPUT": dest} | Kembalikan hasil. |
Cara menjalankan. Simpan berkas di folder skrip Processing profil Anda (jalur sama seperti Skrip 27.4). Untuk berkas gaya dekorator, perintah qgis_process run tambah_luas_ha.py tidak menemukan alatnya. Uji bab ini menjawab Algorithm script:tambah_luas_ha not found!. Alat harus berada di folder skrip yang dikenal QGIS, lalu dipanggil dengan nama script:tambah_luas_ha.
import processing
D = "D:/Latihan/data-sintetis"
r = processing.run("script:tambah_luas_ha", {
"INPUT": D + "/paket-m4/Petak_KPH.gpkg", "OUTPUT": "TEMPORARY_OUTPUT"})
hasil = r["OUTPUT"]
print([f.name() for f in hasil.fields()])
print([(f["ID_Petak"], f["Luas_Ha"], f["Luas_Ha_Hitung"]) for f in list(hasil.getFeatures())[:3]])Hasil. Layar menampilkan (berdasarkan uji pada QGIS 4.0.2, dengan folder skrip sementara):
['fid', 'ID_Petak', 'NAMA_KPH', 'Jenis', 'Tahun_Tanam', 'Luas_Ha', 'Luas_Ha_Hitung']
[('P-01', 1.0, 1.0), ('P-02', 1.0, 1.0), ('P-03', 1.0, 1.0)]Total kolom Luas_Ha_Hitung untuk 16 petak adalah 16,0 ha. Luas elipsoid hampir sama dengan kolom Luas_Ha. Selisihnya sekitar 0,00007 ha per petak (luas elipsoid sedikit lebih kecil), sehingga hilang saat dibulatkan ke tiga desimal.
Bagian yang boleh diubah.
- Baris 8,
name,label,group. Identitas alat. - Baris 9,
types=[2]. Jenis layer yang diterima di formulir. 2 poligon, 1 garis, 0 titik, -1 semua jenis. Baris 14 juga membatasi ke poligon, jadi bila alat ini diubah untuk jenis lain, ubah kedua baris. - Baris 17, nama kolom
Luas_Ha_Hitung. - Baris 27,
10000.0dan3. Satuan dan jumlah desimal. - Baris 23,
"WGS84". Elipsoid pengukur. Pilih yang sesuai wilayah, bila lembaga Anda menetapkannya. [CEK]
Jebakan.
- Layer titik ditolak. Dari Python,
types=[2]tidak ditegakkan, jadi baris 14-15 yang menjaganya. LayerHotspot_Nasional(titik) kini berhenti denganQgsProcessingException: Layer masukan harus bertipe poligon.(Diuji.) Tanpa pengaman itu, layer titik diterima dan kolom luasnya bernilai 0,0 tanpa galat, sehingga hasilnya tampak sah padahal tidak berarti. - Pemeriksaan keluaran. Baris 19-20 menjaga bila
parameterAsSinkmengembalikanNone. Pada uji bab ini, folder keluaran yang tidak ada tidak sampai ke baris itu, karena QGIS lebih dulu menghentikan alat dengan pesannya sendiri (Could not create layer ...). CabangNonehanya tercapai dengan objek tiruan, jadi belum teruji pada pemakaian nyata. - Fitur tanpa bentuk. Fitur yang geometrinya kosong tidak menimbulkan galat:
measureAreamengembalikan 0, jadi kolomLuas_Ha_Hitungbernilai 0,0 untuk fitur itu. (Diuji pada layer sementara berisi satu poligon dan satu fitur tanpa bentuk.) - Tidak ada
feedback. Tidak ada bilah kemajuan dan tombol batal yang bekerja.
Latihan variasi.
- Ganti nama kolom menjadi
Luas_Ha_Elips. - Bulatkan sampai dua desimal.
Contoh jawaban, hanya bagian yang berubah:
kolom.append(QgsField("Luas_Ha_Elips", QMetaType.Type.Double))dan di baris 27, ganti 3 menjadi 2. Hasil uji: kolom bernama Luas_Ha_Elips, nilai 1.0 untuk tiga petak pertama.
Dua gaya, satu pekerjaan: kapan memakai yang mana #
Bab 11 sudah memperkenalkan potongan kedua gaya ini. Di sini Anda melihat skrip lengkap dan hasil jalannya. Skrip 27.4 dan 27.5 sama-sama alat Processing. Keduanya terdaftar di Toolbox, bisa dipakai di Modeler dan mode batch, dan bisa dipanggil lewat Python. Bedanya ada di cara menulis.

| Hal | Gaya kelas (27.4) | Gaya dekorator (27.5) |
|---|---|---|
| Panjang contoh | 90 baris | 29 baris |
| Identitas alat | Enam metode terpisah | Satu dekorator @alg(...) |
| Isian formulir | initAlgorithm dengan addParameter | Satu @alg.input(...) per isian |
| Nama isian | Konstanta kelas (self.PETAK) | Teks langsung ("INPUT") |
| Bilah kemajuan dan batal | Dipakai lewat feedback | Tersedia (feedback), tetapi contoh tidak memakainya |
| Pemeriksaan masukan | QgsProcessingException dengan pesan jelas: layer terbaca, satuan meter, keluaran jadi | QgsProcessingException dengan pesan jelas: jenis poligon, keluaran jadi |
qgis_process run berkas.py | Berhasil | Tidak menemukan alat |
| Cocok untuk | Alat dengan banyak isian dan kontrol rinci | Alat kecil, satu tugas |
Saran praktis: mulailah dengan gaya dekorator untuk alat kecil. Pindah ke gaya kelas bila Anda butuh banyak isian, kontrol rinci, atau pelaporan kemajuan. Pemeriksaan masukan bisa ditulis di kedua gaya. Isi pekerjaannya, yaitu menyiapkan sink dan menulis fitur, hampir sama di kedua gaya. Jadi belajar satu membantu yang lain.
Skrip 27.6: Plugin sendiri di QGIS #
Berkas plugin_ringkas_kph/__init__.py dan plugin_ringkas_kph/ringkas_kph.py. Bagi pembaca buku M4, ini plugin Bab 1.
Tujuan. Menambah satu tombol dan satu menu di QGIS. Satu klik menampilkan jumlah objek dan luas total layer yang sedang dipilih.
Bahan. Folder plugin_ringkas_kph berisi empat berkas: dua skrip Python di bawah, metadata.txt (data diri plugin), dan icon.png (gambar tombol). Contoh layer: paket-m4/Petak_KPH.gpkg.
Analogi: plugin adalah tombol baru di dinding. QGIS adalah gedungnya. Anda memasang tombol itu lewat pintu depan (classFactory), dan QGIS memberi Anda remote control bernama iface untuk mengatur menu dan pesan. Plugin punya empat tahap hidup.

Empat istilah baru: classFactory (pintu depan), iface (remote control QGIS), initGui (pasang tombol), unload (cabut tombol).
Berkas pertama: __init__.py
# -*- coding: utf-8 -*-
"""Pintu masuk plugin. QGIS memanggil classFactory saat plugin dimuat."""
def classFactory(iface):
from .ringkas_kph import RingkasKPH
return RingkasKPH(iface)| Baris | Tulisan | Artinya |
|---|---|---|
| 1 | # -*- coding: utf-8 -*- | Penanda huruf UTF-8. |
| 2 | """Pintu masuk plugin. ...""" | Docstring: penjelasan berkas. |
| 5 | def classFactory(iface): | Fungsi dengan nama wajib persis ini. QGIS memanggilnya saat memuat plugin dan memberikan iface. |
| 6 | from .ringkas_kph import RingkasKPH | Panggil kelas dari berkas tetangga. Titik di depan .ringkas_kph berarti "dari folder yang sama". Pemanggilan ini sengaja ditaruh di dalam fungsi. |
| 7 | return RingkasKPH(iface) | Buat satu benda plugin dan serahkan ke QGIS. |
Berkas kedua: ringkas_kph.py
# -*- coding: utf-8 -*-
"""Plugin Ringkas KPH. Penulis: Badar Mubarok Yogaswara"""
import os
from qgis.core import Qgis, QgsCoordinateTransformContext, QgsDistanceArea, QgsWkbTypes
from qgis.PyQt.QtGui import QAction, QIcon
def ringkas_layer(layer):
"""Mengembalikan (jumlah objek, luas total dalam hektare). Luas dihitung di elipsoid WGS 84."""
da = QgsDistanceArea()
da.setSourceCrs(layer.crs(), QgsCoordinateTransformContext())
da.setEllipsoid("WGS84")
jumlah = 0
luas_m2 = 0.0
for f in layer.getFeatures():
jumlah += 1
if layer.geometryType() == QgsWkbTypes.GeometryType.PolygonGeometry:
luas_m2 += da.measureArea(f.geometry())
return jumlah, luas_m2 / 10000.0
class RingkasKPH:
def __init__(self, iface):
self.iface = iface
self.action = None
def initGui(self):
ikon = os.path.join(os.path.dirname(__file__), "icon.png")
self.action = QAction(QIcon(ikon), "Ringkas layer aktif", self.iface.mainWindow())
self.action.triggered.connect(self.run)
self.iface.addPluginToMenu("&Ringkas KPH", self.action)
self.iface.addToolBarIcon(self.action)
def unload(self):
self.iface.removePluginMenu("&Ringkas KPH", self.action)
self.iface.removeToolBarIcon(self.action)
self.action = None
def run(self):
layer = self.iface.activeLayer()
if layer is None or not hasattr(layer, "getFeatures"):
self.iface.messageBar().pushMessage("Ringkas KPH", "Pilih dulu satu layer vektor di panel Layers.",
level=Qgis.MessageLevel.Warning, duration=6)
return
jumlah, luas_ha = ringkas_layer(layer)
self.iface.messageBar().pushMessage("Ringkas KPH", "%s: %d objek, luas %.2f ha." % (layer.name(), jumlah, luas_ha),
level=Qgis.MessageLevel.Info, duration=8)Penjelasan baris demi baris. Bagian pertama: perkakas dan penghitung (baris 1-20).
| Baris | Tulisan | Artinya |
|---|---|---|
| 5 | from qgis.core import Qgis, QgsCoordinateTransformContext, QgsDistanceArea, QgsWkbTypes | Perkakas tingkat pesan, pengukur luas, dan jenis bentuk. |
| 6 | from qgis.PyQt.QtGui import QAction, QIcon | Perkakas tombol (QAction) dan gambar tombol (QIcon). Letak QAction ini sesuai QGIS 4 (berbasis Qt6). Versi lama menaruhnya di tempat lain. |
| 9-10 | def ringkas_layer(layer): | Fungsi pembantu, terpisah dari urusan tombol agar mudah diuji. Mengembalikan dua nilai. |
| 11-13 | da = QgsDistanceArea() dst. | Pengukur luas di elipsoid WGS 84, seperti Skrip 27.5. |
| 14-15 | jumlah = 0 dan luas_m2 = 0.0 | Dua penghitung awal. |
| 16-19 | for f in layer.getFeatures(): dst. | Tiap fitur menambah jumlah. Luas hanya ditambah bila layer berjenis poligon. |
| 20 | return jumlah, luas_m2 / 10000.0 | Kembalikan jumlah dan luas dalam hektare. |
Bagian kedua: kelas plugin (baris 23-48).
| Baris | Tulisan | Artinya |
|---|---|---|
| 23-26 | class RingkasKPH: dan def __init__(self, iface) | Cetakan plugin. __init__ dijalankan sekali saat benda dibuat: menyimpan iface dan menyiapkan tempat untuk tombol (self.action). |
| 28-30 | def initGui(self): dan self.action = QAction(...) | Dipanggil QGIS saat plugin dinyalakan. Buat tombol dengan ikon dan teks "Ringkas layer aktif". |
| 31 | self.action.triggered.connect(self.run) | Hubungkan: bila tombol diklik, jalankan metode run. triggered adalah sinyal klik. |
| 32-33 | addPluginToMenu dan addToolBarIcon | Pasang tombol di menu Plugins ► Ringkas KPH dan di bilah alat. |
| 35-38 | def unload(self): | Saat plugin dimatikan, cabut tombol dari menu dan bilah alat, lalu kosongkan self.action. |
| 40-41 | def run(self): dan layer = self.iface.activeLayer() | Dipanggil tiap tombol diklik. Ambil layer yang sedang dipilih. |
| 42-45 | if layer is None or not hasattr(layer, "getFeatures"): ... return | Bila tidak ada layer, atau layernya bukan vektor, tampilkan peringatan selama 6 detik lalu berhenti. |
| 46-48 | jumlah, luas_ha = ringkas_layer(layer) lalu pushMessage(...) | Hitung, lalu tampilkan hasil di bilah pesan QGIS selama 8 detik. %s, %d, dan %.2f diisi nama, jumlah, dan luas dua desimal. |
Isi metadata.txt yang diperiksa QGIS (nilai contoh dari paket):
| Bidang | Isi contoh | Arti |
|---|---|---|
name | Ringkas KPH | Nama plugin di daftar. |
qgisMinimumVersion | 3.34 | Versi QGIS terendah yang didukung. |
description dan about | teks singkat dan teks panjang | Penjelasan pendek dan panjang. |
version | 0.1.0 | Nomor versi, bentuk mayor.minor.tambal. |
author dan email | nama dan alamat surel penulis | Identitas pembuat. |
repository | alamat contoh di example.org | Tempat kode; alamat contoh, bukan yang sungguhan. |
Cara memasang. Salin folder plugin_ringkas_kph ke folder plugin profil QGIS Anda. Di Windows: %APPDATA%\QGIS\QGIS4\profiles\default\python\plugins. Lalu buka Plugins ► Manage and Install Plugins, tab Installed, dan centang Ringkas KPH. Pilih layer di panel Layers, lalu klik tombol atau menu Plugins ► Ringkas KPH ► Ringkas layer aktif. Langkah klik ini tidak saya uji karena butuh jendela QGIS. [CEK]
Yang saya uji adalah isi kodenya, tanpa jendela, dengan iface tiruan yang menyediakan menu dan bilah pesan sederhana (cara menjalankan tanpa jendela dibahas di Bab 3).
Hasil. Pesan yang muncul pada uji (berdasarkan QGIS 4.0.2, iface tiruan):
kelas: RingkasKPH
menu: ['&Ringkas KPH', 'bar']
PESAN: Ringkas KPH | Pilih dulu satu layer vektor di panel Layers. | level 1 | detik 6
PESAN: Ringkas KPH | Petak_KPH: 16 objek, luas 16.00 ha. | level 0 | detik 8
menu setelah unload: []Urutannya mengikuti empat tahap hidup: classFactory membuat benda, initGui memasang dua tempelan (menu dan bilah), run dipanggil dua kali (tanpa layer, lalu dengan layer), dan unload mengosongkan semuanya. Luas aslinya 15,9988 ha. Pesan menampilkan 16,00 karena %.2f membulatkan dua desimal.
Bagian yang boleh diubah.
- Baris 30, teks "Ringkas layer aktif". Label tombol dan menu.
- Baris 32 dan 36,
"&Ringkas KPH". Nama menu. Tanda&menandai huruf pintasan. Harus sama di baris 32 dan 36. - Baris 44 dan 48,
duration=6danduration=8. Lama pesan tampil, dalam detik. - Baris 13,
"WGS84". Elipsoid pengukur. __init__.pybaris 6-7. Bila Anda mengganti nama kelas, ganti di dua tempat ini.
Jebakan.
- Nama
classFactorywajib persis. Huruf besar dan kecil dihitung. Alat pengemas di Skrip 27.7 memeriksa katadef classFactory. - Titik di layer titik. Layer
Hotspot_Nasionalmemberi(12, 0.0): dua belas objek, luas 0. Itu benar, bukan galat, karena titik tak punya luas. - Butuh
ifacesungguhan. Kode tidak bisa dijalankan langsung seperti skrip biasa.ifacehanya ada di dalam QGIS. QgsDistanceAreatanpa konteks. Objek ini diberiQgsCoordinateTransformContext()yang kosong. Untuk layer UTM sederhana tidak masalah, tetapi untuk transformasi datum yang khusus, hasilnya bisa berbeda. [kemungkinan]
Latihan variasi.
- Ubah label tombol menjadi "Ringkas layer terpilih".
- Perpanjang pesan sukses menjadi 12 detik.
Contoh jawaban, hanya baris yang berubah:
self.action = QAction(QIcon(ikon), "Ringkas layer terpilih", self.iface.mainWindow())dan pada pushMessage terakhir, duration=12. Hasil uji: teks aksi menjadi Ringkas layer terpilih.
Skrip 27.7: Memeriksa dan mengemas plugin #
Berkas kemas_plugin.py. Bagi pembaca buku M4, ini bagian pengemasan di Bab 6.
Tujuan. Memeriksa bahwa plugin lengkap (berkas wajib dan data diri), lalu mengemasnya menjadi satu berkas zip.
Bahan. Folder plugin_ringkas_kph dari Skrip 27.6. Skrip ini hanya memakai Python biasa, tanpa QGIS, jadi Python sistem pun cukup. Jalankan dengan python kemas_plugin.py. Anda juga bisa memberi jalur folder plugin lain sebagai argumen.
Analogi: ini daftar periksa sebelum mengirim paket. Alamat lengkap? Isi paket benar? Baru dibungkus.
Skrip utuh:
# -*- coding: utf-8 -*-
"""Memeriksa metadata dan mengemas plugin menjadi zip (hanya berkas lokal). Penulis: Badar Mubarok Yogaswara
Jalankan: python kemas_plugin.py [folder_plugin] (Python biasa sudah cukup; tanpa QGIS)
"""
import configparser, os, re, sys, zipfile
WAJIB = ["name", "qgisMinimumVersion", "description", "about", "version", "author", "email", "repository"]
POLA_VERSI = re.compile(r"^\d+\.\d+\.\d+$")
def periksa(folder):
"""Kembalikan daftar masalah (kosong = lolos)."""
masalah = []
if not os.path.isfile(os.path.join(folder, "__init__.py")):
masalah.append("tidak ada __init__.py")
else:
with open(os.path.join(folder, "__init__.py"), encoding="utf-8") as fh:
ada_factory = "def classFactory" in fh.read()
if not ada_factory:
masalah.append("__init__.py tidak punya classFactory")
meta = os.path.join(folder, "metadata.txt")
if not os.path.isfile(meta):
return masalah + ["tidak ada metadata.txt"]
cp = configparser.ConfigParser()
cp.read(meta, encoding="utf-8")
g = cp["general"] if "general" in cp else {}
for k in WAJIB:
if not g.get(k):
masalah.append("bidang metadata kosong: " + k)
if g.get("version") and not POLA_VERSI.match(g["version"]):
masalah.append("versi bukan pola mayor.minor.tambal: " + g["version"])
return masalah
def kemas(folder, keluar):
nama = os.path.basename(os.path.normpath(folder))
induk = os.path.dirname(os.path.abspath(folder))
ver = configparser.ConfigParser()
ver.read(os.path.join(folder, "metadata.txt"), encoding="utf-8")
berkas = os.path.join(keluar, "%s-%s.zip" % (nama, ver["general"]["version"]))
os.makedirs(keluar, exist_ok=True)
with zipfile.ZipFile(berkas, "w", zipfile.ZIP_DEFLATED) as zf:
for r, _, fs in os.walk(folder):
if "__pycache__" in r:
continue
for f in fs:
p = os.path.join(r, f)
zf.write(p, os.path.relpath(p, induk))
return berkas
if __name__ == "__main__":
aq = os.path.dirname(os.path.abspath(__file__))
folder = sys.argv[1] if len(sys.argv) > 1 else os.path.join(aq, "plugin_ringkas_kph")
m = periksa(folder)
if m:
print("GAGAL:", m)
sys.exit(1)
z = kemas(folder, os.path.join(aq, "keluaran"))
print("LOLOS. Zip:", os.path.basename(z))
with zipfile.ZipFile(z) as zf:
print("isi:", zf.namelist())
# uji kegagalan: folder tanpa metadata
import tempfile
with tempfile.TemporaryDirectory() as t:
with open(os.path.join(t, "__init__.py"), "w") as fh:
fh.write("x = 1\n")
print("uji folder rusak ->", periksa(t))Penjelasan baris demi baris. Bagian pertama: aturan (baris 5-8).
| Baris | Tulisan | Artinya |
|---|---|---|
| 5 | import configparser, os, re, sys, zipfile | Lima perkakas: baca berkas metadata.txt, jalur folder, pola teks, argumen terminal, dan zip. |
| 7 | WAJIB = [...] | Daftar delapan bidang metadata yang harus terisi. |
| 8 | POLA_VERSI = re.compile(r"^\d+\.\d+\.\d+$") | Pola versi: tiga kelompok angka dipisah titik, seperti 0.1.0. re adalah perkakas pencocok pola. |
Bagian kedua: pemeriksa (baris 11-32).
| Baris | Tulisan | Artinya |
|---|---|---|
| 11-13 | def periksa(folder): dan masalah = [] | Fungsi pemeriksa. masalah adalah daftar kosong tempat keluhan dikumpulkan. |
| 14-20 | if not os.path.isfile(...) dan with open(...) as fh: | Pastikan __init__.py ada, dan memuat kata def classFactory. Berkas dibuka dengan with (Bab 9), jadi tertutup otomatis begitu isinya selesai dibaca. Hasil pencarian disimpan di ada_factory. Bila tidak ada, tambahkan keluhan. |
| 21-23 | meta = ... dan return masalah + ["tidak ada metadata.txt"] | Bila metadata.txt tidak ada, langsung kembali dengan keluhan itu. |
| 24-26 | cp = configparser.ConfigParser() dst. | Baca metadata.txt. Ambil bagian [general], atau kamus kosong bila tidak ada. |
| 27-29 | for k in WAJIB: dan if not g.get(k): | Tiap bidang wajib diperiksa. Kosong atau tidak ada berarti keluhan. |
| 30-32 | if g.get("version") and not POLA_VERSI.match(...) | Periksa bentuk versi, lalu kembalikan semua keluhan. Daftar kosong berarti lolos. |
Bagian ketiga: pengemas dan penjalan (baris 35-68).
| Baris | Tulisan | Artinya |
|---|---|---|
| 35-40 | def kemas(folder, keluar): | Hitung nama folder plugin dan nama zip: nama-versi.zip, versinya dibaca dari metadata.txt. |
| 41-42 | os.makedirs(...) dan with zipfile.ZipFile(berkas, "w", ...) | Buat folder keluaran dan buka zip untuk ditulisi. |
| 43-48 | for r, _, fs in os.walk(folder): | Telusuri seluruh isi folder. Folder __pycache__ dilewati (continue). Tiap berkas dimasukkan dengan jalur yang dimulai dari nama folder plugin. |
| 49 | return berkas | Kembalikan jalur zip. |
| 52-54 | if __name__ == "__main__": dan folder = sys.argv[1] if ... else ... | Pilih folder: argumen terminal bila ada, kalau tidak folder bawaan di samping skrip. |
| 55-58 | m = periksa(folder) dan sys.exit(1) | Bila ada keluhan, cetak GAGAL lalu berhenti dengan kode 1 (tanda gagal). |
| 59-62 | z = kemas(...) lalu with zipfile.ZipFile(z) as zf: dan print(...) | Kemas, lalu cetak nama zip dan isinya. Zip dibuka dengan with, jadi tertutup otomatis setelah daftar isinya dicetak. |
| 63-68 | import tempfile dan with tempfile.TemporaryDirectory() as t: | Uji kegagalan: buat folder sementara tanpa metadata dan lihat keluhannya. TemporaryDirectory (dari perkakas tempfile) membuat folder sementara, lalu menghapusnya sendiri begitu blok with selesai. |
Hasil. Layar menampilkan (berdasarkan uji dengan Python 3.12 biasa):
LOLOS. Zip: plugin_ringkas_kph-0.1.0.zip
isi: ['plugin_ringkas_kph/icon.png', 'plugin_ringkas_kph/metadata.txt', 'plugin_ringkas_kph/ringkas_kph.py', 'plugin_ringkas_kph/__init__.py']
uji folder rusak -> ['__init__.py tidak punya classFactory', 'tidak ada metadata.txt']Zip terbentuk di keluaran. Di QGIS, zip ini dipasang lewat Plugins ► Manage and Install Plugins ► Install from ZIP. [CEK: tidak diuji]
Bagian yang boleh diubah.
- Baris 7, daftar
WAJIB. Tambah atau kurangi bidang yang wajib. - Baris 8, pola versi. Ganti bila Anda memakai aturan versi lain.
- Baris 59, folder keluaran
os.path.join(aq, "keluaran"). - Baris 54, folder bawaan. Atau beri argumen saat menjalankan.
Jebakan.
- Metadata rusak. Plugin dengan bidang
emaildihapus dan versi0.1ditulis:GAGAL: ['bidang metadata kosong: email', 'versi bukan pola mayor.minor.tambal: 0.1'], kode keluar 1. (Diuji pada salinan.) - Folder tidak ada. Dengan argumen yang salah:
GAGAL: ['tidak ada __init__.py', 'tidak ada metadata.txt']. kemastidak memeriksa lagi. Fungsi ini langsung membacametadata.txt. Selalu panggilperiksalebih dulu, seperti di bagian utama.- Folder sementara dibersihkan otomatis. Baris 65 memakai
TemporaryDirectorydenganwith, jadi folder ujinya hilang setelah dipakai. Bila memakaitempfile.mkdtemp(), folder sementara tidak pernah dihapus dan menumpuk di folder sementara sistem tiap skrip dijalankan. Polawithyang sama menutup berkas di baris 17 dan 61.
Latihan variasi.
- Tambahkan
"tracker"sebagai bidang wajib. - Uji pada salinan plugin tanpa baris
tracker=.
Contoh jawaban, baris 7 menjadi:
WAJIB = ["name", "qgisMinimumVersion", "description", "about", "version", "author", "email", "repository", "tracker"]Hasil uji: plugin asli tetap LOLOS. Salinan tanpa baris tracker= menghasilkan GAGAL: ['bidang metadata kosong: tracker'].
Cek paham #
- Apa yang membuat skrip M4 hanya jalan di struktur folder tertentu?
- Apa beda singkat gaya kelas dan gaya dekorator pada alat Processing?
- Fungsi mana yang QGIS panggil pertama kali saat memuat plugin, dan mengapa namanya tidak boleh diubah?
Jawaban.
- Skrip menghitung lokasi data dari lokasinya sendiri dengan naik tiga folder lalu masuk
data-sintetis(baris 11 pada Skrip 27.1). - Gaya kelas menulis identitas, formulir, dan pekerjaan sebagai metode terpisah di dalam kelas. Gaya dekorator menaruh identitas dan formulir di baris
@di atas satu fungsi pekerjaan. classFactory. QGIS mencari fungsi dengan nama itu, jadi nama lain tidak akan ditemukan.
Kesalahan umum #
- Memindah skrip tanpa memindah data. Galatnya tentang berkas tidak ditemukan, dengan jalur lengkap yang dicari. Perbaikan: bangun struktur folder seperti bagian "Menyiapkan dulu".
- Mengira halaman peta web berjalan offline. Leaflet diambil dari internet. Perbaikan: sambungkan internet saat membuka halaman, atau simpan salinan Leaflet [CEK].
- Memakai
qgis_process rununtuk alat gaya dekorator. Perbaikan: taruh berkas di folder skrip Processing, lalu panggilscript:tambah_luas_ha. - Menjalankan alat kerapatan pada petak ber-CRS derajat. Pembagi 10000 hanya benar untuk satuan meter, jadi alat menolaknya dengan pesan. Perbaikan: ubah petak ke CRS UTM sebelum menjalankan alat.
- Memasukkan layer titik ke alat luas. Alat
tambah_luas_hahanya menerima poligon dan berhenti dengan pesan bila diberi titik. Perbaikan: pilih layer petak. - CSV berisi kode petak yang tidak ada di GeoPackage. Dasbor berhenti dengan pesan yang menyebut kodenya. Perbaikan: samakan kode di kedua berkas.
Ringkasan dan latihan #
- Satu data bisa dikemas menjadi dasbor, peta web, model 3D, alat Processing, atau plugin. Pola dasar skripnya tetap: baca, olah, tulis.
- Skrip M4 menghitung jalur data dari lokasinya sendiri, jadi struktur folder penulis harus ditiru.
- Alat Processing punya dua gaya: kelas (lengkap dan rinci) dan dekorator (ringkas).
- Plugin punya empat tahap:
classFactory,initGui,run,unload. - Periksa dulu, kemas kemudian: daftar keluhan mencegah paket cacat.
- Skrip yang baik memeriksa masukannya lebih dulu dan berhenti dengan pesan yang jelas: kode petak tak dikenal, satuan peta bukan meter, layer bukan poligon.
Latihan. Jalankan Skrip 27.1 dengan data Anda sendiri, misalnya data bulan Oktober. Label grafik dan nama bulan di kartu "Hari puncak" akan ikut berubah. Lalu ubah judul kartu "Total titik panas (30 hari)" di baris 68 dan judul halaman di baris 76 agar sesuai data Anda. Petunjuk: hitung jumlah hari dengan len(d["hari"]).
Tabel perbandingan: lima produk dari satu data #
| Produk | Skrip | Perlu QGIS penuh? | Keluaran dan catatan |
|---|---|---|---|
| Dasbor | 27.1 | Tidak, cukup GDAL | dasbor.html, mandiri, tanpa internet |
| Peta web | 27.2 | Ya | HTML dan GeoJSON, butuh internet untuk Leaflet |
| Model 3D | 27.3 | Ya | dsm.obj, ukuran bergantung langkah |
| Alat Processing | 27.4 dan 27.5 | Ya | Muncul di Toolbox, dua gaya penulisan |
| Plugin | 27.6 dan 27.7 | Dipakai di dalam QGIS | Menu dan tombol, dikemas sebagai zip |