Skip to content

ADD georeferenced GeoTIFF export via to_raster() - #55

Merged
mhdned merged 1 commit into
feat/svm-non-interactive-trainingfrom
feat/georeferenced-raster-export
Aug 21, 2026
Merged

ADD georeferenced GeoTIFF export via to_raster()#55
mhdned merged 1 commit into
feat/svm-non-interactive-trainingfrom
feat/georeferenced-raster-export

Conversation

@mkiani12

Copy link
Copy Markdown
Collaborator

Stacked on #54 — review #50#54 first; this PR targets feat/svm-non-interactive-training.

Problem

With the exception of MosaicCalculator, no tool could write a georeferenced raster. BaseTool._export_file() renders through matplotlib and saves a PNG, so the only documented output of every workflow was a picture of the computed array rather than the array. process() returned the data but was not documented as the way to obtain results.

Three separate losses at export:

  1. Values quantized through a colormap to 256 levels per channel. A pixel that was 0.6237 cannot be recovered, so thresholding, zonal statistics over mapped units and multitemporal differencing are all off the table.
  2. Pixel grid destroyed. dpi=1000 with figsize and bbox_inches="tight" resamples the array into a figure canvas with margins, colorbar and watermark baked in — a 998×998 NDVI became a 9389×8035 RGBA canvas with no pixel correspondence to the input.
  3. All spatial referencing gone. No CRS, no transform, so the result cannot be overlaid in a GIS.

Changes

FileHandler.get_raster_profile() reads CRS, transform, nodata and dtype through rasterio — already a declared dependency, previously used only by the mosaic tool. The existing array-loading path is untouched, so this is metadata capture alongside what is already there and no current tool changes behaviour.

BaseTool.to_raster(output_path) writes the computed array as a GeoTIFF carrying the source band's spatial referencing. Multi-component outputs such as PCA's (6, height, width) become multi-band rasters.

calculator = NDVICalculator(nir_path="nir.tif", red_path="red.tif")
calculator.process()
calculator.to_raster("./exports/ndvi.tif")

Defaults chosen for scene-scale raster products: float32 for continuous results — well beyond reflectance precision and half the size of float64 — and int32 for integer label maps such as classifications, since writing a classification as float with NaN nodata is simply wrong. nodata=NaN for floating point, tiled=True, compress="deflate" with predictor=3 for float data, and BIGTIFF="IF_SAFER". All overridable.

A source without a CRS raises rather than silently writing an identity transform, which would produce a file that looks georeferenced while placing the scene at the coordinate origin — worse than refusing.

PNG export stays the default. execute() is unchanged.

Verified against the bundled Landsat subset

CRS                      : EPSG:32639          (identical to source)
transform                : (30.0, 0.0, 316725.0, 0.0, -30.0, 4176795.0)   (identical to source)
dtype / nodata           : float32 / nan
max |written - computed| : 0.0
distinct values written  : 517,535             (PNG path allowed 256 per channel)
size on disk             : 3.1 MB

PCA writes 6 bands at 998×998, each round-tripping exactly.

On the framing question

The issue notes that if PNG-only output were a deliberate design decision, that would be a legitimate choice for a visualisation-oriented package but should be stated plainly, since "Geospatial Multispectral Image Processing" and "processed products suitable for inspection, reporting, and further analysis" lead a reader to expect georeferenced data products.

I went the other way and added the raster path, since the package's own framing promises it. Both outputs are now documented side by side with an explicit table of what each preserves, and paper.md's description of the package's outputs is corrected to describe both rather than implying figures are data products.

Not included

The full rasterio input rewrite — multi-band stacked GeoTIFF input, and nodata masking on read — is out of scope here. That would touch every calculator's array-loading path, and this PR is deliberately additive so nothing currently working changes. Worth its own issue; the get_raster_profile() groundwork makes it straightforward. Note that until then, a source-declared nodata fill is still treated as valid data on read.

Tests

421 pass (406 on #54, 15 new). tests/raster_export_test.py covers CRS and transform propagation, exact value round-trip, unresampled pixel grid, dtype selection for continuous versus label output, NaN nodata survival, multi-band PCA output, tiling and compression, the missing-CRS error, parent directory creation, and an end-to-end check against the bundled scene asserting the written raster matches both the source georeferencing and process() output exactly.

With the exception of MosaicCalculator, no tool could write a georeferenced
raster. _export_file() renders the result with matplotlib and saves a PNG, so
the only documented output of every workflow was a picture of the computed
array rather than the array.

Three things happened at export. Values were quantised through a colormap to
256 levels per channel, so a pixel that was 0.6237 could not be recovered,
ruling out thresholding, zonal statistics and date differencing. The pixel
grid was resampled by dpi and bbox_inches with margins, colorbar and watermark
baked in, leaving no pixel correspondence to the input - a 998x998 NDVI became
a 9389x8035 RGBA canvas. And CRS and transform were discarded, so the result
could not be loaded over other layers in a GIS.

Add FileHandler.get_raster_profile(), reading CRS, transform, nodata and dtype
via rasterio without touching the existing array-loading path, so no current
tool changes behavior. Add BaseTool.to_raster(), writing the computed array as
a GeoTIFF carrying the spatial referencing of the source band. Multi-component
outputs such as PCA's (6, height, width) are written as multi-band rasters.

Defaults follow raster-product convention: float32 for continuous results,
which is well beyond reflectance precision and half the size of float64, and
int32 for integer label maps such as classifications; NaN nodata for floating
point; tiled, deflate-compressed with a horizontal differencing predictor, and
BIGTIFF=IF_SAFER.

A source without a CRS raises rather than silently writing an identity
transform, which would produce a file that looks georeferenced while placing
the scene at the coordinate origin.

PNG export remains the default. Both paths are documented, including which one
is the data product, and the paper's description of the package's outputs is
corrected to match.

Verified against the bundled Landsat subset: EPSG:32639 and the 30 m affine
transform are preserved exactly, values round-trip with zero difference, and
the output holds 517,535 distinct values where the PNG path allowed 256.
@mhdned
mhdned requested review from aradfarahani and mhdned August 21, 2026 08:02
@mhdned mhdned added bug Something isn't working documentation Improvements or additions to documentation labels Aug 21, 2026
@mhdned

mhdned commented Aug 21, 2026

Copy link
Copy Markdown
Member

Thank you, Mmz. These changes are approved by me.

Regarding the changes to the paper, I read through them and didn’t find any problems. However, when working with agents, you should also consider the JOSS guidelines for the paper. For example, there are limits on the number of words, so we should make sure the changes comply with those requirements.

Submitting a paper to JOSS: https://joss.readthedocs.io/en/latest/submitting.html

@aradfarahani, please check if everything is okay to merge and close this.

@aradfarahani

Copy link
Copy Markdown
Member

@mhdned Merge it!

Comment thread paper/paper.md
FEZrs is organized around a shared abstract base class (`BaseTool`) that standardizes the lifecycle of every analysis module: input validation, processing, visualization customization, and export. Each calculator (for example, `NDVICalculator`, `GaussianCalculator`, or `KMeansCalculator`) inherits this interface, accepts band file paths through a common file-handling layer, and exposes a small set of methods such as `execute` and `histogram_export`. This pattern keeps the public API uniform across spectral indices, filters, enhancement tools, change-detection utilities, and machine-learning modules, reducing cognitive overhead when composing multi-step workflows.

The package is modular by domain: (`spectral_indices`, `filters`, `image_enhancement`, `change_detection`, `clustering`, `glcm`, `pca`, `svm`, and others), while shared utilities handle band-path typing, file I/O, and histogram support. Raster inputs are typically multi-band geospatial imagery. Outputs are written as figures and processed products suitable for inspection, reporting, and further analysis. The design deliberately favors composition of independent calculators over a single monolithic pipeline object, so researchers can select only the methods required for a given study while still benefiting from consistent validation and export behavior.
The package is modular by domain: (`spectral_indices`, `filters`, `image_enhancement`, `change_detection`, `clustering`, `glcm`, `pca`, `svm`, and others), while shared utilities handle band-path typing, file I/O, and histogram support. Raster inputs are typically multi-band geospatial imagery. Results can be exported two ways: as rendered figures via `execute()`, for inspection and reporting, and as georeferenced GeoTIFF rasters via `to_raster()`, which preserve full numerical precision along with the coordinate reference system and affine transform of the source imagery, and are therefore suitable for GIS overlay, zonal statistics, and multitemporal analysis. The design deliberately favors composition of independent calculators over a single monolithic pipeline object, so researchers can select only the methods required for a given study while still benefiting from consistent validation and export behavior.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the new changes, this paragraph has no issues.

@mhdned
mhdned merged commit 9a992ca into feat/svm-non-interactive-training Aug 21, 2026
@mhdned mhdned assigned mhdned and unassigned aradfarahani Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants