Lewati ke isi
Profil penulisSeri Buku GIS Kehutanan dan Pertanian/ PY

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.

Ilustrasi 27.1: Satu data, lima keluaran
Skema data KPH diolah satu skrip menjadi dasbor, peta web, model 3D, alat Processing, dan plugin

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.

Ilustrasi 27.2: Struktur folder yang diharapkan skrip M4
Skema folder kerja berisi seri/M4/skrip dan data-sintetis dengan paket-m4 dan paket-data

Langkahnya:

  1. Buat folder kerja, misalnya D:/Latihan.
  2. Di dalamnya buat seri/M4/skrip, lalu salin semua skrip bab ini ke sana. Salin juga folder plugin_ringkas_kph.
  3. Di folder kerja yang sama buat folder data-sintetis.
  4. Salin folder paket-m4 ke data-sintetis/paket-m4.
  5. Salin folder paket-data ke data-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]

TERMINAL
cd D:/Latihan/seri/M4/skrip
"C:/Program Files/QGIS 4.0.2/bin/python-qgis.bat" buat_dasbor.py

Jalur 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.

Ilustrasi 27.3: Alur skrip 27.1
Skema enam tahap: baca petak, baca CSV, jumlahkan, gambar grafik, rakit HTML, cetak ringkasan

Skrip utuh:

PYTHONUnduh berkas
# -*- 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).

BarisTulisanArtinya
1# -*- coding: utf-8 -*-Komentar penanda: berkas ini ditulis dengan huruf UTF-8.
2-4"""..."""Teks penjelasan di awal berkas (docstring). Python tidak menjalankannya.
5import csv, json, osPanggil tiga perkakas sekaligus: csv membaca tabel teks, json merakit teks JSON, os mengurus jalur folder (Bab 8).
6from collections import defaultdictPanggil kamus khusus yang otomatis mulai dari nol bila kunci belum ada.
7-8from osgeo import ogr dan ogr.UseExceptions()Panggil pembaca data vektor dari GDAL. Baris 8 membuatnya berteriak (galat) bila gagal, bukan diam saja.
9BULAN = ["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).
10AQ = os.path.dirname(os.path.abspath(__file__))Isi kotak AQ dengan lokasi folder skrip ini. __file__ adalah nama berkas yang sedang berjalan.
11PAKET = 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.
12OUT = ...Lokasi folder keluaran: keluaran/dasbor di samping skrip.

Bagian kedua: menghitung (baris 15-40). Fungsi hitung() membuat tiga "buku catatan" (kamus) lalu mengisinya.

BarisTulisanArtinya
16-19petak = {} 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-22defaultdict(int) tiga kaliSiapkan tiga catatan jumlah: per hari, per petak, per KPH. Isi awalnya otomatis 0.
23-24with open(...) dan csv.DictReader(fh)Buka CSV. DictReader memberi tiap baris sebagai kamus, kolomnya dipanggil dengan nama (Bab 9).
25j = int(b["Jumlah"])Ubah teks "1" menjadi angka 1 agar bisa dijumlah.
26-27if 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-30per_hari[...] += jTambahkan jumlah ke catatan hari, petak, dan KPH. Baris 30 mencari KPH petak itu lewat kamus petak, jadi baris 26-27 harus lebih dulu.
31-33luas_kphJumlahkan luas petak per KPH.
34max(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.
35sorted(..., key=lambda kv: (-kv[1], kv[0]))[:3]Urutkan petak dari yang terbanyak. Tanda minus membalik urutan. [:3] mengambil tiga pertama.
36-40return {...}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.

BarisTulisanArtinya
43-45def 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-49def 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.
50pts = " ".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-53teks <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-63def 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).

BarisTulisanArtinya
67os.makedirs(OUT, exist_ok=True)Buat folder keluaran. Bila sudah ada, tidak apa-apa.
68-69kartu = [...]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-81html = """...""" % (...)Teks HTML panjang dengan tiga lubang %s: kartu, grafik garis, grafik batang. Tanda %% di CSS dibaca sebagai satu tanda persen.
82-83with open(..., "w", encoding="utf-8")Tulis HTML ke berkas dasbor.html.
86if __name__ == "__main__":Jalankan bagian di bawahnya hanya bila berkas ini dijalankan langsung.
87-89d = 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.

HASIL
{"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, w dan h. 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 pesan ValueError: 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 hanya KeyError: '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 berarti len(hari) - 1 sama dengan 0, dan pembagian nol menimbulkan ZeroDivisionError. Angka 1 dari max mencegahnya: fungsi garis_svg yang 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_tgl mengambil huruf ke-9 dan ke-10 sebagai hari, ke-6 dan ke-7 sebagai bulan. Maka kolom Tanggal harus berbentuk 2026-09-30. Bentuk lain, misalnya 30/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.

  1. Cetak lima petak teratas, bukan tiga.
  2. Ubah warna garis menjadi hijau tua #2E7D32.

Contoh jawaban, hanya bagian yang berubah:

PYTHON
    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:

PYTHONUnduh berkas
# -*- 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).

BarisTulisanArtinya
6import json, math, osTiga perkakas: JSON, hitungan matematika, dan jalur folder.
7os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")Pengaturan agar QGIS jalan tanpa membuka jendela. setdefault hanya mengisi bila belum ada isinya.
8-9from qgis.core import (...)Panggil enam perkakas QGIS. Tanda kurung memungkinkan daftar dipecah ke dua baris.
11-13AQ, DATA, OUTSama seperti Skrip 27.1: lokasi skrip, folder data-sintetis (naik tiga folder), dan folder keluaran peta_web.
16-21def 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).

BarisTulisanArtinya
24-26def ekspor(...), lyr = QgsVectorLayer(..., "ogr"), assert lyr.isValid(), layer_pathBuka layer. assert menghentikan skrip bila layer gagal dibuka, dan mencetak jalurnya (Bab 10).
27-29opsi = QgsVectorFileWriter.SaveVectorOptions() dst.Siapkan "formulir pilihan" penulisan: format GeoJSON, huruf UTF-8.
30-31opsi.ct = QgsCoordinateTransform(lyr.crs(), QgsCoordinateReferenceSystem("EPSG:4326"), ...)Atur perubahan koordinat dari CRS layer (UTM) ke EPSG:4326 (lintang-bujur), karena GeoJSON standar memakainya.
32opsi.layerOptions = [...]Pilihan tambahan: ketelitian enam desimal dan mengikuti standar GeoJSON (RFC 7946).
33-35writeAsVectorFormatV3(...), 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.

BarisTulisanArtinya
42, 45<link ... leaflet.css> dan <script src=".../leaflet.js">Dua alamat internet tempat Leaflet diambil.
47var peta = L.map('peta');Siapkan papan peta kosong. Ini JavaScript, bukan Python.
48-57fetch('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-62fetch('hotspot.geojson')...Ambil titik panas lalu gambar sebagai lingkaran merah berjari-jari 6.

Bagian keempat: menjalankan (baris 66-83).

BarisTulisanArtinya
67-68app = QgsApplication([], False) dan app.initQgis()Nyalakan mesin QGIS tanpa jendela.
69-71os.makedirs(...), n1 = ekspor(...), n2 = ekspor(...)Buat folder, lalu ekspor dua layer.
72-73with open(... "index.html" ...)Tulis teks HTML ke index.html.
75-80for 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-82print("ubin zoom 0 ...", ke_tile(...))Cetak tiga contoh nomor ubin.
83app.exitQgis()Matikan mesin QGIS.

Hasil. Layar menampilkan (berdasarkan uji pada QGIS 4.0.2):

HASIL
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), dan fillOpacity (kepekatan isi).
  • Baris 60, radius: 6 dan color. 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 tanpa data-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.Jenis harus sama dengan nama kolom di GeoJSON, termasuk huruf besar dan kecil.

Latihan variasi.

  1. Kurangi ketelitian koordinat menjadi tiga desimal.
  2. Ganti warna petak menjadi hijau tua #1B5E20 dan jari-jari titik panas menjadi 10.

Contoh jawaban, hanya bagian yang berubah:

PYTHON
    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:

PYTHONUnduh berkas
# -*- 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).

BarisTulisanArtinya
5-6import os dan os.environ.setdefault(...)Perkakas jalur, dan pengaturan QGIS tanpa jendela (sama seperti Skrip 27.2).
7-9from 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-12AQ, DATA, OUTLokasi 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.

BarisTulisanArtinya
15def 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-21gdal.Open(tif) dst.Buka raster. gt adalah ukuran piksel dan letaknya. data adalah larik angka tinggi. nodata adalah angka penanda "tidak ada data".
22-23baris = list(range(0, ds.RasterYSize, langkah))Daftar nomor baris yang dipilih: 0, 16, 32, dan seterusnya. Kolom dibuat sama.
24-25idx = {} dan n = 0idx mencatat nomor urut tiap titik sudut. n penghitungnya.
26-27with open(obj, "w", ...) dan fh.write(...)Buka berkas .obj dan tulis baris komentar di awalnya.
28-30for 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-32if nodata is not None and z == nodata: z = 0.0Bila piksel tak berdata, tingginya dianggap 0.
33-34x = ... dan y = ...Hitung letak datar dalam meter, dihitung dari sudut barat daya. Baris 0 raster adalah utara, jadi nilainya dibalik.
35-37n += 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-43for 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).
44return 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).

BarisTulisanArtinya
48-50app = QgsApplication([], False), app.initQgis(), os.makedirs(...)Nyalakan QGIS tanpa jendela, siapkan folder.
51-52v, f, zmin, zmax = dsm_ke_obj(...) lalu print(...)Panggil fungsi. Empat nilai hasilnya dibongkar ke empat kotak.
53-55vs = [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-57r = QgsRasterLayer(...)Buka DSM sebagai layer QGIS lalu cetak ukuran dan CRS-nya.
58-59QgsPointCloudLayer(..., "pdal")Buka titik awan LAZ lalu cetak jumlah titiknya. Pembaca "pdal" harus tersedia di QGIS Anda.
60-69from 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):

HASIL
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.0

Berkas keluaran/tiga_d/dsm.obj berukuran 143.790 byte. Lima baris pertamanya:

HASIL
# 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.000

Perhatikan: 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.tif dengan 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.

  1. Jadikan jaring lebih jarang: langkah=32.
  2. Gunakan tinggi asli: skala_z=1.0.

Contoh jawaban, hanya bagian yang berubah:

PYTHON
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:

PYTHONUnduh berkas
# -*- 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).

BarisTulisanArtinya
3-8from qgis.core import (...) dan QMetaTypePanggil 15 perkakas QGIS. Dua di antaranya, QgsFeatureRequest dan QgsProject, tidak dipakai. Yang terakhir, Qgis, dipakai untuk memeriksa satuan peta di baris 52.
11class KerapatanHotspot(QgsProcessingAlgorithm):Buat kelas baru yang mewarisi semua sifat algoritma Processing bawaan. Tanda kurung berarti "turunan dari".
12-15PETAK = "PETAK" dst.Empat nama tetap untuk isian formulir. Dipakai berulang di bawah agar tidak salah ketik.
17-18def createInstance(self)Pabrik: setiap kali QGIS butuh satu salinan alat, ia memanggil ini. self adalah benda yang sedang dibicarakan.
20-24name dan displayNamename adalah nama teknis untuk Python. displayName adalah nama yang terlihat di Toolbox.
26-30group dan groupIdNama kelompok tempat alat muncul ("Kehutanan"), dan kodenya.
32-34shortHelpStringTeks bantuan singkat di kolom kanan jendela alat.

Bagian kedua: formulir (baris 36-44).

BarisTulisanArtinya
36def initAlgorithm(self, config=None):Metode yang QGIS panggil untuk membangun formulir.
37-38QgsProcessingParameterFeatureSource(self.PETAK, "Layer petak (poligon)", [...TypeVectorPolygon])Isian layer masukan. Teks di tengah adalah label di formulir. Daftar di ujung membatasi jenis layer: poligon.
39-40idem untuk TITIKIsian layer titik panas, jenis titik.
41-43QgsProcessingParameterNumber(... defaultValue=2.0, minValue=0.0)Isian angka: bawaan 2,0 titik per hektare, tidak boleh di bawah 0.
44QgsProcessingParameterFeatureSink(self.HASIL, ...)Isian keluaran. Sink adalah tempat hasil ditulis.

Bagian ketiga: persiapan pekerjaan (baris 46-74).

BarisTulisanArtinya
46def processAlgorithm(self, parameters, context, feedback):Metode inti. parameters berisi isian pengguna, context berisi pengaturan QGIS, feedback adalah saluran pesan dan kemajuan.
47-49self.parameterAsSource(...) dan parameterAsDouble(...)Ambil isian formulir menjadi benda Python: dua layer dan satu angka.
50-51if petak is None or titik is None: raise QgsProcessingException(...)Bila layer tidak terbaca, hentikan dengan pesan jelas. raise melempar galat (Bab 10).
52-53if 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.
56ke_petak = QgsCoordinateTransform(...)Siapkan "penerjemah" CRS dari titik ke petak.
57-65indeks = 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-70kolom = QgsFields(petak.fields()) lalu tiga kolom.append(...)Salin kolom petak, tambah tiga kolom baru: Jml_Titik, Titik_per_Ha, Rawan.
71-72sink, dest = self.parameterAsSink(...)Minta QGIS menyiapkan tempat keluaran dengan kolom itu. Dua nilai dikembalikan: sink untuk menulis, dest untuk laporan.
73-74if sink is None: raise QgsProcessingException(...)Bila tempat keluaran gagal dibuat, hentikan dengan pesan jelas.

Bagian keempat: pekerjaan inti (baris 76-90).

BarisTulisanArtinya
76total = petak.featureCount() or 1Jumlah petak. or 1 mencegah pembagian nol di baris 89 bila layer kosong.
77-79for n, f in enumerate(petak.getFeatures()): dan if feedback.isCanceled(): breakTelusuri tiap petak. Bila pengguna menekan Batal, berhenti.
81kandidat = indeks.intersects(pg.boundingBox())Tanya indeks: titik mana yang jatuh di dalam kotak pembatas petak. Ini saringan kasar.
82jumlah = sum(1 for k in kandidat if pg.contains(geom[k]))Saringan halus: hitung hanya titik yang benar-benar di dalam poligon.
83-84luas_ha dan kerapatanLuas dalam hektare (dibagi 10000). Kerapatan adalah jumlah dibagi luas, dengan pengaman bila luas 0.
85-87baru = 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-89sink.addFeature(...) dan feedback.setProgress(...)Tulis fitur dan perbarui bilah kemajuan.
90return {self.HASIL: dest}Serahkan hasil ke QGIS.

Cara menjalankan. Ada dua jalan.

  1. 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]
  2. Dari terminal, dengan qgis_process. Untuk skrip berbentuk kelas, jalur berkas boleh langsung diberikan:
TERMINAL
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.gpkg

Hasil. Layar qgis_process menampilkan isian, bilah kemajuan, dan hasilnya (berdasarkan uji pada QGIS 4.0.2):

HASIL
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.gpkg

Untuk melihat isinya dari Python, dengan alat yang sudah terdaftar di folder skrip (cara uji bab ini memakai folder skrip sementara):

PYTHONUnduh berkas
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])
HASIL
['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 >= batas bila 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 pesan CRS layer petak harus bersatuan meter .... Tanpa pengaman itu, hasilnya diam-diam salah: kerapatan menjadi belasan miliar titik per hektare (misalnya 12309965392.232 untuk satu petak, dan 36929896525.894 untuk 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. Alasannya kerapatan > batas, bukan >=.
  • Batas negatif ditolak. BATAS=-1 berhenti dengan Incorrect parameter value for BATAS, karena minValue=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. contains tidak menghitung titik yang tepat di garis tepi atau di sudut petak. Diuji pada poligon contoh: titik di dalam bernilai True, titik di tepi dan titik di sudut bernilai False. Data bawaan tidak punya titik di tepi, jadi pengaruhnya pada hasil alat tidak terlihat di bab ini.

Latihan variasi.

  1. Jalankan dengan batas 1,0, lalu cek berapa petak yang rawan.
  2. Ganti nama kolom Rawan menjadi Waspada.

Contoh jawaban: untuk nomor 1, ganti "BATAS": 2 menjadi "BATAS": 1. Hasil uji: tetap hanya ['P-07']. Untuk nomor 2, ubah baris 70 menjadi:

PYTHON
        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:

PYTHONUnduh berkas
# -*- 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.

BarisTulisanArtinya
3from qgis.processing import algPanggil dekorator alg.
4-5from qgis.core import (...) dan QMetaTypePerkakas 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.
11def tambah_luas_ha(instance, parameters, context, feedback, inputs):Fungsi pekerjaannya. Isinya padanan processAlgorithm.
12"""..."""Docstring, juga dipakai sebagai teks bantuan.
13sumber = instance.parameterAsSource(...)Ambil layer masukan dari isian formulir.
14-15if 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-18kolom = ..., kolom.append(...), sink, dest = ...Salin kolom, tambah Luas_Ha_Hitung, siapkan keluaran. Sama seperti Skrip 27.4.
19-20if sink is None: raise QgsProcessingException(...)Bila tempat keluaran gagal dibuat, hentikan dengan pesan jelas. Sama seperti baris 73-74 pada Skrip 27.4.
21-23da = QgsDistanceArea(), setSourceCrs, setEllipsoid("WGS84")Siapkan pengukur luas di permukaan bumi yang melengkung. Hasilnya sedikit berbeda dari luas datar area().
24-28for f in sumber.getFeatures(): dst.Untuk tiap fitur: ukur luas, bagi 10000, bulatkan tiga desimal, tambahkan ke atribut, tulis.
29return {"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.

PYTHONUnduh berkas
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):

HASIL
['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.0 dan 3. 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. Layer Hotspot_Nasional (titik) kini berhenti dengan QgsProcessingException: 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 parameterAsSink mengembalikan None. 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 ...). Cabang None hanya tercapai dengan objek tiruan, jadi belum teruji pada pemakaian nyata.
  • Fitur tanpa bentuk. Fitur yang geometrinya kosong tidak menimbulkan galat: measureArea mengembalikan 0, jadi kolom Luas_Ha_Hitung bernilai 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.

  1. Ganti nama kolom menjadi Luas_Ha_Elips.
  2. Bulatkan sampai dua desimal.

Contoh jawaban, hanya bagian yang berubah:

PYTHON
    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.

Ilustrasi 27.4: Dua gaya alat Processing
Skema perbandingan kerangka gaya kelas dan gaya dekorator
HalGaya kelas (27.4)Gaya dekorator (27.5)
Panjang contoh90 baris29 baris
Identitas alatEnam metode terpisahSatu dekorator @alg(...)
Isian formulirinitAlgorithm dengan addParameterSatu @alg.input(...) per isian
Nama isianKonstanta kelas (self.PETAK)Teks langsung ("INPUT")
Bilah kemajuan dan batalDipakai lewat feedbackTersedia (feedback), tetapi contoh tidak memakainya
Pemeriksaan masukanQgsProcessingException dengan pesan jelas: layer terbaca, satuan meter, keluaran jadiQgsProcessingException dengan pesan jelas: jenis poligon, keluaran jadi
qgis_process run berkas.pyBerhasilTidak menemukan alat
Cocok untukAlat dengan banyak isian dan kontrol rinciAlat 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.

Ilustrasi 27.5: Empat tahap hidup plugin
Skema classFactory, initGui, run, unload secara berurutan

Empat istilah baru: classFactory (pintu depan), iface (remote control QGIS), initGui (pasang tombol), unload (cabut tombol).

Berkas pertama: __init__.py

PYTHONUnduh berkas
# -*- coding: utf-8 -*-
"""Pintu masuk plugin. QGIS memanggil classFactory saat plugin dimuat."""


def classFactory(iface):
    from .ringkas_kph import RingkasKPH
    return RingkasKPH(iface)
BarisTulisanArtinya
1# -*- coding: utf-8 -*-Penanda huruf UTF-8.
2"""Pintu masuk plugin. ..."""Docstring: penjelasan berkas.
5def classFactory(iface):Fungsi dengan nama wajib persis ini. QGIS memanggilnya saat memuat plugin dan memberikan iface.
6from .ringkas_kph import RingkasKPHPanggil kelas dari berkas tetangga. Titik di depan .ringkas_kph berarti "dari folder yang sama". Pemanggilan ini sengaja ditaruh di dalam fungsi.
7return RingkasKPH(iface)Buat satu benda plugin dan serahkan ke QGIS.

Berkas kedua: ringkas_kph.py

PYTHONUnduh berkas
# -*- 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).

BarisTulisanArtinya
5from qgis.core import Qgis, QgsCoordinateTransformContext, QgsDistanceArea, QgsWkbTypesPerkakas tingkat pesan, pengukur luas, dan jenis bentuk.
6from qgis.PyQt.QtGui import QAction, QIconPerkakas tombol (QAction) dan gambar tombol (QIcon). Letak QAction ini sesuai QGIS 4 (berbasis Qt6). Versi lama menaruhnya di tempat lain.
9-10def ringkas_layer(layer):Fungsi pembantu, terpisah dari urusan tombol agar mudah diuji. Mengembalikan dua nilai.
11-13da = QgsDistanceArea() dst.Pengukur luas di elipsoid WGS 84, seperti Skrip 27.5.
14-15jumlah = 0 dan luas_m2 = 0.0Dua penghitung awal.
16-19for f in layer.getFeatures(): dst.Tiap fitur menambah jumlah. Luas hanya ditambah bila layer berjenis poligon.
20return jumlah, luas_m2 / 10000.0Kembalikan jumlah dan luas dalam hektare.

Bagian kedua: kelas plugin (baris 23-48).

BarisTulisanArtinya
23-26class RingkasKPH: dan def __init__(self, iface)Cetakan plugin. __init__ dijalankan sekali saat benda dibuat: menyimpan iface dan menyiapkan tempat untuk tombol (self.action).
28-30def initGui(self): dan self.action = QAction(...)Dipanggil QGIS saat plugin dinyalakan. Buat tombol dengan ikon dan teks "Ringkas layer aktif".
31self.action.triggered.connect(self.run)Hubungkan: bila tombol diklik, jalankan metode run. triggered adalah sinyal klik.
32-33addPluginToMenu dan addToolBarIconPasang tombol di menu Plugins ► Ringkas KPH dan di bilah alat.
35-38def unload(self):Saat plugin dimatikan, cabut tombol dari menu dan bilah alat, lalu kosongkan self.action.
40-41def run(self): dan layer = self.iface.activeLayer()Dipanggil tiap tombol diklik. Ambil layer yang sedang dipilih.
42-45if layer is None or not hasattr(layer, "getFeatures"): ... returnBila tidak ada layer, atau layernya bukan vektor, tampilkan peringatan selama 6 detik lalu berhenti.
46-48jumlah, 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):

BidangIsi contohArti
nameRingkas KPHNama plugin di daftar.
qgisMinimumVersion3.34Versi QGIS terendah yang didukung.
description dan aboutteks singkat dan teks panjangPenjelasan pendek dan panjang.
version0.1.0Nomor versi, bentuk mayor.minor.tambal.
author dan emailnama dan alamat surel penulisIdentitas pembuat.
repositoryalamat contoh di example.orgTempat 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):

HASIL
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=6 dan duration=8. Lama pesan tampil, dalam detik.
  • Baris 13, "WGS84". Elipsoid pengukur.
  • __init__.py baris 6-7. Bila Anda mengganti nama kelas, ganti di dua tempat ini.

Jebakan.

  • Nama classFactory wajib persis. Huruf besar dan kecil dihitung. Alat pengemas di Skrip 27.7 memeriksa kata def classFactory.
  • Titik di layer titik. Layer Hotspot_Nasional memberi (12, 0.0): dua belas objek, luas 0. Itu benar, bukan galat, karena titik tak punya luas.
  • Butuh iface sungguhan. Kode tidak bisa dijalankan langsung seperti skrip biasa. iface hanya ada di dalam QGIS.
  • QgsDistanceArea tanpa konteks. Objek ini diberi QgsCoordinateTransformContext() yang kosong. Untuk layer UTM sederhana tidak masalah, tetapi untuk transformasi datum yang khusus, hasilnya bisa berbeda. [kemungkinan]

Latihan variasi.

  1. Ubah label tombol menjadi "Ringkas layer terpilih".
  2. Perpanjang pesan sukses menjadi 12 detik.

Contoh jawaban, hanya baris yang berubah:

PYTHON
        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:

PYTHONUnduh berkas
# -*- 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).

BarisTulisanArtinya
5import configparser, os, re, sys, zipfileLima perkakas: baca berkas metadata.txt, jalur folder, pola teks, argumen terminal, dan zip.
7WAJIB = [...]Daftar delapan bidang metadata yang harus terisi.
8POLA_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).

BarisTulisanArtinya
11-13def periksa(folder): dan masalah = []Fungsi pemeriksa. masalah adalah daftar kosong tempat keluhan dikumpulkan.
14-20if 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-23meta = ... dan return masalah + ["tidak ada metadata.txt"]Bila metadata.txt tidak ada, langsung kembali dengan keluhan itu.
24-26cp = configparser.ConfigParser() dst.Baca metadata.txt. Ambil bagian [general], atau kamus kosong bila tidak ada.
27-29for k in WAJIB: dan if not g.get(k):Tiap bidang wajib diperiksa. Kosong atau tidak ada berarti keluhan.
30-32if 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).

BarisTulisanArtinya
35-40def kemas(folder, keluar):Hitung nama folder plugin dan nama zip: nama-versi.zip, versinya dibaca dari metadata.txt.
41-42os.makedirs(...) dan with zipfile.ZipFile(berkas, "w", ...)Buat folder keluaran dan buka zip untuk ditulisi.
43-48for 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.
49return berkasKembalikan jalur zip.
52-54if __name__ == "__main__": dan folder = sys.argv[1] if ... else ...Pilih folder: argumen terminal bila ada, kalau tidak folder bawaan di samping skrip.
55-58m = periksa(folder) dan sys.exit(1)Bila ada keluhan, cetak GAGAL lalu berhenti dengan kode 1 (tanda gagal).
59-62z = 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-68import 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):

HASIL
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 email dihapus dan versi 0.1 ditulis: 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'].
  • kemas tidak memeriksa lagi. Fungsi ini langsung membaca metadata.txt. Selalu panggil periksa lebih dulu, seperti di bagian utama.
  • Folder sementara dibersihkan otomatis. Baris 65 memakai TemporaryDirectory dengan with, jadi folder ujinya hilang setelah dipakai. Bila memakai tempfile.mkdtemp(), folder sementara tidak pernah dihapus dan menumpuk di folder sementara sistem tiap skrip dijalankan. Pola with yang sama menutup berkas di baris 17 dan 61.

Latihan variasi.

  1. Tambahkan "tracker" sebagai bidang wajib.
  2. Uji pada salinan plugin tanpa baris tracker=.

Contoh jawaban, baris 7 menjadi:

PYTHON
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 #

  1. Apa yang membuat skrip M4 hanya jalan di struktur folder tertentu?
  2. Apa beda singkat gaya kelas dan gaya dekorator pada alat Processing?
  3. Fungsi mana yang QGIS panggil pertama kali saat memuat plugin, dan mengapa namanya tidak boleh diubah?

Jawaban.

  1. Skrip menghitung lokasi data dari lokasinya sendiri dengan naik tiga folder lalu masuk data-sintetis (baris 11 pada Skrip 27.1).
  2. 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.
  3. 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 run untuk alat gaya dekorator. Perbaikan: taruh berkas di folder skrip Processing, lalu panggil script: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_ha hanya 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 #

ProdukSkripPerlu QGIS penuh?Keluaran dan catatan
Dasbor27.1Tidak, cukup GDALdasbor.html, mandiri, tanpa internet
Peta web27.2YaHTML dan GeoJSON, butuh internet untuk Leaflet
Model 3D27.3Yadsm.obj, ukuran bergantung langkah
Alat Processing27.4 dan 27.5YaMuncul di Toolbox, dua gaya penulisan
Plugin27.6 dan 27.7Dipakai di dalam QGISMenu dan tombol, dikemas sebagai zip