Bab 40 — GIS 3: Peta dan Integrasi: PostGIS dan QGIS

Seri I5 — Aplikasi Pertama: Web, Peta, dan Dasbor

CatatanDi bab ini, Anda akan:
  • Membuat peta statis (Matplotlib) dan peta interaktif (folium)
  • Menyimpan dan menanyai data spasial di PostGIS dari Python
  • Mengendalikan QGIS dengan PyQGIS tanpa membuka jendelanya
  • Memahami perbedaan PyQGIS di QGIS 4 (Qt6) dibanding QGIS 3
  • Menyimpan data lokasi usaha di PostGIS dan GeoPackage: proyek bersambung

Semua skrip bab ini ada di folder kode/bab40/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Dua bab GIS sebelumnya membuat Anda bisa menghitung. Sekarang kita menyajikan dan menyimpan. Peta adalah cara data spasial dibaca orang; basis data spasial adalah gudang yang tahan lama; dan QGIS adalah “dapur peta” yang dipakai banyak instansi. Python bisa duduk di tengah ketiganya.

Python sebagai pusat: membaca dan menulis ke berbagai bentuk penyimpanan dan penyaji peta.

Peta statis dengan Matplotlib

Peta statis adalah gambar (PNG, PDF) untuk laporan dan buku. GeoPandas menggambar lewat Matplotlib (Bab 25), jadi semua kendali Matplotlib tersedia: judul, legenda, label.

01_peta_statis.py
# Bab 40 - Peta statis dengan GeoPandas dan Matplotlib
from pathlib import Path

import geopandas as gpd
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
petak = gpd.read_file("data/petak.geojson").to_crs(32748)
kolam = gpd.read_file("data/kolam.geojson").to_crs(32748)
kandang = gpd.read_file("data/kandang.geojson").to_crs(32748)
sungai = gpd.read_file("data/sungai.geojson").to_crs(32748)

warna = {"hutan": "#4f8a3b", "kebun": "#d9a441", "sawah": "#e8dc7a"}
fig, ax = plt.subplots(figsize=(7, 5.6), dpi=100)
for sektor, grup in petak.groupby("sektor"):
    grup.plot(ax=ax, color=warna[sektor], edgecolor="black", linewidth=0.8)
sungai.plot(ax=ax, color="#3b6a9a", linewidth=2.5)
kolam.plot(ax=ax, color="#1f4e79")
kandang.plot(ax=ax, color="#b04a4a", markersize=60, marker="s")
for _, p in petak.iterrows():                                  # label kode petak
    ax.annotate(p["kode"], (p.geometry.centroid.x, p.geometry.centroid.y), ha="center", weight="bold")

ax.set_title("Peta lokasi usaha (data tiruan)")
ax.set_xlabel("Timur (m, UTM 48S)")
ax.set_ylabel("Utara (m, UTM 48S)")
ax.ticklabel_format(style="plain", useOffset=False)
ax.set_aspect("equal")                                         # 1 m di sumbu X = 1 m di sumbu Y
legenda = [Line2D([0], [0], marker="s", color="w", markerfacecolor=w, markersize=12, label=s)
           for s, w in warna.items()]
legenda += [Line2D([0], [0], color="#3b6a9a", lw=2.5, label="sungai"),
            Line2D([0], [0], marker="s", color="w", markerfacecolor="#1f4e79", markersize=9, label="kolam"),
            Line2D([0], [0], marker="s", color="w", markerfacecolor="#b04a4a", markersize=9, label="kandang")]
ax.legend(handles=legenda, loc="upper left", fontsize=8)
fig.tight_layout()
fig.savefig(tmp / "peta_statis.png")
print("peta tersimpan:", "peta_statis.png", "| ukuran piksel:", fig.get_size_inches() * fig.dpi)
peta tersimpan: peta_statis.png | ukuran piksel: [700. 560.]

Hal yang membedakan peta dari grafik biasa:

  • ax.set_aspect("equal"): 1 meter di sumbu X digambar sama panjang dengan 1 meter di sumbu Y. Tanpa ini, petak persegi tampak gepeng.
  • Data digambar dalam UTM (meter), jadi sumbu bisa diberi label meter dan jarak terbaca benar.
  • Warna dipilih per sektor lewat groupby, lalu legenda dibuat sendiri dari Line2D.

Gambar 700 × 560 piksel itu tersimpan di _tmp/peta_statis.png. Contoh peta yang sama dengan zona 60 m sudah Anda lihat di Bab 38.

Peta interaktif dengan folium

folium membungkus pustaka JavaScript Leaflet dan menghasilkan berkas HTML yang bisa digeser dan diperbesar di browser, lengkap dengan penanda dan popup.

02_peta_folium.py
# Bab 40 - Peta interaktif dengan folium (hasilnya berkas HTML)
from pathlib import Path

import folium
import geopandas as gpd

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
petak = gpd.read_file("data/petak.geojson")              # folium butuh EPSG:4326 (derajat)
kolam = gpd.read_file("data/kolam.geojson")
kandang = gpd.read_file("data/kandang.geojson")
sungai = gpd.read_file("data/sungai.geojson")

pusat = petak.union_all().centroid
peta = folium.Map(location=[pusat.y, pusat.x], zoom_start=16, tiles="OpenStreetMap")  # urutan: lintang, bujur

folium.GeoJson(petak, name="petak",
               tooltip=folium.GeoJsonTooltip(fields=["kode", "sektor", "komoditas"])).add_to(peta)
folium.GeoJson(sungai, name="sungai", style_function=lambda f: {"color": "#3b6a9a", "weight": 4}).add_to(peta)
for _, k in kolam.iterrows():
    folium.Marker([k.geometry.centroid.y, k.geometry.centroid.x],
                  popup=f"Kolam {k['kode']}: {k['jumlah_ikan']} ikan {k['jenis']}",
                  icon=folium.Icon(color="blue", icon="tint")).add_to(peta)
for _, k in kandang.iterrows():
    folium.Marker([k.geometry.y, k.geometry.x], popup=f"Kandang {k['kode']}: {k['jenis']}",
                  icon=folium.Icon(color="red")).add_to(peta)
folium.LayerControl().add_to(peta)

peta.save(tmp / "peta_usaha.html")
html = (tmp / "peta_usaha.html").read_text(encoding="utf-8")
print("berkas HTML tersimpan:", "peta_usaha.html")
print("memuat pustaka Leaflet:", "leaflet" in html.lower())
print("jumlah penanda (Marker):", html.count("L.marker("))
print("berisi nama petak 'Meranti':", "Meranti" in html)
berkas HTML tersimpan: peta_usaha.html
memuat pustaka Leaflet: True
jumlah penanda (Marker): 7
berisi nama petak 'Meranti': True
  • folium memakai EPSG:4326 (derajat), dan urutan lokasinya [lintang, bujur]: kebalikan dari (x, y). Kekeliruan urutan ini membuat peta “mendarat” di laut.
  • Skrip memasang lapisan petak (dengan tooltip yang muncul saat kursor mendekat), sungai, tujuh penanda (empat kolam dan tiga kandang), dan kontrol lapisan.
  • Bukti yang bisa kita cetak: berkas memuat Leaflet, memuat tujuh L.marker(, dan memuat nama komoditas Meranti.

Saya tidak membuka peta_usaha.html di browser dalam pembuatan buku ini; yang diperiksa hanya isi berkasnya, seperti pada keluaran di atas. Perhatikan juga bahwa peta dasar (OpenStreetMap) dimuat dari internet saat peta dibuka, jadi tanpa sinyal Anda hanya melihat bentuk petak tanpa latar. Menyalin data sensitif ke berkas HTML berarti data itu ikut terkirim ke siapa pun yang menerima berkasnya.

PostGIS: basis data yang mengerti peta

PostgreSQL adalah basis data relasional (Bab 24); PostGIS adalah ekstensinya yang menambah tipe geometry, ratusan fungsi spasial (ST_Area, ST_Distance, …), dan indeks spasial. Keuntungannya dibanding berkas: banyak pengguna sekaligus, data besar, dan pencarian cepat berdasarkan letak.

Untuk bab ini, pembangun buku menyalakan klaster PostgreSQL sementara (port acak, hanya di komputer ini, dihapus sesudahnya) dan memberi alamatnya lewat variabel DATABASE_URL. Skrip yang memakai ini ditandai @pg. Server PostgreSQL milik penulis tidak disentuh. Di komputer Anda, nilai bawaan postgresql://postgres@localhost:5432/belajar hanyalah contoh: ganti dengan alamat server milik Anda (dan jangan menaruh kata sandi di kode; ambil dari variabel lingkungan, Bab 34).

Membuat tabel dan memuat data

03_postgis_muat_data.py
# Bab 40 - PostGIS: membuat tabel spasial dan memuat data dengan psycopg
import os

import geopandas as gpd
import psycopg

# Di komputer Anda, ganti alamat bawaan ini dengan alamat server PostgreSQL+PostGIS milik Anda
alamat = os.environ.get("DATABASE_URL", "postgresql://postgres@localhost:5432/belajar")

petak = gpd.read_file("data/petak.geojson")
kolam = gpd.read_file("data/kolam.geojson")
sungai = gpd.read_file("data/sungai.geojson")

with psycopg.connect(alamat) as sambungan:
    sambungan.execute("CREATE EXTENSION IF NOT EXISTS postgis")
    versi = sambungan.execute("SELECT PostGIS_Lib_Version()").fetchone()[0]
    print("PostGIS aktif, versi mayor:", versi.split(".")[0])

    sambungan.execute("DROP TABLE IF EXISTS petak, kolam, sungai")
    sambungan.execute("""CREATE TABLE petak (
        kode text PRIMARY KEY, sektor text, komoditas text,
        geom geometry(Polygon, 4326))""")                     # tipe geometri DAN SRID dikunci
    sambungan.execute("""CREATE TABLE kolam (
        kode text PRIMARY KEY, jenis text, jumlah_ikan int,
        geom geometry(Polygon, 4326))""")

    sambungan.execute("""CREATE TABLE sungai (
        nama text, geom geometry(LineString, 4326))""")

    for _, p in petak.iterrows():                              # WKT = teks geometri
        sambungan.execute("INSERT INTO petak VALUES (%s, %s, %s, ST_GeomFromText(%s, 4326))",
                          (p["kode"], p["sektor"], p["komoditas"], p.geometry.wkt))
    for _, k in kolam.iterrows():
        sambungan.execute("INSERT INTO kolam VALUES (%s, %s, %s, ST_GeomFromText(%s, 4326))",
                          (k["kode"], k["jenis"], int(k["jumlah_ikan"]), k.geometry.wkt))

    sambungan.execute("INSERT INTO sungai VALUES (%s, ST_GeomFromText(%s, 4326))",
                      (sungai["nama"].iloc[0], sungai.geometry.iloc[0].wkt))

    # Indeks spasial GiST: membuat pencarian berdasarkan letak cepat
    sambungan.execute("CREATE INDEX petak_geom_idx ON petak USING GIST (geom)")
    sambungan.execute("CREATE INDEX kolam_geom_idx ON kolam USING GIST (geom)")

    for tabel in ("petak", "kolam", "sungai"):
        n = sambungan.execute(f"SELECT count(*) FROM {tabel}").fetchone()[0]
        print(f"baris di tabel {tabel}:", n)
    print("jenis geometri tabel petak:",
          sambungan.execute("SELECT DISTINCT ST_GeometryType(geom) FROM petak").fetchone()[0])
PostGIS aktif, versi mayor: 3
baris di tabel petak: 6
baris di tabel kolam: 4
baris di tabel sungai: 1
jenis geometri tabel petak: ST_Polygon
  • CREATE EXTENSION IF NOT EXISTS postgis mengaktifkan PostGIS di basis data (butuh hak yang cukup di server Anda).
  • geometry(Polygon, 4326) mengunci jenis bentuk dan SRID (nomor CRS). Memasukkan jenis lain akan ditolak: pengaman yang tidak ada pada berkas.
  • Bentuk dikirim sebagai WKT (Well-Known Text), teks seperti POLYGON((...)), dan diubah oleh ST_GeomFromText.
  • Indeks GiST membuat pencarian berdasarkan letak cepat. Hasil: 6 petak, 4 kolam, 1 sungai.

Query spasial

04_postgis_query_spasial.py
# Bab 40 - Query spasial di PostGIS (memakai tabel dari skrip 03)
import os

import psycopg

alamat = os.environ.get("DATABASE_URL", "postgresql://postgres@localhost:5432/belajar")

with psycopg.connect(alamat) as sambungan:
    # Luas: tipe geography menghitung di bumi bulat, hasilnya meter persegi
    print("luas petak (ha):")
    for kode, ha in sambungan.execute(
            "SELECT kode, round((ST_Area(geom::geography) / 10000)::numeric, 2) FROM petak ORDER BY kode"):
        print(f"  {kode}: {ha}")

    # Petak dalam jarak 60 m dari sungai: ST_DWithin pada tipe geography (jarak dalam meter)
    print("\npetak dalam 60 m dari sungai:")
    for (kode,) in sambungan.execute(
            "SELECT p.kode FROM petak p, sungai s "
            "WHERE ST_DWithin(p.geom::geography, s.geom::geography, 60) ORDER BY p.kode"):
        print(" ", kode)

    # Kolam terdekat ke titik gudang (operator <-> = urut menurut jarak, memakai indeks)
    print("\ntiga kolam terdekat ke titik gudang:")
    gudang = "ST_SetSRID(ST_MakePoint(106.8140, -6.9950), 4326)"
    for kode, jarak in sambungan.execute(
            f"SELECT kode, round(ST_Distance(geom::geography, {gudang}::geography)::numeric) "
            f"FROM kolam ORDER BY geom <-> {gudang} LIMIT 3"):
        print(f"  kolam {kode}: {jarak} m")

    # Menggabungkan dua tabel menurut letak: tabel petak dan kolam yang saling bersinggungan
    n = sambungan.execute("SELECT count(*) FROM petak p JOIN kolam k ON ST_Intersects(p.geom, k.geom)").fetchone()[0]
    print("\npasangan petak-kolam yang saling tumpang tindih:", n)
luas petak (ha):
  H1: 9.00
  H2: 6.92
  K1: 4.00
  K2: 3.00
  S1: 3.00
  S2: 2.25

petak dalam 60 m dari sungai:
  K2
  S1
  S2

tiga kolam terdekat ke titik gudang:
  kolam B: 82 m
  kolam C: 172 m
  kolam A: 274 m

pasangan petak-kolam yang saling tumpang tindih: 0

Perhatikan:

  • Memakai ::geography membuat PostGIS menghitung di bumi bulat dan menjawab dalam meter walau datanya berderajat. H2 muncul 6,92 ha di sini, sedangkan GeoPandas (UTM) menghitung 6,93 ha: dua metode berbeda menghasilkan selisih kecil karena proyeksi UTM sedikit mengubah ukuran. Untuk keperluan lapangan keduanya cukup dekat; untuk keperluan hukum, ikuti ketentuan resminya.
  • ST_DWithin(..., 60) menemukan petak dalam 60 m dari sungai: K2, S1, S2, sama dengan hasil overlay Bab 38.
  • Operator <-> mengurutkan menurut jarak dan memanfaatkan indeks: tiga kolam terdekat ke titik gudang (titik contoh) adalah B (82 m), C (172 m), A (274 m).
  • Tidak ada petak yang tumpang tindih dengan kolam (hasil 0).
PeringatanAWAS

Jangan menyusun SQL dengan menempel teks pengguna ke dalam string (f"... {isian} ..."): itu membuka celah SQL injection. Untuk nilai, selalu pakai parameter %s seperti pada INSERT di skrip 03. Skrip 04 menempel teks hanya karena isinya konstanta tetap dari kita sendiri, bukan dari pengguna.

PostGIS dan GeoPandas

05_postgis_geopandas.py
# Bab 40 - PostGIS dan GeoPandas: baca query, tulis tabel baru
import os

import geopandas as gpd
from sqlalchemy import create_engine, text

alamat = os.environ.get("DATABASE_URL", "postgresql://postgres@localhost:5432/belajar")
mesin = create_engine(alamat.replace("postgresql://", "postgresql+psycopg://"))

# Baca hasil query langsung menjadi GeoDataFrame
sql = "SELECT kode, sektor, geom FROM petak WHERE sektor <> 'hutan'"
bukan_hutan = gpd.read_postgis(sql, mesin, geom_col="geom")
print(bukan_hutan[["kode", "sektor"]].to_string(index=False))
print("CRS terbaca:", bukan_hutan.crs)

# Tulis GeoDataFrame ke tabel baru (geoalchemy2 dibutuhkan di belakang layar)
kandang = gpd.read_file("data/kandang.geojson")
kandang.to_postgis("kandang", mesin, if_exists="replace", index=False)
with mesin.connect() as k:
    print("baris di tabel kandang:", k.execute(text("SELECT count(*) FROM kandang")).scalar())
    print("tipe kolom geometri:", k.execute(text(
        "SELECT type || ' / SRID ' || srid FROM geometry_columns WHERE f_table_name = 'kandang'")).scalar())
kode sektor
  K1  kebun
  K2  kebun
  S1  sawah
  S2  sawah
CRS terbaca: EPSG:4326
baris di tabel kandang: 3
tipe kolom geometri: POINT / SRID 4326

read_postgis mengubah hasil query menjadi GeoDataFrame, dengan CRS terbaca otomatis (EPSG:4326). to_postgis menulis GeoDataFrame ke tabel baru dan membuat kolom bertipe geometri (POINT / SRID 4326). Keduanya membutuhkan SQLAlchemy dan, untuk menulis, GeoAlchemy2 (versi 0.20.0 saya pasang lewat alat/pasang.py).

PyQGIS: mengendalikan QGIS dari Python

QGIS adalah program GIS bebas yang sangat populer. Ia punya Python sendiri dan modul bernama PyQGIS (qgis.core, qgis.analysis) untuk mengotomasi pekerjaan: memuat lapisan, memproses, memberi gaya, mencetak peta. Cara pakai: skrip dijalankan oleh Python milik QGIS, bukan Python proyek ini.

Di komputer penulis, QGIS 4.0.2 terpasang di C:\Program Files\QGIS 4.0.2 dengan Python 3.12 miliknya sendiri. Perintah bin\python-qgis.bat menyiapkan semua jalur lalu menjalankan Python itu. Karena itulah dua skrip berikut diberi arahan @python: yang menunjuk ke berkas itu; di komputer Anda jalurnya akan berbeda (atau memakai konsol Python di dalam QGIS).

Pola dasar skrip tanpa antarmuka (sesuai cookbook PyQGIS resmi):

  1. QgsApplication.setPrefixPath("<folder qgis>", True): beri tahu lokasi pemasangan.
  2. app = QgsApplication([], False): False = tanpa GUI.
  3. app.initQgis(): muat penyedia data.
  4. … kerja Anda …
  5. app.exitQgis(): bersih-bersih.

Tambahan kita: os.environ["QT_QPA_PLATFORM"] = "offscreen" sebelum QgsApplication dibuat, supaya Qt tidak mencari layar.

06_pyqgis_dasar.py
# Bab 40 - PyQGIS tanpa antarmuka: memuat lapisan, membaca atribut
import os

os.environ["QT_QPA_PLATFORM"] = "offscreen"        # tanpa jendela (harus sebelum QgsApplication dibuat)

from qgis.core import Qgis, QgsApplication, QgsProject, QgsVectorLayer

QgsApplication.setPrefixPath("C:/Program Files/QGIS 4.0.2/apps/qgis", True)
app = QgsApplication([], False)                    # False = tanpa GUI
app.initQgis()
print("versi mayor QGIS:", Qgis.version().split(".")[0])

petak = QgsVectorLayer("data/petak.geojson", "petak", "ogr")
print("lapisan valid:", petak.isValid(), "| jumlah fitur:", petak.featureCount())
print("CRS:", petak.crs().authid(), "| jenis geometri:", petak.geometryType().name)
print("kolom:", [f.name() for f in petak.fields()])

for fitur in petak.getFeatures():
    if fitur["sektor"] == "sawah":
        print("sawah:", fitur["kode"], fitur["komoditas"])

QgsProject.instance().addMapLayer(petak)
print("lapisan di proyek:", [l.name() for l in QgsProject.instance().mapLayers().values()])
app.exitQgis()
versi mayor QGIS: 4
lapisan valid: True | jumlah fitur: 6
CRS: EPSG:4326 | jenis geometri: Polygon
kolom: ['kode', 'sektor', 'komoditas']
sawah: S1 padi
sawah: S2 padi
lapisan di proyek: ['petak']

Keluaran membuktikan skrip berjalan di QGIS 4 (versi mayor 4): enam fitur terbaca, CRS EPSG:4326, jenis geometri poligon, dan kolom kode, sektor, komoditas. Sawah S1 dan S2 ditemukan dengan menyaring atribut.

Algoritma Processing, gaya, dan menggambar peta

QGIS punya ratusan algoritma pengolah (Processing), mis. native:buffer dan native:reprojectlayer. Dari skrip, panggil dengan processing.run(nama, parameter).

07_pyqgis_proses_dan_cetak.py
# Bab 40 - PyQGIS: algoritma Processing, memberi gaya, menggambar peta ke PNG
import os

os.environ["QT_QPA_PLATFORM"] = "offscreen"

import sys
from pathlib import Path

from qgis.core import (QgsApplication, QgsCategorizedSymbolRenderer, QgsCoordinateReferenceSystem,
                       QgsMapRendererSequentialJob, QgsMapSettings, QgsProject, QgsRendererCategory,
                       QgsSymbol, QgsVectorLayer)
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
QgsApplication.setPrefixPath("C:/Program Files/QGIS 4.0.2/apps/qgis", True)
app = QgsApplication([], False)
app.initQgis()

# Processing: modul `processing` ada di folder plugin QGIS, jadi jalurnya ditambahkan dulu
sys.path.append("C:/Program Files/QGIS 4.0.2/apps/qgis/python/plugins")
import processing
from processing.core.Processing import Processing
from qgis.analysis import QgsNativeAlgorithms

QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms())
Processing.initialize()

petak = QgsVectorLayer("data/petak.geojson", "petak", "ogr")
sungai = QgsVectorLayer("data/sungai.geojson", "sungai", "ogr")

datar = processing.run("native:reprojectlayer", {
    "INPUT": petak, "TARGET_CRS": QgsCoordinateReferenceSystem("EPSG:32748"), "OUTPUT": "memory:"})["OUTPUT"]
print("luas petak di UTM (m2):", [round(f.geometry().area()) for f in datar.getFeatures()])

sungai_utm = processing.run("native:reprojectlayer", {
    "INPUT": sungai, "TARGET_CRS": QgsCoordinateReferenceSystem("EPSG:32748"), "OUTPUT": "memory:"})["OUTPUT"]
zona = processing.run("native:buffer", {"INPUT": sungai_utm, "DISTANCE": 60, "SEGMENTS": 8, "OUTPUT": "memory:"})["OUTPUT"]
print("jumlah fitur zona penyangga:", zona.featureCount(), "| luas (ha):",
      round(next(zona.getFeatures()).geometry().area() / 10_000, 2))

# Gaya: warna berdasarkan kolom sektor
kategori = []
for nilai, warna in [("hutan", "#4f8a3b"), ("kebun", "#d9a441"), ("sawah", "#e8dc7a")]:
    simbol = QgsSymbol.defaultSymbol(datar.geometryType())
    simbol.setColor(QColor(warna))
    kategori.append(QgsRendererCategory(nilai, simbol, nilai))
datar.setRenderer(QgsCategorizedSymbolRenderer("sektor", kategori))
zona.renderer().symbol().setColor(QColor(59, 106, 154, 90))     # biru transparan

# Menggambar peta ke gambar PNG
pengaturan = QgsMapSettings()
pengaturan.setLayers([zona, datar])
pengaturan.setDestinationCrs(datar.crs())
pengaturan.setExtent(zona.extent())
pengaturan.setOutputSize(QSize(700, 450))
pengaturan.setBackgroundColor(QColor("white"))
tugas = QgsMapRendererSequentialJob(pengaturan)
tugas.start()
tugas.waitForFinished()
gambar = tugas.renderedImage()
print("ukuran gambar:", gambar.width(), "x", gambar.height(), "| tersimpan:", gambar.save(str(tmp / "peta_qgis.png")))

proyek = QgsProject.instance()
proyek.setCrs(datar.crs())
proyek.addMapLayers([zona, datar])
print("proyek QGIS tersimpan:", proyek.write(str(tmp / "usaha.qgz")))
app.exitQgis()
luas petak di UTM (m2): [90001, 69252, 39999, 30000, 29999, 22500]
jumlah fitur zona penyangga: 1 | luas (ha): 13.23
ukuran gambar: 700 x 450 | tersimpan: True
proyek QGIS tersimpan: True

Luas petak setelah diubah ke UTM (90001, 69252, 39999, … m²) cocok dengan hitungan GeoPandas. Zona penyangga 60 m berluas 13,23 ha (GeoPandas 13,24 ha: beda sedikit karena banyaknya segmen pada ujung buffer). Sesudah itu skrip memberi gaya per kategori (warna menurut kolom sektor), menggambar peta 700 × 450 piksel ke PNG, dan menyimpan proyek QGIS (.qgz) yang bisa dibuka di QGIS sungguhan.

CatatanTIPS

Karena modul processing ada di folder plugin QGIS, skrip menambahkan .../apps/qgis/python/plugins ke sys.path dan mendaftarkan QgsNativeAlgorithms sebelum processing.run. Tanpa dua langkah itu, algoritma tidak ditemukan.

QGIS 4 berbeda dari QGIS 3

QGIS 4.0 (dirilis Maret 2026) pindah dari Qt5/PyQt5 ke Qt6/PyQt6. Akibatnya banyak kode PyQGIS lama perlu disesuaikan. Dua perubahan yang paling sering menabrak:

  1. Enum Qt harus ditulis lengkap: Qt.AlignLeft menjadi Qt.AlignmentFlag.AlignLeft.
  2. Tipe kolom memakai QMetaType.Type (bukan QVariant) pada QgsField.
09_pyqgis4_beda_dengan_qgis3.py
# Bab 40 - QGIS 4 (Qt6): enum Qt harus ditulis lengkap, tipe kolom memakai QMetaType
import os

os.environ["QT_QPA_PLATFORM"] = "offscreen"

from qgis.core import Qgis, QgsField
from qgis.PyQt.QtCore import QMetaType, Qt

# Gaya QGIS 3 (PyQt5): Qt.AlignLeft. Di QGIS 4 (PyQt6) nama pendek itu sudah tidak ada.
try:
    Qt.AlignLeft
except AttributeError as galat:
    print("gaya lama gagal ->", galat)
print("gaya baru Qt.AlignmentFlag.AlignLeft ->", int(Qt.AlignmentFlag.AlignLeft))

# Tipe kolom: QMetaType.Type menggantikan QVariant pada QgsField
kolom = QgsField("jumlah_ikan", QMetaType.Type.Int)
print("nama kolom:", kolom.name(), "| tipe:", kolom.type())

# Enum milik QGIS sendiri juga dianjurkan ditulis lengkap
print("jenis geometri poligon:", Qgis.GeometryType.Polygon.name)
gaya lama gagal -> type object 'Qt' has no attribute 'AlignLeft'
gaya baru Qt.AlignmentFlag.AlignLeft -> 1
nama kolom: jumlah_ikan | tipe: Type.Int
jenis geometri poligon: Polygon

Yang saya buktikan di QGIS 4.0.2 komputer ini: gaya lama Qt.AlignLeft gagal dengan AttributeError, gaya baru berhasil, dan QgsField dengan QMetaType.Type.Int bekerja. Sebagian nama QGIS lama masih berfungsi lewat lapisan kompatibilitas (saya mencoba QgsWkbTypes.PolygonGeometry dan QgsLayoutExporter.Success, keduanya masih menjawab), tetapi bentuk lengkap seperti Qgis.GeometryType.Polygon lebih aman ke depan. Daftar lengkap perubahan ada di panduan kompatibilitas Qt6 di situs QGIS; saya tidak membaca seluruhnya, jadi rujuklah panduan itu untuk kode Anda sendiri.

Dua temuan dari percobaan di komputer penulis: (1) layout cetak (QgsPrintLayout) bisa diekspor ke PNG tanpa antarmuka, tetapi teks pada label tampil sebagai kotak hitam pada mode offscreen (kemungkinan karena tidak ada font yang dimuat; penyebab pastinya belum saya periksa). Karena itu skrip bab ini memakai QgsMapRendererSequentialJob untuk menggambar peta, bukan layout bertulisan. (2) Ekspor layout memancarkan pesan GDAL “ERROR 6” di keluaran walau berhasil; saya tidak memakainya agar keluaran bersih.

Alur kerja Python dan QGIS

Kebutuhan Alat yang cocok
Hitungan dan analisis berulang GeoPandas / Rasterio (Python proyek Anda)
Penyimpanan bersama, banyak pengguna PostGIS
Memeriksa dan menyunting peta dengan mata dan tikus QGIS (antarmuka)
Mengotomasi langkah QGIS (cetak 20 peta serupa) PyQGIS
Peta untuk laporan atau buku Matplotlib
Peta untuk dibagikan di web folium

Pola kerja yang umum: data masuk ke PostGIS atau GeoPackage; analis memeriksanya di QGIS; skrip GeoPandas menghitung ringkasannya; dan PyQGIS atau Matplotlib mencetak peta mingguan.

Proyek Bersambung I5: data lokasi usaha di dua tempat

Lima lapisan lokasi (petak, kolam, kandang, sungai, dusun) kita simpan ke PostGIS (untuk dipakai bersama dan nanti dibaca API) dan ke GeoPackage (satu berkas yang bisa dikirim lewat surel dan dibuka di QGIS), lalu dicek bahwa keduanya berisi hal yang sama.

08_proyek_dua_penyimpanan.py
# Bab 40 - Proyek bersambung I5: data lokasi disimpan di PostGIS dan GeoPackage
import os
from pathlib import Path

import geopandas as gpd
import pandas as pd
from sqlalchemy import create_engine

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
alamat = os.environ.get("DATABASE_URL", "postgresql://postgres@localhost:5432/belajar")
mesin = create_engine(alamat.replace("postgresql://", "postgresql+psycopg://"))

lapisan = ["petak", "kolam", "kandang", "sungai", "dusun"]
gpkg = tmp / "usaha.gpkg"
gpkg.unlink(missing_ok=True)
for nama in lapisan:
    gdf = gpd.read_file(f"data/{nama}.geojson")
    gdf.to_postgis(f"usaha_{nama}", mesin, if_exists="replace", index=False)       # server
    gdf.to_file(gpkg, layer=nama, driver="GPKG")                                    # berkas portabel

print("lapisan  PostGIS  GeoPackage")
for nama in lapisan:
    a = len(gpd.read_postgis(f"SELECT * FROM usaha_{nama}", mesin, geom_col="geometry"))
    b = len(gpd.read_file(gpkg, layer=nama))
    print(f"{nama:8s} {a:7d} {b:11d}")

# Luas per sektor: dihitung di server PostGIS dan di Python dari GeoPackage; harus sama
dari_server = pd.read_sql(
    "SELECT sektor, round((sum(ST_Area(geometry::geography)) / 10000)::numeric, 1) AS ha "
    "FROM usaha_petak GROUP BY sektor ORDER BY sektor", mesin)
print("\nluas per sektor (ha) dari PostGIS:")
print(dari_server.to_string(index=False))
petak = gpd.read_file(gpkg, layer="petak").to_crs(32748)
print("\nluas per sektor (ha) dari GeoPackage:")
print(petak.assign(ha=petak.area / 10_000).groupby("sektor")["ha"].sum().round(1).to_string())
lapisan  PostGIS  GeoPackage
petak          6           6
kolam          4           4
kandang        3           3
sungai         1           1
dusun          2           2

luas per sektor (ha) dari PostGIS:
sektor   ha
 hutan 15.9
 kebun  7.0
 sawah  5.2

luas per sektor (ha) dari GeoPackage:
sektor
hutan    15.9
kebun     7.0
sawah     5.2

Lima lapisan, jumlah baris sama di kedua tempat, dan luas per sektor cocok: hutan 15,9 ha, kebun 7,0 ha, sawah 5,2 ha (ketiganya dibulatkan satu desimal; sawah 5,25 menjadi 5,2 karena pembulatan Python). Tabel di PostGIS diberi awalan usaha_ supaya tidak bertabrakan dengan tabel skrip 03. Itu mengakhiri bagian peta dari proyek Seri I5: data lokasi sudah rapi dan tersimpan.

Latihan

  1. Di 01_peta_statis.py, tambah garis skala sederhana (sebuah garis 100 m dengan teks “100 m”) di pojok kanan bawah peta.
  2. Di 02_peta_folium.py, ganti tiles="OpenStreetMap" menjadi tiles="CartoDB positron" dan lihat perbedaan isi berkas.
  3. Di 04_postgis_query_spasial.py, tambah query yang menghitung jumlah ikan per dusun memakai ST_Within antara titik pusat kolam (ST_Centroid) dan poligon dusun. (Tabel dusun belum dimuat: tambahkan di skrip 03.)
  4. Tantangan: di 07_pyqgis_proses_dan_cetak.py, tambah algoritma native:intersection antara petak dan zona, lalu cetak luas potongan tiap petak, dan cocokkan dengan Bab 38.

Ringkasan Bab

  • Matplotlib untuk peta statis (jangan lupa set_aspect("equal")); folium untuk peta interaktif (HTML, urutan [lintang, bujur]).
  • PostGIS menambah tipe geometry, fungsi ST_*, dan indeks spasial pada PostgreSQL; kirim nilai lewat parameter, bukan tempelan teks.
  • ::geography menghitung di bumi bulat dalam meter; hasilnya dapat sedikit berbeda dari hitungan UTM.
  • PyQGIS berjalan di Python milik QGIS; skrip tanpa antarmuka butuh setPrefixPath, QgsApplication([], False), initQgis, dan exitQgis.
  • QGIS 4 memakai Qt6: tulis enum Qt lengkap dan pakai QMetaType.Type.
  • Penyimpanan ganda: PostGIS untuk bersama, GeoPackage untuk portabel.

Selanjutnya, di Bab 41: otomasi tugas dan web scraping.