{"id":2510,"date":"2026-06-16T05:23:21","date_gmt":"2026-06-16T15:23:21","guid":{"rendered":"https:\/\/mshaeri.com\/blog\/?p=2510"},"modified":"2026-06-19T07:07:36","modified_gmt":"2026-06-19T17:07:36","slug":"generating-and-visualizing-geotiff-with-python-and-leaflet","status":"publish","type":"post","link":"https:\/\/mshaeri.com\/blog\/generating-and-visualizing-geotiff-with-python-and-leaflet\/","title":{"rendered":"Generating And Visualizing GeoTIFF with Python and Leaflet"},"content":{"rendered":"\n<p>Assume we have a collection of soil-moisture measurements from a farm. Each measurement has a decimal moisture value, latitude, longitude, and sample time. We want to visualize these values on the map to help the farmer better understand the moisture level of different areas of the farm:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"xml\" class=\"language-xml\">moisture,latitude,longitude,sampled_at\n0.1000,50.451201,30.501118,2026-06-11T08:00:00Z\n0.8100,50.451802,30.502045,2026-06-11T08:01:00Z\n0.1842,50.452118,30.500752,2026-06-11T08:02:00Z\n<\/code><\/pre>\n\n\n\n<p>To display farm soil moisture level on map we need to turn those point measurements into a single-band GeoTIFF. At the final step of this post we will have GeoTIFF visualization on the map using leaflet like this :<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image.png\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"817\" src=\"https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image-1024x817.png\" alt=\"\" class=\"wp-image-2527\" srcset=\"https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image-1024x817.png 1024w, https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image-300x239.png 300w, https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image-768x612.png 768w, https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image.png 1047w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">GeoTIFF visualiation on the map using leaflet<\/figcaption><\/figure>\n\n\n\n<p>The only issue with this example is that the latitude and longitude values were randomly generated, so the sample farm happens to appear inside an urban area\ud83d\ude05. You can think of it as a farm area.<\/p>\n\n\n\n<p>What you will read in this post is:<\/p>\n\n\n\n<ol>\n<li><a href=\"#Generating-GeoTIFF-from-CSV-file-data\"> Generating GeoTIFF from CSV file data<\/a><\/li>\n\n\n\n<li><a href=\"#display-the-geotiff-in-leaflet\"> Displaying the geotif on map using leaflet<\/a><\/li>\n<\/ol>\n\n\n\n<p>Also, here&#8217;s the GitHub repo for the full GeoTIFF-Generator project: <a href=\"https:\/\/github.com\/birddevelper\/GeoTIFF-Generator\" target=\"_blank\" rel=\"noreferrer noopener\">https:\/\/github.com\/birddevelper\/GeoTIFF-Generator<\/a><\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"Generating-GeoTIFF-from-CSV-file-data\">Generating GeoTIFF from CSV file data<\/h2>\n\n\n\n<p>Assume our CSV file has these columns:<\/p>\n\n\n\n<ul>\n<li><code>moisture<\/code>: decimal value, usually&nbsp;<code>0.10<\/code>&nbsp;to&nbsp;<code>0.81<\/code>&nbsp;in the sample data<\/li>\n\n\n\n<li><code>latitude<\/code>: WGS84 latitude<\/li>\n\n\n\n<li><code>longitude<\/code>: WGS84 longitude<\/li>\n\n\n\n<li><code>sampled_at<\/code>: ISO timestamp<\/li>\n<\/ul>\n\n\n\n<p>The output <strong>GeoTIFF <\/strong>would be a single-band byte raster. I choosed single band beacause it&#8217;s compact, easy to render in a browser, and still useful in GIS tools. Let&#8217;s start implementation:<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"cli-entry-point\">The Entry Point<\/h2>\n\n\n\n<p>In the main function of <code>make_geotiff.py<\/code>&nbsp;(our main module) we get user arguments and create a&nbsp;<code>RasterConfig<\/code>, and generates the GeoTIFF using <code>make_geotiff<\/code> function. I&#8217;ll explain the <code>make_geotiff <\/code>in later sections:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">from __future__ import annotations\n\nimport argparse\nfrom pathlib import Path\n\nfrom core import RasterConfig\n\n\ndef default_output_dir() -&gt; Path:\n    return Path(__file__).resolve().parent \/ \"output\"\n\n\ndef build_parser() -&gt; argparse.ArgumentParser:\n    parser = argparse.ArgumentParser(\n        description=\"Generate a single-band GeoTIFF from a moisture CSV file.\"\n    )\n    parser.add_argument(\"csv_path\", type=Path, help=\"Path to the input CSV file.\")\n    parser.add_argument(\n        \"--boundary-geojson\",\n        dest=\"boundary_geojson_path\",\n        type=Path,\n        required=True,\n        help=\"Path to a GeoJSON file containing the farm boundary polygon.\",\n    )\n    parser.add_argument(\n        \"-o\",\n        \"--output\",\n        type=Path,\n        default=default_output_dir(),\n        help=\"Output directory or GeoTIFF file path. Defaults to the package output folder.\",\n    )\n    parser.add_argument(\n        \"--resolution-m\",\n        type=float,\n        default=10.0,\n        help=\"Approximate raster resolution in meters.\",\n    )\n    parser.add_argument(\n        \"--max-distance-m\",\n        type=float,\n        default=None,\n        help=\"Optional maximum distance from a sensor point before a pixel is marked NoData.\",\n    )\n    parser.add_argument(\n        \"--nodata\",\n        type=int,\n        default=255,\n        help=\"NoData byte value to write into the GeoTIFF. Must be 101-255.\",\n    )\n    return parser\n\n\ndef main(argv: list[str] | None = None) -&gt; int:\n    parser = build_parser()\n    args = parser.parse_args(argv)\n\n    from pipeline import make_geotiff\n\n    config = RasterConfig(\n        resolution_m=args.resolution_m,\n        max_distance_m=args.max_distance_m,\n        nodata_value=args.nodata,\n    )\n    output_path = make_geotiff(\n        args.csv_path, args.boundary_geojson_path, args.output, config\n    )\n    print(f\"GeoTIFF written to: {output_path}\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n<\/code><\/pre>\n\n\n\n<p>In the code above, the <strong><code>RasterConfig&nbsp;<\/code><\/strong>holds the GeoTIFF generation settings: pixel resolution, optional max interpolation distance, and NoData byte value.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"core-data-and-input-loading\">Core Data And Input Loading<\/h2>\n\n\n\n<p>In <strong><code>core.py<\/code><\/strong>&nbsp;we declare data classes and input parsers.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">from __future__ import annotations\n\nimport csv\nimport json\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\n\n\n@dataclass(frozen=True)\nclass MoistureReading:\n    moisture: float\n    latitude: float\n    longitude: float\n    sampled_at: datetime\n\n\n@dataclass(frozen=True)\nclass RasterConfig:\n    resolution_m: float = 10.0\n    max_distance_m: float | None = None\n    nodata_value: int = 255\n<\/code><\/pre>\n\n\n\n<p><strong><code>MoistureReading<\/code>&nbsp;<\/strong>data class stores one measured point, and <strong><code>RasterConfig<\/code>&nbsp;<\/strong>stores output settings, we created one instance of it in main function in <code>make_geotiff.py<\/code>&nbsp;.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">def _parse_sampled_at(value: str) -&gt; datetime:\n    return datetime.fromisoformat(value.strip().replace(\"Z\", \"+00:00\"))\n\n\ndef read_moisture_csv(csv_path: Path) -&gt; list[MoistureReading]:\n    if not csv_path.exists():\n        raise FileNotFoundError(f\"CSV file does not exist: {csv_path}\")\n\n    readings: list[MoistureReading] = []\n    with csv_path.open(\"r\", encoding=\"utf-8\", newline=\"\") as file:\n        reader = csv.DictReader(file)\n        required_columns = {\"moisture\", \"latitude\", \"longitude\", \"sampled_at\"}\n        missing_columns = required_columns.difference(reader.fieldnames or [])\n        if missing_columns:\n            raise ValueError(f\"CSV file is missing required columns: {sorted(missing_columns)}\")\n\n        for row in reader:\n            moisture_raw = (row.get(\"moisture\") or \"\").strip()\n            latitude_raw = (row.get(\"latitude\") or \"\").strip()\n            longitude_raw = (row.get(\"longitude\") or \"\").strip()\n            sampled_at_raw = (row.get(\"sampled_at\") or \"\").strip()\n\n            if not moisture_raw or not latitude_raw or not longitude_raw or not sampled_at_raw:\n                continue\n\n            readings.append(\n                MoistureReading(\n                    moisture=float(moisture_raw),\n                    latitude=float(latitude_raw),\n                    longitude=float(longitude_raw),\n                    sampled_at=_parse_sampled_at(sampled_at_raw),\n                )\n            )\n\n    return readings\n<\/code><\/pre>\n\n\n\n<p><code>_parse_sampled_at()<\/code>&nbsp;parses ISO timestamps and supports&nbsp;<code>Z<\/code>. <code>read_moisture_csv()<\/code>&nbsp;validates required columns and returns typed readings.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">def _extract_geojson_geometry(data: object) -&gt; dict:\n    if not isinstance(data, dict):\n        raise ValueError(\"Invalid GeoJSON content.\")\n\n    geometry_type = data.get(\"type\")\n    if geometry_type == \"FeatureCollection\":\n        features = data.get(\"features\") or []\n        if not features:\n            raise ValueError(\"GeoJSON FeatureCollection does not contain any features.\")\n        return _extract_geojson_geometry(features[0])\n\n    if geometry_type == \"Feature\":\n        geometry = data.get(\"geometry\")\n        if geometry is None:\n            raise ValueError(\"GeoJSON Feature does not contain a geometry.\")\n        return geometry\n\n    return data\n\n\ndef load_boundary_geojson(geojson_path: Path):\n    if not geojson_path.exists():\n        raise FileNotFoundError(f\"Boundary GeoJSON file does not exist: {geojson_path}\")\n\n    from shapely.geometry import MultiPolygon, Polygon, shape\n\n    with geojson_path.open(\"r\", encoding=\"utf-8\") as file:\n        data = json.load(file)\n\n    geometry = _extract_geojson_geometry(data)\n    polygon = shape(geometry)\n\n    if polygon.is_empty:\n        raise ValueError(f\"Boundary GeoJSON is empty: {geojson_path}\")\n\n    if isinstance(polygon, MultiPolygon):\n        polygon = max(polygon.geoms, key=lambda geom: geom.area)\n\n    if not isinstance(polygon, Polygon):\n        raise ValueError(\"Boundary GeoJSON must describe a Polygon or MultiPolygon.\")\n\n    return polygon\n<\/code><\/pre>\n\n\n\n<p><code><strong>_extract_geojson_geometry()<\/strong><\/code>&nbsp;normalizes different valid GeoJSON shapes into one plain geometry object. If the input is a&nbsp;FeatureCollection, it takes the first feature; if it is a&nbsp;Feature, it extracts its&nbsp;geometry; if it is already a raw geometry like&nbsp;Polygon&nbsp;or&nbsp;MultiPolygon, it returns it unchanged. Its output is the geometry dictionary that Shapely can convert into a polygon for clipping the raster.<strong> load_boundary_geojson()<\/strong>&nbsp;reads the boundary GeoJSON file using <code><strong>_extract_geojson_geometry()<\/strong><\/code>, extracts its geometry, converts it into a Shapely shape, validates that it is not empty, and ensures the result is a usable farm boundary polygon. For simplicity, if the GeoJSON contains a&nbsp;MultiPolygon, it keeps the largest polygon. Its output is a Shapely&nbsp;Polygon, which the raster step uses to mask pixels outside the farm boundary as NoData.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"raster-generation\">Raster Generation<\/h2>\n\n\n\n<p>In <code><strong>raster.py<\/strong><\/code>&nbsp;we convert point measurements into a georeferenced raster. A raster is a grid of pixels or cells where each cell stores a value. For a moisture GeoTIFF, the raster is like a spreadsheet laid over the farm. The raster remains in EPSG:4326, but interpolation and distance filtering operations need meter-like distances. The code approximates meters per degree at the farm latitude.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">from __future__ import annotations\n\nimport math\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nimport numpy as np\nfrom osgeo import gdal, osr\nfrom scipy.spatial import cKDTree\nfrom core import MoistureReading\n\ndef meters_per_degree(latitude: float) -&gt; tuple[float, float]:\n    latitude_radians = math.radians(latitude)\n    meters_per_degree_latitude = (\n        111132.92\n        - 559.82 * math.cos(2 * latitude_radians)\n        + 1.175 * math.cos(4 * latitude_radians)\n        - 0.0023 * math.cos(6 * latitude_radians)\n    )\n    meters_per_degree_longitude = (\n        111412.84 * math.cos(latitude_radians)\n        - 93.5 * math.cos(3 * latitude_radians)\n        + 0.118 * math.cos(5 * latitude_radians)\n    )\n    return meters_per_degree_latitude, meters_per_degree_longitude\n\n\ndef to_metric_xy(\n    longitudes: np.ndarray,\n    latitudes: np.ndarray,\n    reference_latitude: float,\n) -&gt; tuple[np.ndarray, np.ndarray]:\n    meters_lat, meters_lon = meters_per_degree(reference_latitude)\n    x_coords = np.asarray(longitudes, dtype=np.float64) * meters_lon\n    y_coords = np.asarray(latitudes, dtype=np.float64) * meters_lat\n    return x_coords, y_coords\n\n\ndef degree_steps_for_resolution(resolution_m: float, reference_latitude: float) -&gt; tuple[float, float]:\n    meters_lat, meters_lon = meters_per_degree(reference_latitude)\n    lon_step = resolution_m \/ meters_lon\n    lat_step = resolution_m \/ meters_lat\n    return lon_step, lat_step\n<\/code><\/pre>\n\n\n\n<p><code><strong>meters_per_degree()<\/strong><\/code>&nbsp;estimates latitude and longitude scale at a reference latitude. <code><strong>to_metric_xy()<\/strong><\/code>&nbsp;converts lon\/lat arrays into approximate meter coordinates, and <code><strong>degree_steps_for_resolution()<\/strong><\/code>&nbsp;converts requested meter resolution into lon\/lat pixel steps.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"grid-definition\">Grid Definition<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">@dataclass(frozen=True)\nclass GridSpec:\n    width: int\n    height: int\n    lon_step: float\n    lat_step: float\n    geotransform: tuple[float, float, float, float, float, float]\n    lon_grid: np.ndarray\n    lat_grid: np.ndarray\n    reference_latitude: float\n\n\ndef build_grid(\n    bounds: tuple[float, float, float, float],\n    resolution_m: float,\n    reference_latitude: float,\n) -&gt; GridSpec:\n    min_lon, min_lat, max_lon, max_lat = bounds\n    lon_step, lat_step = degree_steps_for_resolution(resolution_m, reference_latitude)\n\n    width = max(1, int(math.ceil((max_lon - min_lon) \/ lon_step)))\n    height = max(1, int(math.ceil((max_lat - min_lat) \/ lat_step)))\n\n    lon_centers = min_lon + (np.arange(width) + 0.5) * lon_step\n    lat_centers = max_lat - (np.arange(height) + 0.5) * lat_step\n    lon_grid, lat_grid = np.meshgrid(lon_centers, lat_centers)\n    geotransform = (min_lon, lon_step, 0.0, max_lat, 0.0, -lat_step)\n\n    return GridSpec(\n        width=width,\n        height=height,\n        lon_step=lon_step,\n        lat_step=lat_step,\n        geotransform=geotransform,\n        lon_grid=lon_grid,\n        lat_grid=lat_grid,\n        reference_latitude=reference_latitude,\n    )\n<\/code><\/pre>\n\n\n\n<p><strong><code>GridSpec<\/code>&nbsp;<\/strong>keeps raster size, pixel centers, and GDAL geotransform together.<strong> <code>build_grid()<\/code><\/strong>&nbsp;creates a lon\/lat grid over the farm boundary bounds. It creates the empty raster grid that the moisture values will be written into. It takes the farm boundary bounds, the desired pixel size, like <code>10<\/code> meters, a reference latitude for meter-to-degree conversion, then it converts the requested meter resolution into longitude\/latitude steps, and calculates how many columns and rows the raster needs. Then, it creates longitude and latitude coordinates for each pixel center, and builds <code>lon_grid<\/code> and <code>lat_grid<\/code>, which represent every raster cell location. Finally it creates the GDAL <code>geotransform<\/code>, which tells GIS tools where the raster sits on Earth.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"continuous-moisture-surface\">Continuous Moisture Surface<\/h3>\n\n\n\n<p>For interpolation we use inverse-distance weighting. Each raster cell queries nearby sample points with&nbsp;<code><strong>cKDTree<\/strong><\/code>; closer samples receive higher weights. This way we will have smooth transation between tails in moisture visulization instead of disecret tiles.<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full\"><a href=\"https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image-2.png\"><img loading=\"lazy\" decoding=\"async\" width=\"731\" height=\"513\" src=\"https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image-2.png\" alt=\"\" class=\"wp-image-2551\" srcset=\"https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image-2.png 731w, https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image-2-300x211.png 300w, https:\/\/mshaeri.com\/blog\/wp-content\/uploads\/2026\/06\/image-2-120x85.png 120w\" sizes=\"(max-width: 731px) 100vw, 731px\" \/><\/a><figcaption class=\"wp-element-caption\">Example of disecret tiles geotiff generation<\/figcaption><\/figure><\/div>\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">def interpolate_moisture(\n    readings: list[MoistureReading],\n    grid: GridSpec,\n    *,\n    neighbors: int = 12,\n    power: float = 2.0,\n    smoothing: float = 1e-9,\n) -&gt; np.ndarray:\n    longitudes = np.array([reading.longitude for reading in readings], dtype=np.float64)\n    latitudes = np.array([reading.latitude for reading in readings], dtype=np.float64)\n    moisture = np.array([reading.moisture for reading in readings], dtype=np.float64)\n\n    point_x, point_y = to_metric_xy(longitudes, latitudes, grid.reference_latitude)\n    grid_x, grid_y = to_metric_xy(grid.lon_grid.ravel(), grid.lat_grid.ravel(), grid.reference_latitude)\n\n    query_points = np.column_stack([grid_x, grid_y])\n    sample_points = np.column_stack([point_x, point_y])\n    tree = cKDTree(sample_points)\n\n    k = min(max(1, neighbors), len(readings))\n    distances, indices = tree.query(query_points, k=k)\n    distances = np.asarray(distances, dtype=np.float64)\n    indices = np.asarray(indices)\n\n    if k == 1:\n        surface = moisture[indices]\n        return surface.reshape(grid.lon_grid.shape).astype(np.float32)\n\n    exact_matches = distances &lt;= smoothing\n    weights = 1.0 \/ np.power(distances + smoothing, power)\n    weighted_moisture = moisture[indices] * weights\n    weighted_sum = weighted_moisture.sum(axis=1)\n    weight_total = weights.sum(axis=1)\n    surface = np.divide(\n        weighted_sum,\n        weight_total,\n        out=np.zeros_like(weighted_sum),\n        where=weight_total &gt; 0,\n    )\n\n    if np.any(exact_matches):\n        exact_rows = np.where(np.any(exact_matches, axis=1))[0]\n        exact_cols = np.argmax(exact_matches[exact_rows], axis=1)\n        surface[exact_rows] = moisture[indices[exact_rows, exact_cols]]\n\n    return surface.reshape(grid.lon_grid.shape).astype(np.float32)\n<\/code><\/pre>\n\n\n\n<p>Function summary:<\/p>\n\n\n\n<ul>\n<li><code>interpolate_moisture()<\/code>&nbsp;creates a continuous decimal moisture grid.<\/li>\n\n\n\n<li><code>neighbors<\/code>&nbsp;limits how many sample points affect each cell.<\/li>\n\n\n\n<li><code>power<\/code>&nbsp;controls how quickly influence drops with distance.<\/li>\n\n\n\n<li><code>smoothing<\/code>&nbsp;prevents division by zero and supports exact point matches.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"boundary-and-distance-masks\">Boundary And Distance Masks<\/h3>\n\n\n\n<p>Displaying moisture levels outside the farm boundary is undesirable, so we need to clip the map to the farm&#8217;s perimeter. The farm boundary is available from a GeoJSON file provided via command-line arguments.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">def clip_to_boundary(grid_values: np.ndarray, grid: GridSpec, boundary_polygon) -&gt; np.ndarray:\n    from shapely import contains_xy\n\n    mask = contains_xy(boundary_polygon, grid.lon_grid, grid.lat_grid)\n    clipped = np.array(grid_values, copy=True)\n    clipped[~mask] = np.nan\n    return clipped\n\n\ndef limit_by_distance(\n    readings: list[MoistureReading],\n    grid_values: np.ndarray,\n    grid: GridSpec,\n    max_distance_m: float,\n) -&gt; np.ndarray:\n    point_lons = np.array([reading.longitude for reading in readings], dtype=np.float64)\n    point_lats = np.array([reading.latitude for reading in readings], dtype=np.float64)\n    point_x, point_y = to_metric_xy(point_lons, point_lats, grid.reference_latitude)\n\n    grid_x, grid_y = to_metric_xy(grid.lon_grid.ravel(), grid.lat_grid.ravel(), grid.reference_latitude)\n    tree = cKDTree(np.column_stack([point_x, point_y]))\n    distances, _ = tree.query(np.column_stack([grid_x, grid_y]))\n\n    limited = np.array(grid_values, copy=True).ravel()\n    limited[distances &gt; max_distance_m] = np.nan\n    return limited.reshape(grid_values.shape)\n<\/code><\/pre>\n\n\n\n<p>In the code above, the<strong> <code>clip_to_boundary()<\/code><\/strong>&nbsp;sets cells outside the farm polygon to&nbsp;<code>np.nan<\/code>, and <code><strong>limit_by_distance()<\/strong><\/code>&nbsp;optionally sets cells too far from samples to&nbsp;<code>np.nan<\/code>. For example, if the nearest sample point is 80 meters away and you set&nbsp;&#8211;max-distance-m 30, that cell becomes&nbsp;np.nan (NoData) in the GeoTIFF.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"geotiff-writer\">GeoTIFF Writer<\/h3>\n\n\n\n<p>The writer converts decimal moisture to byte percent values. GDAL writes the raster as&nbsp;<code>GDT_Byte<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">def write_single_band_geotiff(\n    output_path: Path,\n    grid_values: np.ndarray,\n    grid: GridSpec,\n    nodata_value: int,\n) -&gt; Path:\n    if not 101 &lt;= nodata_value &lt;= 255:\n        raise ValueError(\"UInt8 GeoTIFF NoData value must be between 101 and 255.\")\n\n    output_path.parent.mkdir(parents=True, exist_ok=True)\n\n    driver = gdal.GetDriverByName(\"GTiff\")\n    dataset = driver.Create(\n        str(output_path),\n        grid.width,\n        grid.height,\n        1,\n        gdal.GDT_Byte,\n        options=[\"COMPRESS=LZW\"],\n    )\n    if dataset is None:\n        raise RuntimeError(f\"Could not create GeoTIFF at {output_path}\")\n\n    dataset.SetGeoTransform(grid.geotransform)\n    spatial_reference = osr.SpatialReference()\n    spatial_reference.ImportFromEPSG(4326)\n    dataset.SetProjection(spatial_reference.ExportToWkt())\n\n    band = dataset.GetRasterBand(1)\n    band.SetNoDataValue(nodata_value)\n\n    percent_values = np.where(\n        np.isfinite(grid_values),\n        np.rint(np.clip(grid_values * 100.0, 0.0, 100.0)),\n        nodata_value,\n    ).astype(np.uint8)\n    band.WriteRaster(\n        0,\n        0,\n        grid.width,\n        grid.height,\n        percent_values.tobytes(order=\"C\"),\n        buf_xsize=grid.width,\n        buf_ysize=grid.height,\n        buf_type=gdal.GDT_Byte,\n    )\n    band.FlushCache()\n\n    dataset.FlushCache()\n    dataset = None\n    return output_path\n\n\n<\/code><\/pre>\n\n\n\n<ul>\n<li><code><strong>write_single_band_geotiff<\/strong>()<\/code>&nbsp;writes one&nbsp;<code>UInt8<\/code>&nbsp;band with LZW compression.<\/li>\n\n\n\n<li>Valid decimal moisture is scaled by&nbsp;<code>* 100<\/code>, rounded, and clipped to&nbsp;<code>0..100<\/code>.<\/li>\n\n\n\n<li>Missing cells become&nbsp;<code>nodata_value<\/code>, normally&nbsp;<code>255<\/code>.<\/li>\n\n\n\n<li><code><strong>WriteRaster<\/strong>()<\/code>&nbsp;avoids requiring GDAL&#8217;s&nbsp;<strong><code>gdal_array<\/code>&nbsp;<\/strong>extension.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"pipeline\">Pipeline<\/h2>\n\n\n\n<p>Finally, <code>pipeline.py<\/code>&nbsp;connects all pieces.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"python\" class=\"language-python\">from __future__ import annotations\n\nfrom pathlib import Path\n\nfrom core import RasterConfig, load_boundary_geojson, read_moisture_csv\nfrom raster import build_grid, clip_to_boundary, interpolate_moisture, limit_by_distance, write_single_band_geotiff\n\n\ndef default_output_dir() -&gt; Path:\n    return Path(__file__).resolve().parent \/ \"output\"\n\n\ndef resolve_output_path(csv_path: Path, output_target: Path | None) -&gt; Path:\n    if output_target is None:\n        return default_output_dir() \/ f\"{csv_path.stem}_moisture.tif\"\n\n    if output_target.suffix.lower() in {\".tif\", \".tiff\"}:\n        return output_target\n\n    return output_target \/ f\"{csv_path.stem}_moisture.tif\"\n\n\ndef make_geotiff(\n    csv_path: Path,\n    boundary_geojson_path: Path,\n    output_target: Path | None = None,\n    config: RasterConfig | None = None,\n) -&gt; Path:\n    job_config = config or RasterConfig()\n    readings = read_moisture_csv(csv_path)\n    if not readings:\n        raise ValueError(\"No valid moisture readings were found in the CSV file.\")\n\n    boundary = load_boundary_geojson(boundary_geojson_path)\n    reference_latitude = float(sum(reading.latitude for reading in readings) \/ len(readings))\n\n    grid = build_grid(boundary.bounds, job_config.resolution_m, reference_latitude)\n    interpolated = interpolate_moisture(readings, grid)\n    clipped = clip_to_boundary(interpolated, grid, boundary)\n\n    if job_config.max_distance_m is not None:\n        clipped = limit_by_distance(readings, clipped, grid, job_config.max_distance_m)\n\n    output_path = resolve_output_path(csv_path, output_target)\n    return write_single_band_geotiff(output_path, clipped, grid, job_config.nodata_value)\n\n\n\n\n<\/code><\/pre>\n\n\n\n<p><code><strong>make_geotiff()<\/strong><\/code>&nbsp;is the main pipeline function that turns the input CSV and farm boundary GeoJSON into the final GeoTIFF file. To sum up the flow, the generation order is like following:<\/p>\n\n\n\n<ol>\n<li>read CSV<\/li>\n\n\n\n<li>load boundary<\/li>\n\n\n\n<li>compute average latitude for meter conversion<\/li>\n\n\n\n<li>build grid<\/li>\n\n\n\n<li>interpolate moisture<\/li>\n\n\n\n<li>clip to boundary<\/li>\n\n\n\n<li>optionally apply max-distance mask<\/li>\n\n\n\n<li>write GeoTIFF<\/li>\n<\/ol>\n\n\n\n<p>To generate GeoTIFF, run the <code>make_geotiff.py<\/code> like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"bash\" class=\"language-bash\">python make_geotiff.py input\/farm_moisture_200.csv --boundary-geojson input\/farm_boundary.geojson\n<\/code><\/pre>\n\n\n\n<p><\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"display-the-geotiff-in-leaflet\">Displaying the GeoTIFF on map using leaflet<\/h2>\n\n\n\n<p>The GeoTIFF stays single-band. So in the browser we read its pixel value and maps it to a color.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"html\">HTML<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"markup\" class=\"language-markup\">&lt;!DOCTYPE html&gt;\n&lt;html lang=\"en\"&gt;\n&lt;head&gt;\n  &lt;meta charset=\"UTF-8\" \/&gt;\n  &lt;meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" \/&gt;\n  &lt;title&gt;Farm Moisture Map&lt;\/title&gt;\n  &lt;link rel=\"stylesheet\" href=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.css\" \/&gt;\n  &lt;style&gt;\n    html, body, #map {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      font-family: Arial, sans-serif;\n    }\n  &lt;\/style&gt;\n&lt;\/head&gt;\n&lt;body&gt;\n  &lt;div id=\"map\"&gt;&lt;\/div&gt;\n  &lt;script src=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.js\"&gt;&lt;\/script&gt;\n  &lt;script src=\"https:\/\/unpkg.com\/geotiff\/dist-browser\/geotiff.js\"&gt;&lt;\/script&gt;\n  &lt;script src=\"https:\/\/cdn.jsdelivr.net\/npm\/georaster@1.6.0\/dist\/georaster.browser.bundle.min.js\"&gt;&lt;\/script&gt;\n  &lt;script src=\"https:\/\/unpkg.com\/georaster-layer-for-leaflet\/dist\/georaster-layer-for-leaflet.min.js\"&gt;&lt;\/script&gt;\n  &lt;script src=\"https:\/\/unpkg.com\/chroma-js@2.4.2\/chroma.min.js\"&gt;&lt;\/script&gt;\n  &lt;script src=\"app.js\"&gt;&lt;\/script&gt;\n&lt;\/body&gt;\n&lt;\/html&gt;\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"javascript\">JavaScript<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"javascript\" class=\"language-javascript\">const GEOTIFF_URL = \"output\/farm_moisture_200_moisture.tif\";\nconst MIN_VALUE = 0;\nconst MAX_VALUE = 100;\n\nconst map = L.map(\"map\", { zoomControl: true }).setView([0, 0], 2);\n\nL.tileLayer(\"https:\/\/tile.openstreetmap.org\/{z}\/{x}\/{y}.png\", {\n  attribution: \"&amp;copy; OpenStreetMap contributors\",\n  maxZoom: 19,\n}).addTo(map);\n\nconst moistureScale = chroma\n  .scale([\"#7b4f2a\", \"#d9c27d\", \"#4caf50\"])\n  .domain([MIN_VALUE, MAX_VALUE]);\n\nasync function loadMoistureRaster() {\n  try {\n    const response = await fetch(GEOTIFF_URL);\n    if (!response.ok) {\n      throw new Error(`Failed to load GeoTIFF: ${response.status}`);\n    }\n\n    const arrayBuffer = await response.arrayBuffer();\n    const georaster = await parseGeoraster(arrayBuffer);\n    const noData = georaster.noDataValue;\n\n    const rasterLayer = new GeoRasterLayer({\n      georaster,\n      opacity: 0.85,\n      resolution: 256,\n      pixelValuesToColorFn: (values) =&gt; {\n        const moisture = values?.[0];\n\n        if (\n          moisture === undefined ||\n          moisture === null ||\n          Number.isNaN(moisture) ||\n          moisture === noData\n        ) {\n          return undefined;\n        }\n\n        const clamped = Math.max(MIN_VALUE, Math.min(MAX_VALUE, moisture));\n        return moistureScale(clamped).hex();\n      },\n    });\n\n    rasterLayer.addTo(map);\n    map.fitBounds(rasterLayer.getBounds());\n  } catch (error) {\n    console.error(error);\n    alert(\"Failed to load GeoTIFF. Check the console for details.\");\n  }\n}\n\nloadMoistureRaster();\n\n\n<\/code><\/pre>\n\n\n\n<p>In the javascript after we load the GeoTIFF bytes using <code>fetch()<\/code>, we parse it by <code>parseGeoraster()<\/code>. Then <code>GeoRasterLayer<\/code>&nbsp;renders the raster on Leaflet. The <code>pixelValuesToColorFn()<\/code> function&nbsp;skips NoData and maps&nbsp;<code>0..100<\/code>&nbsp;moisture to a corresponding color. The <code>fitBounds()<\/code>&nbsp;zooms the map to the raster extent. So when page loads, it automatically zoom in the farm area.<\/p>\n\n\n\n<p>Serve the HTML over a HTTP server like apache, or ngnix, not&nbsp;<code>file:\/\/<\/code>, so the browser can fetch the GeoTIFF and openstreet, otherwise you face CORS error. BTW, you can find source code of the project in this github repository:<a href=\"https:\/\/github.com\/birddevelper\/GeoTIFF-Generator\" target=\"_blank\" rel=\"noreferrer noopener\"> https:\/\/github.com\/birddevelper\/GeoTIFF-Generator<\/a><\/p>\n\n\n\n<p>I hope you found this post helpful. If you enjoyed it or have any questions, leave a comment, I\u2019d love to hear your thoughts.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Assume we have a collection of soil-moisture measurements from a farm. Each measurement has a decimal moisture value, latitude, longitude, and sample time. We want &hellip; <\/p>\n","protected":false},"author":1,"featured_media":2536,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1,41],"tags":[364,353,363,351,361,360,358,352,359,39,354,362,357],"_links":{"self":[{"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/posts\/2510"}],"collection":[{"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/comments?post=2510"}],"version-history":[{"count":4,"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/posts\/2510\/revisions"}],"predecessor-version":[{"id":2566,"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/posts\/2510\/revisions\/2566"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/media\/2536"}],"wp:attachment":[{"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/media?parent=2510"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/categories?post=2510"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mshaeri.com\/blog\/wp-json\/wp\/v2\/tags?post=2510"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}