Analisi tecnica dettagliata dello script Render_Multiscale_Cog_Proc.py
Sistema di riferimento EPSG:6708
Ciò che segue è il risultato di numerosi tentativi falliti e ripetuti fino a ottenere un risultato soddisfacente, realizzato grazie alla collaborazione determinante di Claude.ai (Sonnet 4.6). Trattandosi di codice Python non revisionato da "cuochi esperti", si consiglia di adottare ogni cautela prima di utilizzarlo in produzione.
Lo script Render_Multiscale_Cog_Proc.py risponde a un problema pratico tipico dei sistemi GIS su rete locale: un progetto QGIS composto da decine di layer vettoriali tematizzati e da raster di sfondo risulta lento da caricare quando i dati risiedono su un server di rete, perché ogni layer viene trasferito per intero prima di poter essere visualizzato.
La soluzione adottata è la pre-renderizzazione del progetto in un file raster unico strutturato come Cloud Optimized GeoTIFF (COG) con piramidi interne generate da render reali a scale diverse. A differenza delle piramidi standard — che sono semplici ricampionamenti (media dei pixel) del livello base — le overview di questo COG contengono immagini renderizzate direttamente da QGIS alla scala corretta, con i layer appropriati attivi e le data-defined override già valutate.
Il COG viene utilizzato come sfondo cartografico nell'intervallo 1:150.000–1:600.000. Al di sotto di 1:150.000 il progetto carica i layer originali ad alta risoluzione.
Il COG può essere considerato un tile server embedded nel file, senza server: GDAL lato client legge solo i blocchi 512×512 px necessari alla vista corrente attraverso la condivisione di rete esistente, senza scaricare l'intero file. Non richiede infrastruttura aggiuntiva rispetto a una normale condivisione SMB.
La pipeline si divide in due fasi distinte eseguite in sequenza:
Fase A — algoritmo Processing QGIS (rendering): eseguito dal pannello Processing di QGIS. Per ciascuno dei tre livelli di scala renderizza l'intera estensione geografica in un'unica immagine, poi la ritaglia in tile tramite QImage.copy(). Genera build_cog.bat e inject_ovr.py.
Fase B — GDAL (assemblaggio): eseguita in OSGeo4W Shell. Georiferisce i tile PNG, assembla VRT, costruisce i GeoTIFF intermedi e produce il COG finale con overview reali.
Algoritmo Processing QGIS
├─ Scala 1:150.000 → render intera estensione → 36 tile PNG
├─ Scala 1:300.000 → render intera estensione → 36 tile PNG
└─ Scala 1:600.000 → render intera estensione → 36 tile PNG
└─ genera: build_cog.bat + inject_ovr.py
OSGeo4W Shell: build_cog.bat
├─ [1] gdal_translate → 108 GeoTIFF georeferenziati
├─ [2] gdalbuildvrt → 3 VRT (uno per scala)
├─ [3] gdal_translate → 3 GeoTIFF compressi per scala
└─ [4] inject_ovr.py → COG finale con overview reali
La scelta di renderizzare l'intera estensione in un'unica immagine (anziché tile per tile) è architetturalmente fondamentale: il motore di etichettatura PAL lavora una sola volta sull'intera area, eliminando alla radice qualsiasi problema di etichette troncate, duplicate o disallineate ai confini tra tile — indipendentemente dalla forma delle etichette (dritte, curve, lunghe).
I parametri sono esposti nel dialogo del Processing e non richiedono alcuna modifica al codice. I valori di default coprono il caso tipico del progetto FVG.
| Parametro (dialogo) | Tipo | Default | Descrizione |
|---|---|---|---|
| Layer di riferimento estensione | Layer (opzionale) | — | La extent() di questo layer diventa l'area di rendering; se vuoto usa il campo manuale |
| Estensione (manuale) | Extent (opzionale) | — | Usata solo se nessun layer di riferimento è selezionato |
| DPI di rendering | Intero | 96 | Risoluzione del render; la risoluzione m/px è calcolata automaticamente da scala e DPI |
| Colonne / Righe griglia tile | Interi | 6 / 6 | Griglia di ritaglio: 6×6 = 36 tile per scala |
| Includi scala 1:150.000 | Checkbox | ✓ | Attiva il livello base COG |
| Includi scala 1:300.000 | Checkbox | ✓ | Attiva la overview 2× |
| Includi scala 1:600.000 | Checkbox | ✓ | Attiva la overview 4× |
| Margine scala-overview (%) | Double | 5.0 | Riduce la risoluzione effettiva per anticipare la soglia di passaggio GDAL tra overview |
| Algoritmo compressione COG | Enum | DEFLATE | DEFLATE (universale) o ZSTD (decompressione più veloce su LAN) |
| Livello di compressione | Intero | 9 | 1–9 per DEFLATE, 1–22 per ZSTD |
| Cartella di output | Cartella | C:\temp\cog_fvg | Dove vengono salvati tile PNG, .bat e .py intermedi |
La risoluzione in m/px non è più un parametro esplicito: viene calcolata automaticamente a runtime dalla scala nominale e dal DPI, secondo la relazione res_m = scala × 0,0254 / dpi. Questo garantisce che la scala effettivamente renderizzata coincida sempre con la scala nominale del livello, indipendentemente dal DPI scelto.
Il metodo QgsProject.instance().mapLayers() restituisce tutti i layer registrati nel progetto indipendentemente dalla loro visibilità. Usarlo direttamente include layer non voluti, ignora i layer disabilitati e non rispetta la struttura gerarchica dei gruppi.
La funzione get_layers_at_scale() percorre ricorsivamente il layer tree partendo dalla radice. Per ogni nodo viene verificata itemVisibilityChecked() che corrisponde esattamente alla spunta nel pannello Layer.
def get_layers_at_scale(scala):
layers = []
def _collect(node):
if not node.itemVisibilityChecked():
return
if isinstance(node, QgsLayerTreeLayer):
layer = node.layer()
if layer and layer.isSpatial():
if layer.hasScaleBasedVisibility():
if layer.isInScaleRange(scala):
layers.append(layer)
else:
layers.append(layer)
elif isinstance(node, QgsLayerTreeGroup):
for child in node.children():
_collect(child)
for child in root.children():
_collect(child)
return layers
| Metodo | Comportamento |
|---|---|
| isVisible() | Considera la visibilità ereditata dai nodi padre — non corrisponde alla spunta |
| itemVisibilityChecked() | Legge esattamente la spunta del singolo nodo nel pannello Layer — corretto |
| hasScaleBasedVisibility() | True se il layer ha almeno un limite di scala configurato |
| isInScaleRange(scala) | True se il valore è nell'intervallo min-max configurato nel layer |
Le data-defined override in QGIS usano la variabile @map_scale per modificare simbologia o etichettatura. Senza un contesto espressioni esplicito, @map_scale riceve NULL e le espressioni CASE eseguono sempre il ramo ELSE — producendo ad esempio etichette a dimensione fissa indipendentemente dalla scala.
def make_expr_context(settings):
ctx = QgsExpressionContext()
ctx.appendScope(QgsExpressionContextUtils.globalScope())
ctx.appendScope(QgsExpressionContextUtils.projectScope(
QgsProject.instance()))
ctx.appendScope(
QgsExpressionContextUtils.mapSettingsScope(settings))
return ctx
| Scope | Variabili fornite |
|---|---|
| globalScope() | Variabili globali QGIS (es. @qgis_version) |
| projectScope(project) | Variabili del progetto (es. @project_title) |
| mapSettingsScope(settings) | @map_scale, @map_extent, @map_crs, @map_rotation |
Il punto critico è che mapSettingsScope(settings) deve ricevere l'oggetto QgsMapSettings già completamente configurato — con extent, output size e DPI impostati — perché il calcolo di @map_scale dipende da questi tre valori.
Per ciascun livello di scala, l'intera estensione geografica viene renderizzata in un'unica QImage, poi ritagliata in 36 tile tramite QImage.copy() — una pura operazione di slicing dei pixel, senza alcun rendering aggiuntivo. Questa scelta architetturale è la conseguenza diretta dei problemi incontrati con il rendering per-tile (descritti nel capitolo 18): è la soluzione più semplice possibile ed elimina strutturalmente l'intera classe di problemi legati ai confini tra tile.
# Calcolo dimensioni del render completo
full_px_w = tile_px_w * tile_cols
full_px_h = tile_px_h * tile_rows
# Render unico sull'intera estensione
settings = QgsMapSettings()
settings.setLayers(layers)
settings.setExtent(QgsRectangle(EX_MIN, EY_MIN, EX_MAX, EY_MAX))
settings.setOutputSize(QSize(full_px_w, full_px_h))
settings.setOutputDpi(dpi)
job = QgsMapRendererSequentialJob(settings)
job.start()
job.waitForFinished()
full_img = job.renderedImage().copy()
del job ; gc.collect()
# Ritaglio in tile: nessun rendering, solo slicing dei pixel
for row in range(tile_rows):
for col in range(tile_cols):
tile_img = full_img.copy(col*tile_px_w, row*tile_px_h,
tile_px_w, tile_px_h)
tile_img.save(path)
Il rendering usa QgsMapRendererSequentialJob (anziché il CustomPainterJob usato nelle versioni iniziali) perché renderizza i layer uno alla volta nello stesso contesto, senza thread interni in parallelo. Questo previene conflitti sulle risorse condivise del motore Qt (cache glyph/font del labeling engine) che causavano blocchi della canvas e etichette assenti in alcuni livelli — problemi documentati nel capitolo 18. L'immagine risultante arriva direttamente da renderedImage() senza gestione manuale di QPainter.
| Flag | Effetto |
|---|---|
| Antialiasing = True | Smoothing dei bordi di poligoni e linee |
| DrawLabeling = True | Include le etichette nel rendering |
| UseAdvancedEffects = True | Abilita trasparenze, blend mode ed effetti di strato |
| UseRenderingOptimization = True | Ottimizza il rendering delle geometrie |
Per garantire che ogni livello di scala parta da uno stato pulito, job e full_img vengono esplicitamente distrutti al termine di ciascun livello tramite del + gc.collect(). Questo forza la distruzione immediata degli oggetti C++ sottostanti invece di affidarsi al garbage collector Python, che potrebbe ritardarla fino al livello successivo propagando stato residuo tra render.
Per ogni tile PNG viene generata una chiamata a gdal_translate con -a_srs per assegnare il SR senza riproiettare e -a_ullr per georeferenziare tramite le coordinate dell'angolo superiore sinistro e inferiore destro. I tile georeferenziati vengono assemblati in un VRT tramite gdalbuildvrt e convertiti in GeoTIFF compresso.
Invece di costruire le piramidi per ricampionamento, questo script inietta come overview i GeoTIFF renderizzati realmente alle scale 1:300.000 e 1:600.000. Il flusso è:
-
Apertura del file base (render_150k.tif) in modalità GA_Update
-
Chiamata a ds.BuildOverviews('AVERAGE', OVR_FACTORS) per creare le strutture overview vuote
-
Per ciascun livello, lettura dei dati con ReadAsArray(buf_xsize, buf_ysize) e scrittura con WriteArray()
-
Flush con ds.FlushCache() e conversione finale in COG con OVERVIEWS=FORCE_USE_EXISTING
| Parametro | Valore | Motivazione |
|---|---|---|
| COMPRESS | DEFLATE / ZSTD | DEFLATE: compatibilità universale. ZSTD: decompressione più veloce su rete LAN |
| PREDICTOR | 2 | Differenziazione orizzontale: riduce l'entropia dei dati Byte RGB |
| ZLEVEL / ZSTD_LEVEL | 9 | Massima compressione; la lettura è identica al livello 1 |
| BLOCKSIZE | 512 | Tile interni 512×512 px; GDAL legge solo i blocchi nella finestra corrente |
| NUM_THREADS | ALL_CPUS | Encoding parallelo su tutti i core disponibili |
| OVERVIEWS | FORCE_USE_EXISTING | Non ricalcolare le piramidi — usa quelle iniettate da inject_ovr.py |
| OVERVIEW_COMPRESS | uguale a COMPRESS | Senza questo parametro le overview restano non compresse |
| BIGTIFF | IF_NEEDED | Abilita automaticamente il formato BigTIFF se il file supera 4 GB |
Nota: ZSTD usa il parametro ZSTD_LEVEL (non LEVEL né ZLEVEL). La presenza di ZSTD_LEVEL nella lista opzioni di gdal_translate conferma che libzstd è linkato correttamente nella build OSGeo4W.
Se si modifica la simbologia di layer presenti solo a 1:300.000, è sufficiente rieseguire l'algoritmo Processing deselezionando i livelli 1:150.000 e 1:600.000. Lo script produce i nuovi tile PNG e rigenera render_300k.tif.
Riniettare significa sostituire il contenuto di un livello overview esistente con i dati di un nuovo GeoTIFF pre-renderizzato. inject_ovr.py apre il file base in GA_Update, chiama BuildOverviews per preparare le strutture, sovrascrive i dati con WriteArray e rigenera il COG finale con FORCE_USE_EXISTING. Il file base (render_150k.tif) non viene modificato nel contenuto, quindi il COG aggiornato mantiene invariato il livello 1:150.000.
Il file COG finale può essere caricato in QGIS come un normale GeoTIFF tramite percorso UNC di rete. GDAL gestisce automaticamente il fetch dei soli blocchi 512×512 px necessari alla vista corrente, riducendo il traffico di rete rispetto al caricamento dei layer originali.
Per la corretta integrazione nel progetto impostare la visibilità dipendente dalla scala sul layer COG:
-
Scala massima (più grande): 1:150.000
-
Scala minima (più piccola): 1:600.000
Nella versione originale dell'algoritmo, basata sul rendering di 36 tile separati per livello di scala, le etichette posizionate in prossimità del bordo di un tile venivano troncate di netto nell'immagine assemblata. Il motore di etichettatura ancora l'etichetta al punto della feature, ma il testo si estende fisicamente oltre il bounding box del tile.
Il problema era stato affrontato con un buffer perimetrale: ogni tile veniva renderizzato su un'area estesa di LABEL_BUFFER_M metri per lato, e l'immagine risultante veniva poi ritagliata alla dimensione esatta tramite QImage.copy(). La calibrazione del buffer dipendeva da corpo carattere, DPI e risoluzione in m/px — un parametro da aggiustare manualmente al variare del DPI.
Nell'architettura corrente (capitolo 6) il problema non esiste più per costruzione: non ci sono confini interni tra tile durante il rendering, perché il motore PAL lavora una sola volta sull'intera estensione. Il parametro LABEL_BUFFER_M è stato rimosso dal dialogo. La trattazione storica completa del meccanismo a buffer è documentata nel capitolo 18.
| Parametro | Valore |
|---|---|
| Griglia tile | 6×6 = 36 tile per scala |
| Livelli di scala | 3 (1:150.000, 1:300.000, 1:600.000) |
| Tile totali prodotti | 108 PNG |
| Risoluzione base | 37.5 m/px (1:150.000) |
| Sistema di riferimento | EPSG:6708 nativo, nessuna riproiezione |
| Dimensione file COG | 94 MB |
| Compressione | DEFLATE + PREDICTOR=2 + ZLEVEL=9 |
| Layer inclusi | 18 layer vettoriali e raster tematizzati |
QGIS 4.0, rilasciato il 6 marzo 2026, rappresenta la più importante migrazione tecnica dalla versione 3.0: il framework grafico passa da Qt 5 a Qt 6. Sul piano funzionale, la versione 4.0 consolida e amplia il supporto nativo al formato COG introducendo tre miglioramenti specifici.
QGIS 4.0 introduce un algoritmo dedicato nel pannello Processing — Crea Cloud Optimized GeoTIFF — che permette la creazione diretta di COG a partire da una cartella di raster di input, senza dover ricorrere a GDAL da riga di comando. L'algoritmo supporta la configurazione delle piramidi, del tipo di compressione e del blocksize direttamente dalla finestra di dialogo Processing.
Rispetto allo script illustrato in questo articolo, l'algoritmo nativo di QGIS 4.0 non gestisce il rendering multi-scala con overview pre-renderizzate: è adatto alla conversione di raster già esistenti, non alla produzione di COG da layer QGIS tematizzati. Le due soluzioni sono quindi complementari.
I dialoghi Esporta raster e Salva con nome ora includono un'opzione esplicita per il formato COG. In QGIS 3.x l'export COG era disponibile solo tramite il driver GDAL selezionando manualmente l'estensione .tif e specificando i parametri di creazione — una procedura non intuitiva. In QGIS 4.0 l'utente può selezionare COG come formato di destinazione con opzioni dedicate per piramidi e compressione.
In QGIS 3.x, quando si specificava il formato di output tramite nome file, il driver GTiff e il driver COG erano indistinguibili poiché entrambi usano l'estensione .tif/.tiff. Questo causava export in formato GTiff standard quando si voleva COG. QGIS 4.0 permette ora di specificare esplicitamente -of COG nelle operazioni Processing che accettano flag GDAL, eliminando questa ambiguità.
Per completezza: il supporto in lettura dei COG era già presente in QGIS dalla versione 3.2 tramite GDAL. Il driver COG di GDAL gestisce automaticamente la lettura a blocchi e il caching delle overview. Lo script presentato in questo articolo è quindi pienamente operativo su QGIS 3.x (testato su 3.40 LTR) e non richiede QGIS 4.0 per il rendering o per la generazione degli script GDAL.
La migrazione da Qt5 a Qt6 comporta alcune modifiche al codice Python. Il team QGIS fornisce un compatibility shim (qgis.PyQt) che permette di scrivere codice compatibile con entrambe le versioni tramite modifiche minime.
La modifica principale è sostituire gli import diretti da PyQt5 con il proxy qgis.PyQt, che reindirizza automaticamente verso PyQt5 o PyQt6 a seconda dell'ambiente QGIS:
# Compatibile Qt5 (QGIS 3.x) e Qt6 (QGIS 4.x)
from qgis.PyQt.QtCore import QSize, QCoreApplication, QT_VERSION_STR
# Rilevamento versione per la logica condizionale degli enum
IS_QT6 = int(QT_VERSION_STR.split('.')[0]) >= 6
Nell'algoritmo corrente QImage e QPainter non sono più importati direttamente: il rendering usa QgsMapRendererSequentialJob che restituisce l'immagine tramite renderedImage() senza richiedere la gestione manuale di un QPainter.
Qt6 richiede che gli enum siano completamente qualificati. Il pattern IS_QT6 risolve il problema in un'unica riga per tutti i flag di QgsMapSettings:
_FLAGS = QgsMapSettings.Flag if IS_QT6 else QgsMapSettings
settings.setFlag(_FLAGS.Antialiasing, True)
settings.setFlag(_FLAGS.DrawLabeling, True)
settings.setFlag(_FLAGS.UseAdvancedEffects, True)
settings.setFlag(_FLAGS.UseRenderingOptimization, True)
| Componente | QGIS 3.40 LTR | QGIS 4.0 | Azione richiesta |
|---|---|---|---|
| Import QtCore | from PyQt5.QtCore | from qgis.PyQt.QtCore | Usare qgis.PyQt ovunque |
| QgsMapSettings flags | non qualificato | QgsMapSettings.Flag.* | Usare _FLAGS = ... if IS_QT6 |
| qgis.core API | invariata | invariata (deprecate 2.x) | Nessuna modifica |
| osgeo / GDAL | invariata | invariata | Nessuna modifica |
| Algoritmo Processing | compatibile | compatibile | Nessuna modifica |
| Parametro | Valore |
|---|---|
| Render per livello di scala | 1 (intera estensione, poi ritagliata in tile) |
| Griglia tile | 6×6 = 36 tile per scala (ritaglio da render unico) |
| Livelli di scala | 3 (1:150.000, 1:300.000, 1:600.000) |
| Tile totali prodotti | 108 PNG |
| Sistema di riferimento | EPSG:6708 nativo — nessuna distorsione di etichette |
| Dimensione file COG | 94 MB |
| Compressione | DEFLATE/ZSTD + PREDICTOR=2, LEVEL=9 (driver COG) |
| Layer inclusi | 18 layer vettoriali e raster tematizzati |
| Motore di rendering | QgsMapRendererSequentialJob + FlagNoThreading |
| Compatibilità | QGIS 3.40 LTR e QGIS 4.0 senza modifiche al codice |
L'algoritmo compare nel pannello Processing di QGIS sotto Scripts → GISDIS FVG → Render Multi-Scala → COG. Può essere richiamato come qualsiasi strumento nativo, incluso l'utilizzo in modelli grafici (Graphical Modeler) e in batch tramite qgis_process.
Una QgsProcessingAlgorithm richiede l'implementazione di un set minimo di metodi obbligatori, oltre alla logica applicativa:
| Metodo | Ruolo |
|---|---|
| name() / displayName() | Identificatore interno e nome visualizzato nel pannello Processing |
| group() / groupId() | Categoria di raggruppamento (es. "GISDIS FVG") |
| flags() | FlagNoThreading: forza esecuzione sul thread GUI — necessario per rendering Qt |
| shortHelpString() | Testo HTML mostrato nel pannello di aiuto del dialogo |
| initAlgorithm() | Definisce i parametri che generano i controlli del dialogo |
| processAlgorithm() | Contiene la logica di rendering, ritaglio e generazione script GDAL |
| Classe parametro | Widget generato | Uso corrente |
|---|---|---|
| QgsProcessingParameterFolderDestination | Selettore cartella | Cartella di output per tile PNG e script GDAL |
| QgsProcessingParameterMapLayer (opz.) | Selettore layer | Layer di riferimento per l'estensione — la sua extent() diventa l'area di rendering |
| QgsProcessingParameterExtent (opz.) | Selettore su mappa | Estensione manuale, usata solo se nessun layer di riferimento è selezionato |
| QgsProcessingParameterNumber (DPI) | Spinbox intero | DPI di rendering; res_m calcolata automaticamente da scala e DPI |
| QgsProcessingParameterNumber (griglia) | Spinbox intero | Colonne e righe della griglia di ritaglio (default 6×6) |
| QgsProcessingParameterBoolean (×3) | Checkbox | Inclusione dei livelli 1:150k, 1:300k, 1:600k |
| QgsProcessingParameterNumber (margine) | Spinbox double | Margine scala-overview in % per anticipare soglia di passaggio GDAL |
| QgsProcessingParameterEnum | Menu a tendina | Algoritmo di compressione COG: DEFLATE o ZSTD |
| QgsProcessingParameterNumber (livello) | Spinbox intero | Livello di compressione (1–9 per DEFLATE, 1–22 per ZSTD) |
L'estensione da layer di riferimento (QgsProcessingParameterMapLayer) sostituisce i meccanismi precedenti basati sull'impostazione della scala della canvas — che interferivano con il job di rendering interno di QGIS bloccandone la risposta ai comandi. layer.extent() non tocca in alcun modo la canvas, eliminando questa classe di problemi.
Per default QGIS esegue gli algoritmi Processing su un thread di lavoro separato. Questo script usa QgsMapRendererSequentialJob che interagisce con il motore di rendering Qt (cache glyph/font, paint engine): eseguirlo in background può corrompere risorse condivise con il thread GUI. FlagNoThreading forza l'esecuzione sincrona sul thread principale:
def flags(self):
return super().flags() | QgsProcessingAlgorithm.FlagNoThreading
L'algoritmo produce un log dettagliato per ciascun livello di scala, utile per diagnosticare problemi di configurazione senza dover aprire le proprietà di ogni layer:
-
Layer visibili a questa scala (nome e presenza di limiti scala-etichette)
-
[label-scale] — range di visibilità delle etichette per ciascun layer vettoriale; segnala se il range esclude la scala corrente
-
[label-size] — dimensione font calcolata per le espressioni data-defined basate su @map_scale; segnala size=0 che renderebbe l'etichetta invisibile
-
Scala QGIS effettiva e @map_scale — conferma che la scala nominale e quella renderizzata coincidono
Processing → Opzioni → Impostazioni generali
→ Cartelle script aggiuntivi → [aggiungi cartella]
oppure copiare in:
%APPDATA%\QGIS\QGIS3\profiles\default\processing\scripts\
Poi: Processing → Strumenti di Processing → Scripts
→ tasto destro → "Carica script dal file"
Durante l'esecuzione di build_cog.bat compariva l'avviso 'ZLEVEL creation option not supported'. La causa è un'incoerenza tra driver GDAL distinti: il driver GTiff utilizza ZLEVEL per DEFLATE e ZSTD_LEVEL per ZSTD, mentre il driver COG utilizza un parametro unificato — LEVEL — valido per tutti gli algoritmi di compressione.
| Driver target | Parametro DEFLATE | Parametro ZSTD |
|---|---|---|
| GTiff (file intermedi render_XXXk.tif) | ZLEVEL | ZSTD_LEVEL |
| COG (file finale — inject_ovr.py) | LEVEL | LEVEL |
La correzione sostituisce in inject_ovr.py il parametro condizionale con il valore unificato:
# PRIMA — genera warning sul driver COG
creationOptions = [f"{comp_level_param}={comp_level}", ...]
# DOPO — corretto per il driver COG
creationOptions = ["LEVEL={comp_level}", ...]
I capitoli precedenti documentano l'architettura originaria, basata su 36 render per livello di scala (uno per tile) con buffer perimetrale per le etichette dritte e un doppio passaggio per le etichette curve. Questa architettura, messa alla prova nell'uso quotidiano, ha rivelato due criticità strutturali che ne hanno richiesto una revisione radicale, descritta in questo capitolo.
Il primo problema riguardava QgsMapRendererCustomPainterJob, utilizzato per ciascuno dei 108 render (36 tile × 3 scale). Questa classe avvia internamente thread di lavoro per il rendering dei singoli layer — un comportamento ereditato dal motore di rendering della canvas stessa. Eseguire 108 job in sequenza senza una distruzione esplicita degli oggetti C++ sottostanti lasciava risorse del motore di rendering Qt (cache glyph/font, paint engine) in uno stato non completamente smaltito tra un job e il successivo. Il sintomo osservato era un blocco della canvas di QGIS al termine dello script — zoom e F5 smettevano di rispondere — che sopravviveva persino alla chiusura del progetto, segno che la corruzione avveniva a livello di processo e non di singolo progetto QGIS.
Il secondo problema riguardava il meccanismo a doppio passaggio per le etichette curve, basato su QgsNullSymbolRenderer per isolare temporaneamente le sole etichette in un render globale separato. In alcuni casi questo produceva tile completamente vuote a eccezione delle etichette — sintomo di un'interazione imprevista tra la sostituzione del renderer e il ciclo di vita degli oggetti layer durante un rendering asincrono.
La soluzione adottata elimina alla radice entrambi i problemi: invece di 36 render per livello con buffer e doppio passaggio, l'intera estensione geografica viene renderizzata in un'unica immagine per ciascuno dei tre livelli di scala, e solo successivamente questa immagine viene ritagliata in tile tramite QImage.copy() — una pura operazione di slicing dei pixel, senza alcun rendering aggiuntivo.
# Render dell'intera estensione (una sola volta per livello)
settings = QgsMapSettings()
settings.setLayers(layers)
settings.setExtent(QgsRectangle(EX_MIN, EY_MIN, EX_MAX, EY_MAX))
settings.setOutputSize(QSize(full_px_w, full_px_h))
# ... rendering (vedi 18.3) ...
# Ritaglio in tile: nessun rendering aggiuntivo
for row in range(tile_rows):
for col in range(tile_cols):
px_x, px_y = col * tile_px_w, row * tile_px_h
tile_img = full_img.copy(px_x, px_y, tile_px_w, tile_px_h)
tile_img.save(path)
Questa architettura rende strutturalmente impossibile sia il troncamento sia la duplicazione delle etichette ai bordi tile, qualunque sia la loro forma (dritte, curve, lunghe): il motore PAL (Placement Algorithm) lavora una sola volta sull'intera estensione, e il ritaglio successivo non altera in alcun modo il testo già posizionato — taglia semplicemente i pixel, non rielabora il layout.
Il render dell'estensione unica utilizza QgsMapRendererSequentialJob anziché QgsMapRendererCustomPainterJob. La differenza sostanziale è che i layer vengono renderizzati uno alla volta nello stesso contesto, anziché in parallelo su thread separati per layer — eliminando la contesa tra thread sulle risorse condivise del motore di rendering che causava il blocco della canvas. L'API è anche più semplice: l'immagine arriva direttamente da renderedImage(), senza gestione manuale di QPainter.
job = QgsMapRendererSequentialJob(settings)
job.start()
job.waitForFinished()
errors = job.errors()
if errors:
for err in errors:
feedback.pushWarning(f'Errore layer "{err.layerId}": {err.message}')
full_img = job.renderedImage().copy() # copia indipendente dal job
Il controllo job.errors() è una diagnostica aggiuntiva introdotta in questa fase: espone nel log eventuali errori di rendering per singolo layer, utile per individuare problemi di dati o di stile senza dover ispezionare manualmente ogni layer.
Per default QGIS esegue gli algoritmi Processing su un thread di lavoro separato dal thread GUI principale. Il metodo flags() viene sovrascritto per dichiarare esplicitamente FlagNoThreading, forzando l'esecuzione sincrona sul thread GUI — comportamento corretto per algoritmi che eseguono rendering diretto:
def flags(self):
return super().flags() | QgsProcessingAlgorithm.FlagNoThreading
Va precisato che questo intervento, da solo, non era sufficiente a risolvere il blocco della canvas: il problema reale riguardava i thread interni spawnati da CustomPainterJob per il rendering dei singoli layer, non il thread su cui gira processAlgorithm(). La combinazione di FlagNoThreading con QgsMapRendererSequentialJob (18.3) è la soluzione completa: il primo garantisce un contesto di esecuzione prevedibile, il secondo elimina la causa di fondo del conflitto tra thread.
Per garantire che ogni livello di scala parta da uno stato pulito, gli oggetti di rendering vengono esplicitamente distrutti al termine di ciascun livello, invece di affidarsi al solo garbage collector automatico di Python — che potrebbe ritardare la distruzione degli oggetti C++ sottostanti fino a dopo l'avvio del livello successivo:
del job
gc.collect()
# ... rendering tile ...
del full_img, settings
gc.collect()
Con l'architettura a estensione unica, due parametri della versione precedente sono stati rimossi perché il problema che cercavano di risolvere non può più presentarsi: LABEL_BUFFER_PX (il buffer perimetrale per evitare il troncamento di etichette ai bordi tile) e GLOBAL_LABEL_LAYERS (la selezione dei layer da trattare con il render globale separato per le etichette curve). Non esistono più confini interni tra render: il motore PAL lavora sempre sull'intera estensione, quindi non c'è nulla da bufferizzare né da isolare in un passaggio separato.
Una revisione indipendente ha riguardato il modo in cui lo script determina l'area geografica da renderizzare, sostituendo l'interazione diretta con la canvas di QGIS con un meccanismo che non dipende in alcun modo dall'interfaccia grafica.
Una versione intermedia dello script impostava automaticamente la scala della canvas a 1:600.000 tramite canvas.zoomScale() e leggeva l'estensione risultante, per evitare all'utente la selezione manuale delle coordinate. Sebbene la causa principale del blocco della canvas si sia poi rivelata essere CustomPainterJob (capitolo 18.1), l'approccio basato su zoomScale() restava comunque fragile per altri motivi: richiede l'oggetto iface, non disponibile in esecuzione headless (ad esempio tramite qgis_process da riga di comando), e introduce una dipendenza dall'interfaccia grafica non necessaria per un'operazione concettualmente semplice come la determinazione di un'estensione geografica.
Il meccanismo adottato sostituisce interamente l'interazione con la canvas con un parametro QgsProcessingParameterMapLayer: l'utente seleziona un layer del progetto (tipicamente un confine amministrativo o una maschera dedicata), e la sua extent() diventa l'area di rendering:
self.addParameter(QgsProcessingParameterMapLayer(
self.EXTENT_LAYER,
'Layer di riferimento per l\'estensione (opzionale)',
optional=True
))
extent_layer = self.parameterAsLayer(
parameters, self.EXTENT_LAYER, context)
if extent_layer is not None:
ext = extent_layer.extent()
ext_crs = extent_layer.crs()
else:
# ricade sul parametro Estensione manuale
ext = self.parameterAsExtent(parameters, self.EXTENT, context)
ext_crs = self.parameterAsExtentCrs(parameters, self.EXTENT, context)
Questo elimina ogni dipendenza da iface e da canvas, rendendo lo script eseguibile identicamente da dialogo interattivo, da modello del Graphical Modeler o da riga di comando headless.
Durante la messa a punto è emerso che alcune etichette risultavano assenti a scale specifiche pur essendo i layer correttamente inclusi nel rendering. La diagnosi ha richiesto di distinguere due meccanismi di QGIS concettualmente separati, entrambi basati sulla scala ma indipendenti l'uno dall'altro.
Il primo meccanismo è la visibilità del layer per scala (hasScaleBasedVisibility / isInScaleRange), già gestito da get_layers_at_scale fin dalle prime versioni dello script: se il layer è fuori range, l'intero layer — simbologia ed etichette insieme — viene escluso dal rendering.
Il secondo meccanismo, distinto e indipendente, è la scala-dipendenza configurata dentro le proprietà di etichettatura del layer (scheda Rendering del pannello Etichette in QGIS): un intervallo di scala che limita la sola visibilità delle etichette, lasciando la simbologia del layer sempre presente. Se questo intervallo esclude la scala corrente, il layer appare regolarmente sulla mappa ma privo di testo — esattamente il sintomo osservato, e indistinguibile a prima vista da altre possibili cause.
Questa funzione individua, per ciascun layer e per ciascuna regola di un'eventuale etichettatura basata su regole (QgsRuleBasedLabeling, attraversata ricorsivamente), se la scala-dipendenza delle etichette esclude la scala corrente; in tal caso la disattiva temporaneamente per la durata del render, ripristinandola subito dopo in un blocco finally:
def relax_label_scale_visibility(layers_list, feedback, scala):
saved = []
for layer in layers_list:
if not isinstance(layer, QgsVectorLayer):
continue # i layer raster non hanno etichettatura
labeling = layer.labeling()
if labeling is None:
continue
for rule, settings in _iter_label_settings(labeling):
if not settings.scaleVisibility:
continue
smin, smax = settings.minimumScale, settings.maximumScale
in_range = not ((smin and scala > smin) or
(smax and scala < smax))
feedback.pushInfo(f'[label-scale] {layer.name()}:
range 1:{smax:.0f}-1:{smin:.0f}
{"dentro" if in_range else "FUORI"} a 1:{scala:,}')
if not in_range:
settings.scaleVisibility = False
# ... applica e registra per il ripristino ...
Un bug iniziale in questa funzione tentava di chiamare layer.labeling() indiscriminatamente su tutti i layer, inclusi i raster (ad esempio il DEM del progetto), che non possiedono questo metodo — causando un AttributeError che interrompeva l'esecuzione. La correzione, riportata nel codice sopra, antepone un controllo isinstance(layer, QgsVectorLayer).
Oltre alla scala-dipendenza on/off, le etichette possono avere una dimensione del font controllata da un'espressione data-defined basata su @map_scale, capace di restituire 0 — etichetta invisibile — senza che alcun flag lo segnali. Questa funzione valuta l'espressione usando il contesto del render corrente, riportando il valore calcolato:
dd = settings_obj.dataDefinedProperties()
prop = dd.property(_PAL_PROPS.Size)
if prop is not None and prop.isActive():
val, ok = prop.value(expr_context, None)
Una prima versione di questa diagnostica ha prodotto un risultato fuorviante per il layer places: l'espressione di quel layer dipende dal campo population, non da @map_scale, e valutandola senza una feature concreta nel contesto il campo risultava NULL, facendo collassare la CASE al valore di default impostato nella chiamata — letto erroneamente come "size calcolata 0". La correzione individua preventivamente, tramite QgsExpression(expr_str).referencedColumns(), se l'espressione dipende da campi della feature: in tal caso segnala esplicitamente che il valore non è valutabile in modo affidabile senza una feature concreta, invece di presentare un numero potenzialmente errato.
L'ispezione diretta del file QML di stile del layer places ha permesso di esaminare la configurazione XML sottostante e confermare che non era la causa del problema osservato. Le proprietà rilevanti—"scaleVisibility=1" con range 1:50.000–1:1.000.100 per le etichette, e un'espressione di dimensione font basata sul campo population con valore minimo 8 (mai zero)—mostravano che il layer avrebbe dovuto mostrare le etichette correttamente a tutte e tre le scale target. Questa analisi ha permesso di escludere places e indirizzare la diagnosi verso altri layer e, infine, verso la causa radice descritta nel capitolo seguente.
Le correzioni dei capitoli 18-20, pur necessarie e corrette, non erano sufficienti a spiegare un sintomo specifico: le etichette di alcuni layer risultavano assenti unicamente al livello 1:600.000, pur essendo visibili sia a 1:150.000 sia a 1:300.000, e pur essendo visibili anche nella canvas interattiva di QGIS quando impostata manualmente a quella stessa scala.
L'indagine ha esaminato il log di rendering del livello "600k", individuando una riga rivelatrice:
Scala QGIS: 1:883896 | @map_scale=883895.9854014597
Il livello nominalmente "1:600.000" stava in realtà renderizzando a scala 1:883.896 — uno scarto del 47%. Questo spiega l'intera classe di sintomi osservati: i limiti di scala-etichette e le espressioni @map_scale-based nel progetto sono tarati su soglie nominali rotonde, ma la scala realmente utilizzata durante il render era ben oltre quelle soglie, mentre nella canvas interattiva la scala mostrata è sempre quella effettiva e corretta.
La causa risiedeva nei parametri RES_150K, RES_300K e RES_600K (risoluzione in m/px), valori fissi calibrati per 96 DPI nella progettazione iniziale dello script. La relazione corretta fra scala, risoluzione e DPI è:
scala = res_m × dpi / 0,0254
Se il DPI impostato nel dialogo differisce da 96 — nel caso diagnosticato, 150 — la stessa risoluzione fissa in m/px corrisponde a una scala diversa. Con res_m=150 e dpi=150: scala = 150 × 150 / 0,0254 ≈ 885.827, coerente con l'884k osservato a meno di arrotondamenti sui pixel del tile.
La correzione elimina la possibilità stessa dell'errore: invece di un valore di risoluzione fisso e indipendente dal DPI, res_m viene calcolato a runtime dalla scala nominale e dal DPI corrente, garantendo che la scala effettivamente renderizzata coincida sempre esattamente con quella nominale:
M_PER_INCH = 0.0254
for include_key, scala, tag, ovr in [
(self.INCLUDE_150K, 150_000, '150k', 1),
(self.INCLUDE_300K, 300_000, '300k', 2),
(self.INCLUDE_600K, 600_000, '600k', 4),
]:
if self.parameterAsBool(parameters, include_key, context):
res_m = scala * self.M_PER_INCH / dpi
scale_levels.append({'scala': scala, 'res_m': res_m, ...})
Conseguenza diretta del fix è la rimozione dei tre parametri numerici di risoluzione (RES_150K, RES_300K, RES_600K) dal dialogo Processing: non essendoci più alcun valore da calibrare manualmente, restano solo le tre caselle di spunta per includere o escludere ciascun livello di scala. Questo elimina strutturalmente un'intera classe di errori di configurazione, oltre a semplificare l'interfaccia.
Una volta corretta la scala effettiva di ciascun livello, è emerso un ultimo dettaglio legato non al contenuto dei render ma a come GDAL e QGIS decidono quale overview del COG mostrare durante la navigazione interattiva.
Quando QGIS visualizza un raster COG, per ogni livello di zoom calcola la risoluzione richiesta dalla vista corrente e seleziona l'overview la cui risoluzione è la più vicina senza essere più grossolana del necessario. Questo meccanismo di selezione non scatta esattamente alla scala nominale di un overview, ma solo dopo che la vista la supera di un margine di tolleranza intrinseco all'algoritmo — nel caso osservato, un passaggio dall'overview 1:300.000 a quello 1:600.000 avveniva navigando fino a circa 1:630.000, anziché esattamente a 1:600.000.
È importante sottolineare che questo non è un difetto della pipeline di rendering: il contenuto del livello "600k" è correttamente renderizzato alla scala 1:600.000 esatta (capitolo 21); il margine riguarda esclusivamente la soglia con cui il visualizzatore decide quando mostrare quel contenuto rispetto al precedente.
Per compensare questo margine intrinseco, è stato introdotto un parametro che riduce leggermente la sola risoluzione effettiva di ciascun livello — non la scala nominale usata per etichettatura e diagnostica — spostando la soglia di passaggio del visualizzatore più vicino alla scala nominale desiderata:
margin_factor = 1.0 - (margin_pct / 100.0) # default: 5%
res_m = scala * margin_factor * self.M_PER_INCH / dpi
La distinzione fra scala nominale (usata da get_layers_at_scale, relax_label_scale_visibility, diagnose_label_size_expression e dai nomi dei file) e risoluzione effettiva (su cui agisce il margine) è intenzionale: tutte le decisioni di visibilità ed etichettatura restano ancorate al valore nominale rotondo (150.000/300.000/600.000), mentre solo il dettaglio fisico del render viene leggermente affinato. La conseguenza secondaria — la scala vera riportata nel log per il render sarà lievemente inferiore al nominale, ad esempio 1:570.000 anziché 1:600.000 con un margine del 5% — rientra ampiamente nei margini di tolleranza già osservati nelle soglie di scala-etichette del progetto (dell'ordine di centinaia di migliaia), e non ne pregiudica la corretta valutazione. Impostando il parametro a 0 il comportamento torna identico a un calcolo senza margine.
Il percorso di messa a punto descritto nei capitoli 18-22 ha portato l'algoritmo a un'architettura sostanzialmente più semplice e robusta di quella originaria: un render per livello di scala anziché trentasei, nessun buffer perimetrale, nessun meccanismo a doppio passaggio, nessuna dipendenza dalla canvas di QGIS, e una relazione matematicamente esatta fra scala nominale, risoluzione e DPI.
| Parametro | Valore attuale |
|---|---|
| Render per livello di scala | 1 (intera estensione, poi ritagliata in tile) |
| Livelli di scala | 3, selezionabili indipendentemente (1:150k, 1:300k, 1:600k) |
| Motore di rendering | QgsMapRendererSequentialJob + FlagNoThreading |
| Estensione | da layer di riferimento (QgsProcessingParameterMapLayer) o manuale |
| Risoluzione per livello | calcolata da scala nominale e DPI (mai un valore fisso) |
| Margine overview | percentuale configurabile, default 5%, disattivabile a 0 |
| Buffer etichette ai bordi | non più necessario — nessun confine interno tra render |
| Gestione etichette curve | non più necessaria — un solo render per l'intera estensione |
| Diagnostica scala-etichette | relax_label_scale_visibility + diagnose_label_size_expression |
| Compatibilità | QGIS 3.40 LTR e QGIS 4.0 (Qt5/Qt6) senza modifiche al codice |
Il file COG risultante, generato con questa architettura, mantiene tutte le caratteristiche di ottimizzazione descritte nei capitoli iniziali — compressione DEFLATE/ZSTD, piramidi reali costruite da render effettivi anziché da semplice ricampionamento, dimensione contenuta sotto i 100 MB per l'intera area del Friuli-Venezia Giulia — risolvendo al contempo, in modo strutturale anziché correttivo, tutte le classi di problemi emerse nell'uso quotidiano: blocco della canvas, etichette troncate o duplicate ai confini, etichette assenti per scarto fra scala nominale ed effettiva.
-
QGIS 3.40 LTR o QGIS 4.0, Python 3.x integrato
-
qgis.core: QgsMapRendererSequentialJob, QgsMapSettings, QgsProject, QgsRectangle, QgsLayerTreeLayer, QgsLayerTreeGroup, QgsExpressionContext, QgsExpressionContextUtils, QgsVectorLayer, QgsRuleBasedLabeling, QgsPalLayerSettings, QgsExpression, QgsCoordinateTransform
-
qgis.core (Processing): QgsProcessing, QgsProcessingAlgorithm, QgsProcessingParameterMapLayer, QgsProcessingParameterExtent, QgsProcessingParameterFolderDestination, QgsProcessingParameterNumber, QgsProcessingParameterBoolean, QgsProcessingParameterEnum, QgsProcessingException
-
qgis.PyQt.QtCore: QSize, QCoreApplication, QT_VERSION_STR (compatibile Qt5/Qt6 in QGIS 4.0)
-
GDAL 3.x in OSGeo4W Shell: gdal_translate, gdalbuildvrt, gdal.Open, gdal.Translate
-
Python standard: os, math, gc, statistics (benchmark), time (benchmark)
[1] Open Geospatial Consortium (2023). OGC Cloud Optimized GeoTIFF Standard, Version 1.0. OGC Document 21-026. Disponibile: https://docs.ogc.org/is/21-026/21-026.html
[2] cogeotiff/cog-spec (2023). Cloud Optimized GeoTIFF Specification. GitHub Repository. Disponibile: https://github.com/cogeotiff/cog-spec
[3] GDAL Development Team (2024). COG — Cloud Optimized GeoTIFF Generator. GDAL Documentation. Disponibile: https://gdal.org/drivers/raster/cog.html
[4] QGIS Development Team (2026). Changelog for QGIS 4.0 — Norrköping. Rilascio: 6 marzo 2026. Disponibile: https://changelog.qgis.org/en/version/4.0/
[5] QGIS Development Team (2025). Plugin migration to be compatible with Qt5 and Qt6. QGIS Wiki. Disponibile: https://github.com/qgis/QGIS/wiki/Plugin-migration-to-be-compatible-with-Qt5-and-Qt6
[6] QGIS Plugin Repository (2026). Migrate Your Plugin to QGIS 4. Disponibile: https://plugins.qgis.org/docs/migrate-qgis4
[7] Alberti, K. (2021). GeoTIFF Compression Optimization Guide. Kokoalberti.com. Disponibile: https://kokoalberti.com/articles/geotiff-compression-optimization-guide/
[8] Cloud-Native Geo Community (2024). Cloud-Optimized GeoTIFFs — Cloud-Optimized Geospatial Formats Guide. Disponibile: https://guide.cloudnativegeo.org/cloud-optimized-geotiffs/intro.html
[9] cogeo.org (2024). Cloud Optimized GeoTIFF — Ecosystem and Tools Overview. Disponibile: https://cogeo.org/
[10] Friess, M. et al. (2023). COMTiles: A Case Study of a Cloud Optimized Tile Archive Format. ISPRS Archives of the Photogrammetry, Remote Sensing and Spatial Information Sciences, Volume XLVIII-4/W7-2023. FOSS4G 2023, Prizren. DOI: 10.5194/isprs-archives-XLVIII-4-W7-2023
[11] Milani, E. et al. (2024). A computational framework for processing time-series of earth observation data based on discrete convolution: global-scale historical Landsat cloud-free aggregates at 30 m spatial resolution. PLOS ONE / PMC. Disponibile: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC11624844/
[12] Kowalski, D. et al. (2025). Optimizing Cloud-to-GPU Throughput for Deep Learning With Earth Observation Data. arXiv. Disponibile: https://arxiv.org/abs/2506.06235