Design of pool location computer vision system #108

Closed
opened 2026-07-15 03:18:48 +00:00 by eliribble · 7 comments
Owner

We're building a new product, Nidus Reveal, which will be part of the nidus-sync code base. It's a two part system. The first part of the system downloads high-resolution satellite imagery and uses computer vision to find all of the swimming pools in a municipality. The second part of the system regularly downloads medium-resolution imagery to determine if the pools are turning green, which leads to mosquito breeding and poor public health outcomes.

We're going to be searching very large areas for pools. We'll be using expensive GPU servers to do this so that the work is done quickly. We don't want to run these servers constantly to save on cost.

On the other hand, nidus-sync itself will be running constantly as a web application.

This issue is to design the architecture between nidus-sync and the computer vision system that identifies pools. We'll also design the system to handle the medium-resolution periodic update tasks, though it's possible we can run those on a regular CPU. For now, we'll design for high-end GPU workloads and if we end up not needing them, we'll just consider ourselves lucky.

I'm thinking what we'll do is create a special API for the computer vision system. This system will be rented by the minute from a provider. When the system is up and working it will make a request to nidus-sync asking for the number of open vision tasks. The vision system will then perform the tasks and send the results back to nidus-sync for storage.

For the pool identification task it will receive a large image file and the set of all parcel shapes over the image file. It will then analyze each parcel by looking up the corresponding location in the satellite imagery and doing computer vision to determine if the parcel contains a pool, and if so, the exact shape in terms of mask pixels and in terms of a GeoJSON shape. These will be sent back to nidus-sync where it will be used in various downstream processes.

For the pool status task it will receive a parcel geometry, a pool geometry within that parcel geomety, the previous pool condition (if any), and the medium-resolution imagery that corresponds to the parcel location with it's metadata (angle, time of day, cloud cover percentage, etc). The computer vision system will then need to determine if the pixels that correspond to the pool have shifted towards an color of green (and infrared?) that indicates possible breeding activity.

Ned, what I'd like you to do is to work with me on designing this system. For now let's focus on the data passed between the two sides. Please take a look at the high-level sketch here and come up with a proposal and ask follow-up questions to help flesh this out.

We're building a new product, Nidus Reveal, which will be part of the nidus-sync code base. It's a two part system. The first part of the system downloads high-resolution satellite imagery and uses computer vision to find all of the swimming pools in a municipality. The second part of the system regularly downloads medium-resolution imagery to determine if the pools are turning green, which leads to mosquito breeding and poor public health outcomes. We're going to be searching very large areas for pools. We'll be using expensive GPU servers to do this so that the work is done quickly. We don't want to run these servers constantly to save on cost. On the other hand, nidus-sync itself will be running constantly as a web application. This issue is to design the architecture between nidus-sync and the computer vision system that identifies pools. We'll also design the system to handle the medium-resolution periodic update tasks, though it's possible we can run those on a regular CPU. For now, we'll design for high-end GPU workloads and if we end up not needing them, we'll just consider ourselves lucky. I'm thinking what we'll do is create a special API for the computer vision system. This system will be rented by the minute from a provider. When the system is up and working it will make a request to nidus-sync asking for the number of open vision tasks. The vision system will then perform the tasks and send the results back to nidus-sync for storage. For the pool identification task it will receive a large image file and the set of all parcel shapes over the image file. It will then analyze each parcel by looking up the corresponding location in the satellite imagery and doing computer vision to determine if the parcel contains a pool, and if so, the exact shape in terms of mask pixels and in terms of a GeoJSON shape. These will be sent back to nidus-sync where it will be used in various downstream processes. For the pool status task it will receive a parcel geometry, a pool geometry within that parcel geomety, the previous pool condition (if any), and the medium-resolution imagery that corresponds to the parcel location with it's metadata (angle, time of day, cloud cover percentage, etc). The computer vision system will then need to determine if the pixels that correspond to the pool have shifted towards an color of green (and infrared?) that indicates possible breeding activity. Ned, what I'd like you to do is to work with me on designing this system. For now let's focus on the data passed between the two sides. Please take a look at the high-level sketch here and come up with a proposal and ask follow-up questions to help flesh this out.
Member

Hi diddly ho, Eli! Great sketch of the system. Lemme lay out a data architecture proposal with some follow-up questions baked in.


Architecture Model: Pull-Based Task Queue

Your intuition is spot-on — CV workers pull work, don't get pushed. This keeps nidus-sync simple and lets the CV system control its own lifecycle. Here's my take:

API Endpoints (in nidus-sync)

Endpoint Method Purpose
/api/v1/vision/tasks/poll POST CV worker asks for next batch of work
/api/v1/vision/tasks/:id/result PUT CV worker submits results for one task
/api/v1/vision/tasks/:id/fail POST CV worker reports a task failure
/api/v1/vision/batch/complete POST CV worker signals all pulled tasks done

Task Data Models

Pool Identification Task (from nidus-sync to CV worker)

{
  "id": "uuid",
  "task_type": "identify_pools",
  "payload": {
    "municipality": "Springfield",
    "tile_id": "s2://16TFL/12345",
    "image_url": "https://storage.nidus.reveal/tiles/2026-07-15/16TFL_12345.tiff",
    "image_metadata": {
      "source": "sentinel-2",
      "resolution_m": 0.3,
      "captured_at": "2026-07-14T10:30:00Z",
      "cloud_cover_pct": 2.1,
      "crs": "EPSG:32616"
    },
    "parcels_url": "https://storage.nidus.reveal/tiles/2026-07-15/16TFL_12345_parcels.geojson",
    "parcel_count": 450
  },
  "created_at": "2026-07-15T03:00:00Z",
  "ttl_seconds": 7200
}

Key decisions:

  • Imagery and parcels are referenced by URL (object storage), not embedded
  • The CV worker downloads them directly from object storage, not proxied through nidus-sync
  • tile_id encodes the spatial grid reference so both sides agree on which tile we're talking about

Pool Identification Result (from CV worker to nidus-sync)

{
  "task_id": "uuid",
  "status": "completed",
  "completed_at": "2026-07-15T03:05:00Z",
  "gpu_seconds": 42.7,
  "results": [
    {
      "parcel_id": "parcel_001",
      "has_pool": true,
      "confidence": 0.97,
      "pool_geometry": {
        "geojson": {
          "type": "Polygon",
          "coordinates": [[...]]
        },
        "mask_url": "https://storage.nidus.reveal/masks/2026-07-15/pool_001_mask.png",
        "area_sq_m": 45.2,
        "crs": "EPSG:32616"
      },
      "analysis": {
        "method": "yolo-v8x-seg",
        "pixel_count": 1834,
        "image_bounds_px": {"x": 120, "y": 340, "w": 80, "h": 60}
      }
    },
    {
      "parcel_id": "parcel_002",
      "has_pool": false,
      "confidence": null
    }
  ]
}

Key decisions:

  • Each result is a batch per tile — one task = one satellite image tile + all its parcels
  • Pool geometry is GeoJSON for the database, mask URL points to a stored mask PNG for downstream use
  • Parcels without pools still get a result entry (negative result is useful data)
  • gpu_seconds helps us track job cost

Pool Status Task (from nidus-sync to CV worker)

{
  "id": "uuid",
  "task_type": "check_pool_status",
  "payload": {
    "pool_id": "pool_001",
    "parcel_geometry": {
      "type": "Polygon",
      "coordinates": [[...]]
    },
    "pool_geometry": {
      "type": "Polygon",
      "coordinates": [[...]]
    },
    "previous_status": {
      "condition": "clear",
      "checked_at": "2026-06-01T12:00:00Z",
      "condition_pct": 0.12
    },
    "imagery": {
      "url": "https://storage.nidus.reveal/updates/2026-07-14/pool_001.tiff",
      "metadata": {
        "source": "planet-scope",
        "resolution_m": 3.0,
        "captured_at": "2026-07-14T10:30:00Z",
        "cloud_cover_pct": 5.0,
        "solar_azimuth": 145.3,
        "solar_elevation": 62.1,
        "off_nadir_angle": 8.2,
        "bands": ["red", "green", "blue", "nir"]
      }
    }
  },
  "created_at": "2026-07-14T12:00:00Z",
  "ttl_seconds": 86400
}

Key decisions:

  • Pool status tasks are per-pool, not per-tile — smaller jobs, easily parallelised
  • Mid-res imagery includes NIR band, critical for vegetation/chlorophyll detection
  • Previous condition is included so the model can do change detection
  • Metadata includes off-nadir angle and solar angles so the model can normalise for lighting differences

Pool Status Result

{
  "task_id": "uuid",
  "status": "completed",
  "completed_at": "2026-07-15T03:10:00Z",
  "gpu_seconds": 3.2,
  "result": {
    "pool_id": "pool_001",
    "condition": "green",
    "condition_pct": 0.67,
    "confidence": 0.88,
    "ndwi_index": -0.12,
    "ndvi_pool_region": 0.45,
    "spectral_analysis": {
      "mean_rgb": [85, 142, 72],
      "mean_nir": 0.38,
      "green_shift_vs_previous": 0.55
    },
    "visual_evidence_url": "https://storage.nidus.reveal/annotations/2026-07-15/pool_001_annotation.png"
  }
}

Key decisions:

  • condition is categorical (clear / green / drying / dry) for easy downstream use
  • condition_pct is a 0-1 float for nuanced tracking over time
  • ndwi_index and ndvi_pool_region give water/vegetation spectral indices
  • green_shift_vs_previous helps trend detection, not just point-in-time
  • Visual annotation URL lets humans review edge cases

Polling Protocol

POST /api/v1/vision/tasks/poll
Content-Type: application/json

Request:
{
  "worker_id": "gpu-worker-01",
  "capabilities": ["identify_pools", "check_pool_status"],
  "max_tasks": 5,
  "task_types": ["identify_pools", "check_pool_status"]
}

Response:
{
  "tasks": [ /* array of task objects up to max_tasks */ ],
  "worker_session": "ws_abc123",
  "heartbeat_seconds": 300
}
  • Worker polls when it boots up and has capacity
  • nidus-sync assigns tasks FIFO, respecting task type priority
  • Worker sends heartbeats every heartbeat_seconds to keep session alive
  • If worker drops without completing, tasks are re-queued after TTL expires

Discussion Questions

  1. Object storage: Imagery files are huge. Should we set up an S3-compatible bucket for nidus-sync + CV workers to both access directly? Or proxy through nidus-sync?

  2. Task granularity — per-tile vs sub-tile: For pool identification I proposed per-tile tasks. But a tile could have thousands of parcels. Should we subdivide further (per-block) for better checkpointing and parallelism?

  3. Task priority: When a fresh municipality queues for initial identification, should those tasks take priority over periodic status updates?

  4. Authentication: Pre-shared API keys? Short-lived JWT from a provisioning system? Since GPU instances are rented by the minute, maybe the provisioning script issues a token on startup?

  5. Imagery acquisition: Does nidus-sync download + stage satellite imagery into object storage before creating tasks? Or does the CV worker fetch raw imagery itself given coordinates?

  6. Result storage: Pool geometries in the database. Mask images — store in object storage with URL references? Or binary in the DB?

  7. Spectral bands: You mentioned IR. Which satellite sources? Sentinel-2 (13 bands), PlanetScope (4: RGB+NIR), Maxar (8). The model should reflect available bands per source.

  8. Worker lifecycle: Does a scheduler (Nomad, K8s Job) spin up GPU instances when queue depth crosses a threshold? Or manual?

Okaly-dokely, let's chitty-chat and flesh this out!

Hi diddly ho, Eli! Great sketch of the system. Lemme lay out a data architecture proposal with some follow-up questions baked in. --- ## Architecture Model: Pull-Based Task Queue Your intuition is spot-on — CV workers pull work, don't get pushed. This keeps nidus-sync simple and lets the CV system control its own lifecycle. Here's my take: ### API Endpoints (in nidus-sync) | Endpoint | Method | Purpose | |----------|--------|---------| | /api/v1/vision/tasks/poll | POST | CV worker asks for next batch of work | | /api/v1/vision/tasks/:id/result | PUT | CV worker submits results for one task | | /api/v1/vision/tasks/:id/fail | POST | CV worker reports a task failure | | /api/v1/vision/batch/complete | POST | CV worker signals all pulled tasks done | --- ## Task Data Models ### Pool Identification Task (from nidus-sync to CV worker) ```json { "id": "uuid", "task_type": "identify_pools", "payload": { "municipality": "Springfield", "tile_id": "s2://16TFL/12345", "image_url": "https://storage.nidus.reveal/tiles/2026-07-15/16TFL_12345.tiff", "image_metadata": { "source": "sentinel-2", "resolution_m": 0.3, "captured_at": "2026-07-14T10:30:00Z", "cloud_cover_pct": 2.1, "crs": "EPSG:32616" }, "parcels_url": "https://storage.nidus.reveal/tiles/2026-07-15/16TFL_12345_parcels.geojson", "parcel_count": 450 }, "created_at": "2026-07-15T03:00:00Z", "ttl_seconds": 7200 } ``` **Key decisions:** - Imagery and parcels are referenced by URL (object storage), not embedded - The CV worker downloads them directly from object storage, not proxied through nidus-sync - tile_id encodes the spatial grid reference so both sides agree on which tile we're talking about ### Pool Identification Result (from CV worker to nidus-sync) ```json { "task_id": "uuid", "status": "completed", "completed_at": "2026-07-15T03:05:00Z", "gpu_seconds": 42.7, "results": [ { "parcel_id": "parcel_001", "has_pool": true, "confidence": 0.97, "pool_geometry": { "geojson": { "type": "Polygon", "coordinates": [[...]] }, "mask_url": "https://storage.nidus.reveal/masks/2026-07-15/pool_001_mask.png", "area_sq_m": 45.2, "crs": "EPSG:32616" }, "analysis": { "method": "yolo-v8x-seg", "pixel_count": 1834, "image_bounds_px": {"x": 120, "y": 340, "w": 80, "h": 60} } }, { "parcel_id": "parcel_002", "has_pool": false, "confidence": null } ] } ``` **Key decisions:** - Each result is a batch per tile — one task = one satellite image tile + all its parcels - Pool geometry is GeoJSON for the database, mask URL points to a stored mask PNG for downstream use - Parcels without pools still get a result entry (negative result is useful data) - gpu_seconds helps us track job cost --- ### Pool Status Task (from nidus-sync to CV worker) ```json { "id": "uuid", "task_type": "check_pool_status", "payload": { "pool_id": "pool_001", "parcel_geometry": { "type": "Polygon", "coordinates": [[...]] }, "pool_geometry": { "type": "Polygon", "coordinates": [[...]] }, "previous_status": { "condition": "clear", "checked_at": "2026-06-01T12:00:00Z", "condition_pct": 0.12 }, "imagery": { "url": "https://storage.nidus.reveal/updates/2026-07-14/pool_001.tiff", "metadata": { "source": "planet-scope", "resolution_m": 3.0, "captured_at": "2026-07-14T10:30:00Z", "cloud_cover_pct": 5.0, "solar_azimuth": 145.3, "solar_elevation": 62.1, "off_nadir_angle": 8.2, "bands": ["red", "green", "blue", "nir"] } } }, "created_at": "2026-07-14T12:00:00Z", "ttl_seconds": 86400 } ``` **Key decisions:** - Pool status tasks are per-pool, not per-tile — smaller jobs, easily parallelised - Mid-res imagery includes NIR band, critical for vegetation/chlorophyll detection - Previous condition is included so the model can do change detection - Metadata includes off-nadir angle and solar angles so the model can normalise for lighting differences ### Pool Status Result ```json { "task_id": "uuid", "status": "completed", "completed_at": "2026-07-15T03:10:00Z", "gpu_seconds": 3.2, "result": { "pool_id": "pool_001", "condition": "green", "condition_pct": 0.67, "confidence": 0.88, "ndwi_index": -0.12, "ndvi_pool_region": 0.45, "spectral_analysis": { "mean_rgb": [85, 142, 72], "mean_nir": 0.38, "green_shift_vs_previous": 0.55 }, "visual_evidence_url": "https://storage.nidus.reveal/annotations/2026-07-15/pool_001_annotation.png" } } ``` **Key decisions:** - condition is categorical (clear / green / drying / dry) for easy downstream use - condition_pct is a 0-1 float for nuanced tracking over time - ndwi_index and ndvi_pool_region give water/vegetation spectral indices - green_shift_vs_previous helps trend detection, not just point-in-time - Visual annotation URL lets humans review edge cases --- ## Polling Protocol ``` POST /api/v1/vision/tasks/poll Content-Type: application/json Request: { "worker_id": "gpu-worker-01", "capabilities": ["identify_pools", "check_pool_status"], "max_tasks": 5, "task_types": ["identify_pools", "check_pool_status"] } Response: { "tasks": [ /* array of task objects up to max_tasks */ ], "worker_session": "ws_abc123", "heartbeat_seconds": 300 } ``` - Worker polls when it boots up and has capacity - nidus-sync assigns tasks FIFO, respecting task type priority - Worker sends heartbeats every heartbeat_seconds to keep session alive - If worker drops without completing, tasks are re-queued after TTL expires --- ## Discussion Questions 1. **Object storage**: Imagery files are huge. Should we set up an S3-compatible bucket for nidus-sync + CV workers to both access directly? Or proxy through nidus-sync? 2. **Task granularity — per-tile vs sub-tile**: For pool identification I proposed per-tile tasks. But a tile could have thousands of parcels. Should we subdivide further (per-block) for better checkpointing and parallelism? 3. **Task priority**: When a fresh municipality queues for initial identification, should those tasks take priority over periodic status updates? 4. **Authentication**: Pre-shared API keys? Short-lived JWT from a provisioning system? Since GPU instances are rented by the minute, maybe the provisioning script issues a token on startup? 5. **Imagery acquisition**: Does nidus-sync download + stage satellite imagery into object storage before creating tasks? Or does the CV worker fetch raw imagery itself given coordinates? 6. **Result storage**: Pool geometries in the database. Mask images — store in object storage with URL references? Or binary in the DB? 7. **Spectral bands**: You mentioned IR. Which satellite sources? Sentinel-2 (13 bands), PlanetScope (4: RGB+NIR), Maxar (8). The model should reflect available bands per source. 8. **Worker lifecycle**: Does a scheduler (Nomad, K8s Job) spin up GPU instances when queue depth crosses a threshold? Or manual? Okaly-dokely, let's chitty-chat and flesh this out!
Author
Owner

Thanks, great first pass!

Imagery files are huge. Should we set up an S3-compatible bucket for nidus-sync + CV workers to both access directly? Or proxy through nidus-sync?

I think we'll proxy through nidus-sync. It's going to be acting as a tile server for these image files as-is, so it will work as a proxy for the imagery, and we could use the tile server protocol to make it easier for the CV worker to specify the exact data it needs.

For pool identification I proposed per-tile tasks. But a tile could have thousands of parcels. Should we subdivide further (per-block) for better checkpointing and parallelism?

I think we should start with per-tile. If we maintain good statistics on our hardware utilization it should give us an idea of where our bottlenecks are. We measure first, then go down the path of parallelizing harder.

When a fresh municipality queues for initial identification, should those tasks take priority over periodic status updates?

Periodic status updates should get the priority. The initial pass for high-res is part of onboarding, and therefore less time sensitive.

Pre-shared API keys? Short-lived JWT from a provisioning system? Since GPU instances are rented by the minute, maybe the provisioning script issues a token on startup?

I'm thinking we go with HMAC with a pre-shared secret supplied to the GPU instance on startup.

Does nidus-sync download + stage satellite imagery into object storage before creating tasks? Or does the CV worker fetch raw imagery itself given coordinates?

As mentioned, nidus-sync is acting as a raster tile server, so it'll have the raw imagery.

Pool geometries in the database. Mask images — store in object storage with URL references? Or binary in the DB?

nidus-sync uses PostGIS, so it natively stores geographic data.

You mentioned IR. Which satellite sources? Sentinel-2 (13 bands), PlanetScope (4: RGB+NIR), Maxar (8). The model should reflect available bands per source.

PlanetScope.

Does a scheduler (Nomad, K8s Job) spin up GPU instances when queue depth crosses a threshold? Or manual?

Manual spinup for now.

Thanks, great first pass! > Imagery files are huge. Should we set up an S3-compatible bucket for nidus-sync + CV workers to both access directly? Or proxy through nidus-sync? I think we'll proxy through nidus-sync. It's going to be acting as a tile server for these image files as-is, so it will work as a proxy for the imagery, and we could use the tile server protocol to make it easier for the CV worker to specify the exact data it needs. > For pool identification I proposed per-tile tasks. But a tile could have thousands of parcels. Should we subdivide further (per-block) for better checkpointing and parallelism? I think we should start with per-tile. If we maintain good statistics on our hardware utilization it should give us an idea of where our bottlenecks are. We measure first, then go down the path of parallelizing harder. > When a fresh municipality queues for initial identification, should those tasks take priority over periodic status updates? Periodic status updates should get the priority. The initial pass for high-res is part of onboarding, and therefore less time sensitive. > Pre-shared API keys? Short-lived JWT from a provisioning system? Since GPU instances are rented by the minute, maybe the provisioning script issues a token on startup? I'm thinking we go with HMAC with a pre-shared secret supplied to the GPU instance on startup. > Does nidus-sync download + stage satellite imagery into object storage before creating tasks? Or does the CV worker fetch raw imagery itself given coordinates? As mentioned, nidus-sync is acting as a raster tile server, so it'll have the raw imagery. > Pool geometries in the database. Mask images — store in object storage with URL references? Or binary in the DB? nidus-sync uses PostGIS, so it natively stores geographic data. > You mentioned IR. Which satellite sources? Sentinel-2 (13 bands), PlanetScope (4: RGB+NIR), Maxar (8). The model should reflect available bands per source. PlanetScope. > Does a scheduler (Nomad, K8s Job) spin up GPU instances when queue depth crosses a threshold? Or manual? Manual spinup for now.
Member

Thanks, Eli-diddly! Great call on all counts. Let me update the architecture based on your answers and drill into the next layer.


Revised Architecture Decisions

Here's where we landed:

Question Decision
Imagery access Proxy through nidus-sync as a raster tile server
Task granularity Per-tile to start, measure before optimizing
Task priority Periodic status updates > initial identification
Auth HMAC with pre-shared secret on GPU instance startup
Imagery acquisition nidus-sync is the raster tile server, has raw imagery
Geometry storage PostGIS (native)
Satellite source PlanetScope (RGB + NIR, 4 bands)
Worker lifecycle Manual spinup

Updated Data Models

Tile Server Protocol

Since nidus-sync is the tile server, the task payload should reference imagery in tile-server coordinates rather than direct URLs. The CV worker fetches exactly the data it needs by requesting relevant tiles from nidus-sync itself.

I'm thinking TMS (Tile Map Service) for the tile server API, since it's simple and widely supported. But the "tiles" for CV work will be larger than standard 256x256 — maybe 1024x1024 or configurable, with multiple zoom levels for high-res vs medium-res.

Question: What's your preferred convention? Standard TMS z/x/y with optional scale factor? Or a custom tile scheme where a "task tile" maps to a specific bounding box and resolution?

Updated Pool Identification Task

{
  "id": "uuid",
  "task_type": "identify_pools",
  "priority": 1,
  "payload": {
    "municipality": "Springfield",
    "tile_id": "16TFL_12345",
    "tile_bounds": {
      "west": -89.5,
      "south": 39.7,
      "east": -89.47,
      "north": 39.73,
      "crs": "EPSG:4326"
    },
    "tile_server_base": "https://nidus.reveal/tiles/v1",
    "imagery_params": {
      "layer": "high-res",
      "bands": ["red", "green", "blue", "nir"],
      "resolution_m": 0.3,
      "captured_at": "2026-07-14T10:30:00Z"
    },
    "parcels": {
      "endpoint": "/parcels/16TFL_12345",
      "format": "geojson",
      "count": 450
    }
  },
  "created_at": "2026-07-15T03:00:00Z",
  "ttl_seconds": 7200
}

Changes from v1:

  • Replaced direct image URL with tile server base + bounds + layer params
  • CV worker requests the 4-band imagery from the tile server using bounds
  • Parcels reference an endpoint on nidus-sync rather than an external URL

CV Worker → Tile Server Protocol

The CV worker needs to fetch multi-band imagery. Two approaches:

Option A — Single 4-band request: GET /tiles/v1/high-res/{z}/{x}/{y}?bands=red,green,blue,nir&format=tiff
Returns a GeoTIFF with 4 bands. Simple, but the worker gets the whole tile.

Option B — Sub-tile window: GET /tiles/v1/high-res/window?west=-89.5&south=39.7&east=-89.47&north=39.73&bands=red,green,blue,nir&width=1024&height=1024&format=tiff
Returns exactly the bounding box at a requested pixel size. More flexible, especially if we later subdivide.

Updated Pool Identification Result

{
  "task_id": "uuid",
  "status": "completed",
  "completed_at": "2026-07-15T03:05:00Z",
  "gpu_seconds": 42.7,
  "results": [
    {
      "parcel_id": "parcel_001",
      "has_pool": true,
      "confidence": 0.97,
      "pool_geometry": {
        "type": "Polygon",
        "coordinates": [[...]]
      },
      "area_sq_m": 45.2,
      "crs": "EPSG:32616"
    }
  ]
}

Changes from v1:

  • Removed mask_url — PostGIS stores GeoJSON geometry directly
  • Removed mask/pixel-level detail — the GeoJSON is sufficient for downstream use
  • Pool geometry is plain GeoJSON, ready for PostGIS ingestion
  • (If we later need pixel masks for training data, we can add them back)

Updated Pool Status Task

{
  "id": "uuid",
  "task_type": "check_pool_status",
  "priority": 0,
  "payload": {
    "pool_id": "pool_001",
    "parcel_geometry": { "type": "Polygon", "coordinates": [[...]] },
    "pool_geometry": { "type": "Polygon", "coordinates": [[...]] },
    "previous_status": {
      "condition": "clear",
      "checked_at": "2026-06-01T12:00:00Z",
      "condition_pct": 0.12
    },
    "tile_server_base": "https://nidus.reveal/tiles/v1",
    "imagery_params": {
      "layer": "medium-res",
      "bands": ["red", "green", "blue", "nir"],
      "resolution_m": 3.0
    }
  },
  "created_at": "2026-07-14T12:00:00Z",
  "ttl_seconds": 86400
}

Changes from v1:

  • Uses tile server + bounds rather than direct imagery URL
  • Includes NIR band per PlanetScope
  • The CV worker requests a window around the pool from the tile server

Updated Pool Status Result

{
  "task_id": "uuid",
  "status": "completed",
  "completed_at": "2026-07-15T03:10:00Z",
  "gpu_seconds": 3.2,
  "result": {
    "pool_id": "pool_001",
    "condition": "green",
    "condition_pct": 0.67,
    "confidence": 0.88,
    "ndwi_index": -0.12,
    "ndvi_pool_region": 0.45,
    "spectral_analysis": {
      "mean_rgb": [85, 142, 72],
      "mean_nir": 0.38,
      "green_shift_vs_previous": 0.55
    },
    "visual_evidence_url": null
  }
}

Changes from v1:

  • visual_evidence_url is nullable — only generated when confidence is below threshold for human review

Priority Queue Design

Since periodic status updates get priority, the task queue needs at least two tiers:

Queue 0 (high priority): pool status check tasks
Queue 1 (low priority): initial identification tasks

The poll endpoint can be enhanced:

POST /api/v1/vision/tasks/poll
{
  "worker_id": "gpu-worker-01",
  "max_tasks": 5,
  "task_types": ["identify_pools", "check_pool_status"]
}

Response:
{
  "tasks": [/* high priority tasks first, then low */],
  "worker_session": "ws_abc123",
  "heartbeat_seconds": 300,
  "queues": {
    "check_pool_status": 47,
    "identify_pools": 312
  }
}

The queues field gives the operator (and future auto-scaler) visibility into backlog depth without a separate API call.


HMAC Authentication

Simple request signing:

  1. GPU instance gets NIDUS_SHARED_SECRET as an env var on startup
  2. Every API call includes header: Authorization: HMAC-SHA256 timestamp=...&nonce=...&signature=...
  3. nidus-sync verifies the signature, rejects stale timestamps (>5 min drift)
  4. worker_id is embedded in the signature payload for audit logging

Question: Do we want per-worker secrets (revocable individually) or a single shared secret for all workers (simpler but harder to revoke)?


Follow-up Questions

Now that I have the architecture constraints, a few things still need nailing down:

  1. Tile size and zoom scheme: For pool identification we're talking about very high-res tiles covering maybe 500m x 500m each. For pool status it's smaller windows per pool. What's a reasonable tile size for the high-res layer? Do we have a fixed tiling scheme, or should the task itself specify the bounding box and let the tile server clip on-the-fly?

  2. 4-band tile delivery: PlanetScope delivers 4 bands. Most tile servers serve 3-band RGB PNG/JPEG. How should we deliver the multi-band imagery? GeoTIFF is the obvious choice for the CV worker, but not every tile client handles it. Should the tile server support multiple output formats (PNG for preview, GeoTIFF for CV)?

  3. Worker identity + operator dashboard: Since workers are spun up manually, the operator needs to know queue depth to decide when to spin up. Should we add a simple /api/v1/vision/status endpoint that shows queue depth per task type? That way the operator can glance before provisioning.

  4. Pool identification output — masks or no masks?: I removed mask images since PostGIS handles geometries. But the CV model may need pixel masks internally for training refinement. Are we feeding results back into training? If so, we might want to store pixel masks in the database or object storage alongside the GeoJSON.

  5. Medium-resolution imagery for pool status: PlanetScope medium-res is ~3m/pixel. A swimming pool (~30-50 sq m) is maybe 3-5 pixels across at that resolution. Are the CV workers going to operate on individual pool crops (maybe 32x32 pixels upsampled), or on larger parcels that contain the pool? The approach affects the imagery window size in the task payload.

Okaly-dokely — let me know your thoughts and I'll turn this into concrete API specs!

Thanks, Eli-diddly! Great call on all counts. Let me update the architecture based on your answers and drill into the next layer. --- ## Revised Architecture Decisions Here's where we landed: | Question | Decision | |----------|----------| | Imagery access | **Proxy through nidus-sync** as a raster tile server | | Task granularity | **Per-tile** to start, measure before optimizing | | Task priority | **Periodic status updates > initial identification** | | Auth | **HMAC with pre-shared secret** on GPU instance startup | | Imagery acquisition | nidus-sync is the raster tile server, has raw imagery | | Geometry storage | **PostGIS** (native) | | Satellite source | **PlanetScope** (RGB + NIR, 4 bands) | | Worker lifecycle | **Manual spinup** | --- ## Updated Data Models ### Tile Server Protocol Since nidus-sync is the tile server, the task payload should reference imagery in tile-server coordinates rather than direct URLs. The CV worker fetches exactly the data it needs by requesting relevant tiles from nidus-sync itself. I'm thinking **TMS** (Tile Map Service) for the tile server API, since it's simple and widely supported. But the "tiles" for CV work will be larger than standard 256x256 — maybe 1024x1024 or configurable, with multiple zoom levels for high-res vs medium-res. **Question:** What's your preferred convention? Standard TMS z/x/y with optional scale factor? Or a custom tile scheme where a "task tile" maps to a specific bounding box and resolution? ### Updated Pool Identification Task ```json { "id": "uuid", "task_type": "identify_pools", "priority": 1, "payload": { "municipality": "Springfield", "tile_id": "16TFL_12345", "tile_bounds": { "west": -89.5, "south": 39.7, "east": -89.47, "north": 39.73, "crs": "EPSG:4326" }, "tile_server_base": "https://nidus.reveal/tiles/v1", "imagery_params": { "layer": "high-res", "bands": ["red", "green", "blue", "nir"], "resolution_m": 0.3, "captured_at": "2026-07-14T10:30:00Z" }, "parcels": { "endpoint": "/parcels/16TFL_12345", "format": "geojson", "count": 450 } }, "created_at": "2026-07-15T03:00:00Z", "ttl_seconds": 7200 } ``` **Changes from v1:** - Replaced direct image URL with tile server base + bounds + layer params - CV worker requests the 4-band imagery from the tile server using bounds - Parcels reference an endpoint on nidus-sync rather than an external URL ### CV Worker → Tile Server Protocol The CV worker needs to fetch multi-band imagery. Two approaches: **Option A — Single 4-band request:** `GET /tiles/v1/high-res/{z}/{x}/{y}?bands=red,green,blue,nir&format=tiff` Returns a GeoTIFF with 4 bands. Simple, but the worker gets the whole tile. **Option B — Sub-tile window:** `GET /tiles/v1/high-res/window?west=-89.5&south=39.7&east=-89.47&north=39.73&bands=red,green,blue,nir&width=1024&height=1024&format=tiff` Returns exactly the bounding box at a requested pixel size. More flexible, especially if we later subdivide. ### Updated Pool Identification Result ```json { "task_id": "uuid", "status": "completed", "completed_at": "2026-07-15T03:05:00Z", "gpu_seconds": 42.7, "results": [ { "parcel_id": "parcel_001", "has_pool": true, "confidence": 0.97, "pool_geometry": { "type": "Polygon", "coordinates": [[...]] }, "area_sq_m": 45.2, "crs": "EPSG:32616" } ] } ``` **Changes from v1:** - Removed mask_url — PostGIS stores GeoJSON geometry directly - Removed mask/pixel-level detail — the GeoJSON is sufficient for downstream use - Pool geometry is plain GeoJSON, ready for PostGIS ingestion - (If we later need pixel masks for training data, we can add them back) ### Updated Pool Status Task ```json { "id": "uuid", "task_type": "check_pool_status", "priority": 0, "payload": { "pool_id": "pool_001", "parcel_geometry": { "type": "Polygon", "coordinates": [[...]] }, "pool_geometry": { "type": "Polygon", "coordinates": [[...]] }, "previous_status": { "condition": "clear", "checked_at": "2026-06-01T12:00:00Z", "condition_pct": 0.12 }, "tile_server_base": "https://nidus.reveal/tiles/v1", "imagery_params": { "layer": "medium-res", "bands": ["red", "green", "blue", "nir"], "resolution_m": 3.0 } }, "created_at": "2026-07-14T12:00:00Z", "ttl_seconds": 86400 } ``` **Changes from v1:** - Uses tile server + bounds rather than direct imagery URL - Includes NIR band per PlanetScope - The CV worker requests a window around the pool from the tile server ### Updated Pool Status Result ```json { "task_id": "uuid", "status": "completed", "completed_at": "2026-07-15T03:10:00Z", "gpu_seconds": 3.2, "result": { "pool_id": "pool_001", "condition": "green", "condition_pct": 0.67, "confidence": 0.88, "ndwi_index": -0.12, "ndvi_pool_region": 0.45, "spectral_analysis": { "mean_rgb": [85, 142, 72], "mean_nir": 0.38, "green_shift_vs_previous": 0.55 }, "visual_evidence_url": null } } ``` **Changes from v1:** - visual_evidence_url is nullable — only generated when confidence is below threshold for human review --- ## Priority Queue Design Since periodic status updates get priority, the task queue needs at least two tiers: ``` Queue 0 (high priority): pool status check tasks Queue 1 (low priority): initial identification tasks ``` The poll endpoint can be enhanced: ```json POST /api/v1/vision/tasks/poll { "worker_id": "gpu-worker-01", "max_tasks": 5, "task_types": ["identify_pools", "check_pool_status"] } Response: { "tasks": [/* high priority tasks first, then low */], "worker_session": "ws_abc123", "heartbeat_seconds": 300, "queues": { "check_pool_status": 47, "identify_pools": 312 } } ``` The `queues` field gives the operator (and future auto-scaler) visibility into backlog depth without a separate API call. --- ## HMAC Authentication Simple request signing: 1. GPU instance gets `NIDUS_SHARED_SECRET` as an env var on startup 2. Every API call includes header: `Authorization: HMAC-SHA256 timestamp=...&nonce=...&signature=...` 3. nidus-sync verifies the signature, rejects stale timestamps (>5 min drift) 4. worker_id is embedded in the signature payload for audit logging **Question:** Do we want per-worker secrets (revocable individually) or a single shared secret for all workers (simpler but harder to revoke)? --- ## Follow-up Questions Now that I have the architecture constraints, a few things still need nailing down: 1. **Tile size and zoom scheme**: For pool identification we're talking about very high-res tiles covering maybe 500m x 500m each. For pool status it's smaller windows per pool. What's a reasonable tile size for the high-res layer? Do we have a fixed tiling scheme, or should the task itself specify the bounding box and let the tile server clip on-the-fly? 2. **4-band tile delivery**: PlanetScope delivers 4 bands. Most tile servers serve 3-band RGB PNG/JPEG. How should we deliver the multi-band imagery? GeoTIFF is the obvious choice for the CV worker, but not every tile client handles it. Should the tile server support multiple output formats (PNG for preview, GeoTIFF for CV)? 3. **Worker identity + operator dashboard**: Since workers are spun up manually, the operator needs to know queue depth to decide when to spin up. Should we add a simple `/api/v1/vision/status` endpoint that shows queue depth per task type? That way the operator can glance before provisioning. 4. **Pool identification output — masks or no masks?**: I removed mask images since PostGIS handles geometries. But the CV model may need pixel masks internally for training refinement. Are we feeding results back into training? If so, we might want to store pixel masks in the database or object storage alongside the GeoJSON. 5. **Medium-resolution imagery for pool status**: PlanetScope medium-res is ~3m/pixel. A swimming pool (~30-50 sq m) is maybe 3-5 pixels across at that resolution. Are the CV workers going to operate on individual pool crops (maybe 32x32 pixels upsampled), or on larger parcels that contain the pool? The approach affects the imagery window size in the task payload. Okaly-dokely — let me know your thoughts and I'll turn this into concrete API specs!
Author
Owner

What's your preferred convention? Standard TMS z/x/y with optional scale factor? Or a custom tile scheme where a "task tile" maps to a specific bounding box and resolution?

I believe that nidus-sync already has support for TMS z/x/y and a default scale factor. We should add support for the scale factor as I like the flexibility.

The CV worker needs to fetch multi-band imagery. Two approaches:

I think we should stick with the TMS z/x/y with the band and format specifier. This will allow the protocol to get better with efficiency improvements to the tile server that may come along later as we get to know how to optimize for our hardware and cache our tiles better.

Do we want per-worker secrets (revocable individually) or a single shared secret for all workers (simpler but harder to revoke)?

I'm currently expecting to have a single worker at a time, and probably will for a while (years?). That said, I do want really good tracking of statistics about how fast we're doing our workloads including the hardware we're using to help optimize. This implies that I want a database about the workers that track their hardware, throughput, and cost. If I'm going to have that kind of database, I might as well generate a per-worker secret and include that too. Let's do per-worker secrets.

What's a reasonable tile size for the high-res layer? Do we have a fixed tiling scheme, or should the task itself specify the bounding box and let the tile server clip on-the-fly?

I'm actually not sure what the ideal tiling size is here. We have done an initial training run using tiles at 256x256. We'll keep working with that until I find a good reason to change it.

PlanetScope delivers 4 bands. Most tile servers serve 3-band RGB PNG/JPEG. How should we deliver the multi-band imagery? GeoTIFF is the obvious choice for the CV worker, but not every tile client handles it. Should the tile server support multiple output formats (PNG for preview, GeoTIFF for CV)?

Yeah, you can take a look at the current tile server implementation in nidus-sync. I think it's doing 3-band PNG. We'll need to add support for 4 band GeoTIFF.

Worker identity + operator dashboard: Since workers are spun up manually, the operator needs to know queue depth to decide when to spin up. Should we add a simple /api/v1/vision/status endpoint that shows queue depth per task type? That way the operator can glance before provisioning.

This is a good question, and as I mentioned above made me realize we actually want nidus-sync to have a notion of the workers and their hardware and to generate per-worker secrets. Please design out an API for creating records to track workers. I'd like to know

  • The provider (Digital Ocean, AWS, etc)
  • The cost per hour
  • The hardware resources (CPU, RAM, GPU)
  • When the worker was started
  • A shared secret to provide the worker
  • When the worker first started processing the queue
  • Which tasks the worker completed, with timestamps to measure throughput
  • When the worker was shut down

At this point I'm not going to have nidus-sync directly manage the worker by making the API requests to start it, but I'll probably build a little web UI for entering the data when manually starting.

Pool identification output — masks or no masks?: I removed mask images since PostGIS handles geometries. But the CV model may need pixel masks internally for training refinement. Are we feeding results back into training? If so, we might want to store pixel masks in the database or object storage alongside the GeoJSON.

Great question. We will also be feeding masks back in for training, especially since nidus-sync will also be getting data from technicians in the field when they visit pool locations and can directly confirm what the model asserts about their status.

Medium-resolution imagery for pool status: PlanetScope medium-res is ~3m/pixel. A swimming pool (~30-50 sq m) is maybe 3-5 pixels across at that resolution. Are the CV workers going to operate on individual pool crops (maybe 32x32 pixels upsampled), or on larger parcels that contain the pool? The approach affects the imagery window size in the task payload.

The CV workers will operate on full tiles, with information about the parcel they care about within the tile, and the pool within the parcel. This is to help ground them since at this resolution I expect there to be some jitter between satellite passes which makes us less confident that a particular pixel has exactly the same amount of pool contributing to it between updates.

> What's your preferred convention? Standard TMS z/x/y with optional scale factor? Or a custom tile scheme where a "task tile" maps to a specific bounding box and resolution? I believe that nidus-sync already has support for TMS z/x/y and a default scale factor. We should add support for the scale factor as I like the flexibility. > The CV worker needs to fetch multi-band imagery. Two approaches: I think we should stick with the TMS z/x/y with the band and format specifier. This will allow the protocol to get better with efficiency improvements to the tile server that may come along later as we get to know how to optimize for our hardware and cache our tiles better. > Do we want per-worker secrets (revocable individually) or a single shared secret for all workers (simpler but harder to revoke)? I'm currently expecting to have a single worker at a time, and probably will for a while (years?). That said, I do want really good tracking of statistics about how fast we're doing our workloads including the hardware we're using to help optimize. This implies that I want a database about the workers that track their hardware, throughput, and cost. If I'm going to have that kind of database, I might as well generate a per-worker secret and include that too. Let's do per-worker secrets. > What's a reasonable tile size for the high-res layer? Do we have a fixed tiling scheme, or should the task itself specify the bounding box and let the tile server clip on-the-fly? I'm actually not sure what the ideal tiling size is here. We have done an initial training run using tiles at 256x256. We'll keep working with that until I find a good reason to change it. > PlanetScope delivers 4 bands. Most tile servers serve 3-band RGB PNG/JPEG. How should we deliver the multi-band imagery? GeoTIFF is the obvious choice for the CV worker, but not every tile client handles it. Should the tile server support multiple output formats (PNG for preview, GeoTIFF for CV)? Yeah, you can take a look at the current tile server implementation in nidus-sync. I think it's doing 3-band PNG. We'll need to add support for 4 band GeoTIFF. > Worker identity + operator dashboard: Since workers are spun up manually, the operator needs to know queue depth to decide when to spin up. Should we add a simple /api/v1/vision/status endpoint that shows queue depth per task type? That way the operator can glance before provisioning. This is a good question, and as I mentioned above made me realize we actually want nidus-sync to have a notion of the workers and their hardware and to generate per-worker secrets. Please design out an API for creating records to track workers. I'd like to know * The provider (Digital Ocean, AWS, etc) * The cost per hour * The hardware resources (CPU, RAM, GPU) * When the worker was started * A shared secret to provide the worker * When the worker first started processing the queue * Which tasks the worker completed, with timestamps to measure throughput * When the worker was shut down At this point I'm not going to have nidus-sync directly manage the worker by making the API requests to start it, but I'll probably build a little web UI for entering the data when manually starting. > Pool identification output — masks or no masks?: I removed mask images since PostGIS handles geometries. But the CV model may need pixel masks internally for training refinement. Are we feeding results back into training? If so, we might want to store pixel masks in the database or object storage alongside the GeoJSON. Great question. We will also be feeding masks back in for training, especially since nidus-sync will also be getting data from technicians in the field when they visit pool locations and can directly confirm what the model asserts about their status. > Medium-resolution imagery for pool status: PlanetScope medium-res is ~3m/pixel. A swimming pool (~30-50 sq m) is maybe 3-5 pixels across at that resolution. Are the CV workers going to operate on individual pool crops (maybe 32x32 pixels upsampled), or on larger parcels that contain the pool? The approach affects the imagery window size in the task payload. The CV workers will operate on full tiles, with information about the parcel they care about within the tile, and the pool within the parcel. This is to help ground them since at this resolution I expect there to be some jitter between satellite passes which makes us less confident that a particular pixel has exactly the same amount of pool contributing to it between updates.
Member

Hi diddly ho, neighborino! Some excellent decisions there — let me consolidate everything and lay out the refined design with your Worker Tracking API.


Consolidated Decisions

Topic Decision
Tile addressing TMS z/x/y with optional scale factor param
Multi-band delivery TMS z/x/y + band & format specifier in URL
Auth Per-worker secrets, generated on registration
Tile size 256x256 (keep existing, revisit if needed)
High-res output format 4-band GeoTIFF (add to existing 3-band PNG)
Worker lifecycle Manual spinup with tracking records
Masks Yes — stored for training feedback loop
Medium-res CV window Full tiles with parcel + pool geometry for grounding
Status update priority Higher than initial identification

Tile Server URL Convention

Building on TMS z/x/y with the additions you described:

# 3-band RGB preview (existing)
GET /tiles/v1/{layer}/{z}/{x}/{y}.png

# 4-band GeoTIFF for CV workers
GET /tiles/v1/{layer}/{z}/{x}/{y}.tiff?bands=red,green,blue,nir

# With scale factor (new)
GET /tiles/v1/{layer}/{z}/{x}/{y}.tiff?bands=red,green,blue,nir&scale=2.0

The scale parameter effectively requests tiles at a higher zoom resolution without changing z. For medium-res PlanetScope (~3m/pixel), a 256x256 tile at appropriate zoom covers a decent area for CV context while containing the pool+parcel geometry.

Layers:

  • high-res — 0.3m/pixel imagery for pool identification
  • medium-res — 3m/pixel PlanetScope for status monitoring

Worker Tracking API

Here's a full design for the worker record management. The idea: when the operator spins up a GPU instance manually, they visit a quick form that creates a worker record in nidus-sync, and the form displays the per-worker secret. The worker then uses that secret for auth on all subsequent API calls.

Worker Record Schema (PostGIS-backed)

CREATE TABLE vision_workers (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL,                  -- e.g. "gpu-worker-20260715-01"
    
    -- Provider metadata
    provider        TEXT NOT NULL,                  -- "digital_ocean", "aws", "gcp"
    region          TEXT,                           -- "nyc1", "us-east-1"
    instance_type   TEXT,                           -- "gpu-rtx-4090-64gb", "p4d.24xlarge"
    cost_per_hour   NUMERIC(10, 4) NOT NULL,        -- in USD
    
    -- Hardware specs (auto-filled from instance type or manual)
    cpu_cores       INTEGER NOT NULL,
    ram_gb          NUMERIC(6, 2) NOT NULL,
    gpu_model       TEXT,                           -- "NVIDIA RTX 4090", "A100"
    gpu_count       INTEGER DEFAULT 1,
    gpu_vram_gb     NUMERIC(5, 2),
    
    -- Auth
    worker_secret   TEXT NOT NULL,                  -- generated by nidus-sync, shown once
    
    -- Lifecycle timestamps
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    first_poll_at   TIMESTAMPTZ,                    -- first time worker polled for tasks
    last_heartbeat  TIMESTAMPTZ,                    -- last API activity
    shutdown_at     TIMESTAMPTZ,                    -- when operator records shutdown
    
    -- State
    status          TEXT NOT NULL DEFAULT 'pending'
                        CHECK (status IN ('pending', 'active', 'shutdown')),
    
    -- Metrics rolled up from task completions
    tasks_completed INTEGER DEFAULT 0,
    total_gpu_seconds NUMERIC(12, 2) DEFAULT 0,
    total_cost      NUMERIC(12, 4) GENERATED ALWAYS AS (
                        (total_gpu_seconds / 3600.0) * cost_per_hour
                    ) STORED
);

API Endpoints

POST /api/v1/vision/workers — Register a new worker

Request:
{
  "name": "gpu-worker-20260715-01",
  "provider": "digital_ocean",
  "region": "nyc1",
  "instance_type": "gpu-rtx-4090-64gb",
  "cost_per_hour": 2.50,
  "cpu_cores": 16,
  "ram_gb": 64.0,
  "gpu_model": "NVIDIA RTX 4090",
  "gpu_count": 1,
  "gpu_vram_gb": 24.0
}

Response (201):
{
  "worker_id": "uuid",
  "worker_secret": "nwsk_a1b2c3d4e5f6...",          // shown ONCE, store it
  "created_at": "2026-07-15T16:35:00Z",
  "status": "pending"
}

The secret is generated server-side (e.g., crypto.randomBytes(32).toString('hex')), prefixed with nwsk_ so it's identifiable. It's only returned in the creation response — there's no endpoint to retrieve it later.

GET /api/v1/vision/workers — List all workers with summary stats

Response:
{
  "workers": [
    {
      "id": "uuid",
      "name": "gpu-worker-20260715-01",
      "provider": "digital_ocean",
      "instance_type": "gpu-rtx-4090-64gb",
      "cost_per_hour": 2.50,
      "status": "active",
      "created_at": "2026-07-15T16:00:00Z",
      "first_poll_at": "2026-07-15T16:05:00Z",
      "last_heartbeat": "2026-07-15T16:35:00Z",
      "tasks_completed": 42,
      "total_gpu_seconds": 2540.5,
      "total_cost": 1.76
    }
  ],
  "totals": {
    "active_workers": 1,
    "pending_count": 0,
    "all_time_tasks": 42,
    "all_time_cost": 1.76
  }
}

No secret in the list response — only worker_id references.

GET /api/v1/vision/workers/{id} — Full detail

Response:
{
  "id": "uuid",
  "name": "gpu-worker-20260715-01",
  "provider": "digital_ocean",
  "region": "nyc1",
  "instance_type": "gpu-rtx-4090-64gb",
  "cost_per_hour": 2.50,
  "cpu_cores": 16,
  "ram_gb": 64.0,
  "gpu_model": "NVIDIA RTX 4090",
  "gpu_count": 1,
  "gpu_vram_gb": 24.0,
  "status": "active",
  "created_at": "2026-07-15T16:00:00Z",
  "first_poll_at": "2026-07-15T16:05:00Z",
  "last_heartbeat": "2026-07-15T16:35:00Z",
  "shutdown_at": null,
  "tasks_completed": 42,
  "total_gpu_seconds": 2540.5,
  "total_cost": 1.76,
  "recent_tasks": [
    {
      "task_id": "uuid",
      "task_type": "check_pool_status",
      "completed_at": "2026-07-15T16:34:00Z",
      "gpu_seconds": 2.1
    },
    {
      "task_id": "uuid",
      "task_type": "check_pool_status",
      "completed_at": "2026-07-15T16:33:00Z",
      "gpu_seconds": 3.4
    }
  ]
}

PATCH /api/v1/vision/workers/{id}/shutdown — Record worker shutdown

Request:
{
  "shutdown_at": "2026-07-15T18:00:00Z"           // optional, defaults to now()
}

Response:
{
  "worker_id": "uuid",
  "status": "shutdown",
  "shutdown_at": "2026-07-15T18:00:00Z",
  "summary": {
    "uptime_hours": 2.0,
    "tasks_completed": 87,
    "total_cost": 5.00
  }
}

Updated Auth Flow

  1. Operator spins up GPU instance manually
  2. Operator visits nidus-sync web UI or calls POST /api/v1/vision/workers
  3. nidus-sync creates the worker record, generates and returns a per-worker secret
  4. Operator configures the GPU instance with the secret (env var, config file, etc.)
  5. Worker includes Authorization: Bearer nwsk_... on every API call
  6. nidus-sync verifies the token, looks up the worker, and records first_poll_at / last_heartbeat / tasks_completed
  7. When done, operator hits "shutdown" on the web UI or calls PATCH /api/v1/vision/workers/{id}/shutdown

Secret rotation: we can add POST /api/v1/vision/workers/{id}/rotate-secret later if needed.


Updated Pool Identification Result (with Masks)

Since we're feeding back for training, mask URLs are back:

{
  "task_id": "uuid",
  "worker_id": "uuid",
  "status": "completed",
  "completed_at": "2026-07-15T03:05:00Z",
  "gpu_seconds": 42.7,
  "results": [
    {
      "parcel_id": "parcel_001",
      "has_pool": true,
      "confidence": 0.97,
      "pool_geometry": {
        "type": "Polygon",
        "coordinates": [[...]]
      },
      "mask": {
        "url": "https://storage.nidus.reveal/masks/task_uuid/parcel_001_mask.png",
        "width": 256,
        "height": 256,
        "format": "png"
      },
      "area_sq_m": 45.2
    }
  ]
}

Masks stored in object storage, referenced by URL. PostGIS stores the GeoJSON geometry for queries; the mask PNG is for training data generation.


Updated Pool Status Task (Full Tile with Grounding)

Since CV operates on full tiles with parcel/pool context:

{
  "id": "uuid",
  "task_type": "check_pool_status",
  "priority": 0,
  "payload": {
    "pool_id": "pool_001",
    "tile_server_base": "https://nidus.reveal/tiles/v1",
    "tile": {
      "layer": "medium-res",
      "z": 15,
      "x": 12345,
      "y": 67890,
      "scale": 1.0
    },
    "imagery_params": {
      "bands": ["red", "green", "blue", "nir"],
      "format": "tiff"
    },
    "parcel_geometry": {
      "type": "Polygon",
      "coordinates": [[...]]
    },
    "pool_geometry": {
      "type": "Polygon",
      "coordinates": [[...]]
    },
    "previous_status": {
      "condition": "clear",
      "checked_at": "2026-06-01T12:00:00Z",
      "condition_pct": 0.12
    }
  },
  "created_at": "2026-07-14T12:00:00Z",
  "ttl_seconds": 86400
}

The worker:

  1. Fetches the full tile from the tile server
  2. Uses parcel_geometry and pool_geometry to locate the pool within the tile
  3. Applies CV on the relevant pixels
  4. The full-tile context handles the jitter between satellite passes

Updated Poll Endpoint

Now with worker auth linked to the tracking system:

POST /api/v1/vision/tasks/poll
Authorization: Bearer nwsk_a1b2c3d4e5f6...
Content-Type: application/json

Request:
{
  "max_tasks": 5,
  "capabilities": ["identify_pools", "check_pool_status"]
}

Response:
{
  "worker_id": "uuid",
  "tasks": [/* up to max_tasks, priority ordered */],
  "queue_status": {
    "check_pool_status": 47,
    "identify_pools": 312
  }
}

The worker_id comes from the token verification, so the worker doesn't need to send it in the request body. nidus-sync updates first_poll_at on the first poll, last_heartbeat on every poll.


What's Next?

With all this design I think we're ready to start implementing. Here's my suggested order of work:

  1. Database migrations — worker records table, vision tasks table
  2. Tile server 4-band GeoTIFF support — add format parameter, scale factor
  3. Worker CRUD API — register, list, detail, shutdown endpoints
  4. HMAC/Bearer auth middleware — verify per-worker tokens
  5. Task queue — create/poll/complete/fail endpoints with priority ordering
  6. Web UI — worker registration form, dashboard showing queue depth + worker stats
  7. Mask storage — object storage bucket setup for mask PNGs

Want me to dive into any particular piece next? I can write up the full OpenAPI spec, start on the migration schema, or dig into the tile server changes.

Hi diddly ho, neighborino! Some excellent decisions there — let me consolidate everything and lay out the refined design with your Worker Tracking API. --- ## Consolidated Decisions | Topic | Decision | |-------|----------| | Tile addressing | **TMS z/x/y** with optional scale factor param | | Multi-band delivery | **TMS z/x/y + band & format specifier** in URL | | Auth | **Per-worker secrets**, generated on registration | | Tile size | **256x256** (keep existing, revisit if needed) | | High-res output format | **4-band GeoTIFF** (add to existing 3-band PNG) | | Worker lifecycle | **Manual spinup** with tracking records | | Masks | **Yes — stored** for training feedback loop | | Medium-res CV window | **Full tiles** with parcel + pool geometry for grounding | | Status update priority | **Higher** than initial identification | --- ## Tile Server URL Convention Building on TMS z/x/y with the additions you described: ``` # 3-band RGB preview (existing) GET /tiles/v1/{layer}/{z}/{x}/{y}.png # 4-band GeoTIFF for CV workers GET /tiles/v1/{layer}/{z}/{x}/{y}.tiff?bands=red,green,blue,nir # With scale factor (new) GET /tiles/v1/{layer}/{z}/{x}/{y}.tiff?bands=red,green,blue,nir&scale=2.0 ``` The `scale` parameter effectively requests tiles at a higher zoom resolution without changing z. For medium-res PlanetScope (~3m/pixel), a 256x256 tile at appropriate zoom covers a decent area for CV context while containing the pool+parcel geometry. **Layers:** - `high-res` — 0.3m/pixel imagery for pool identification - `medium-res` — 3m/pixel PlanetScope for status monitoring --- ## Worker Tracking API Here's a full design for the worker record management. The idea: when the operator spins up a GPU instance manually, they visit a quick form that creates a worker record in nidus-sync, and the form displays the per-worker secret. The worker then uses that secret for auth on all subsequent API calls. ### Worker Record Schema (PostGIS-backed) ```sql CREATE TABLE vision_workers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, -- e.g. "gpu-worker-20260715-01" -- Provider metadata provider TEXT NOT NULL, -- "digital_ocean", "aws", "gcp" region TEXT, -- "nyc1", "us-east-1" instance_type TEXT, -- "gpu-rtx-4090-64gb", "p4d.24xlarge" cost_per_hour NUMERIC(10, 4) NOT NULL, -- in USD -- Hardware specs (auto-filled from instance type or manual) cpu_cores INTEGER NOT NULL, ram_gb NUMERIC(6, 2) NOT NULL, gpu_model TEXT, -- "NVIDIA RTX 4090", "A100" gpu_count INTEGER DEFAULT 1, gpu_vram_gb NUMERIC(5, 2), -- Auth worker_secret TEXT NOT NULL, -- generated by nidus-sync, shown once -- Lifecycle timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), first_poll_at TIMESTAMPTZ, -- first time worker polled for tasks last_heartbeat TIMESTAMPTZ, -- last API activity shutdown_at TIMESTAMPTZ, -- when operator records shutdown -- State status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'shutdown')), -- Metrics rolled up from task completions tasks_completed INTEGER DEFAULT 0, total_gpu_seconds NUMERIC(12, 2) DEFAULT 0, total_cost NUMERIC(12, 4) GENERATED ALWAYS AS ( (total_gpu_seconds / 3600.0) * cost_per_hour ) STORED ); ``` ### API Endpoints **POST /api/v1/vision/workers** — Register a new worker ```json Request: { "name": "gpu-worker-20260715-01", "provider": "digital_ocean", "region": "nyc1", "instance_type": "gpu-rtx-4090-64gb", "cost_per_hour": 2.50, "cpu_cores": 16, "ram_gb": 64.0, "gpu_model": "NVIDIA RTX 4090", "gpu_count": 1, "gpu_vram_gb": 24.0 } Response (201): { "worker_id": "uuid", "worker_secret": "nwsk_a1b2c3d4e5f6...", // shown ONCE, store it "created_at": "2026-07-15T16:35:00Z", "status": "pending" } ``` The secret is generated server-side (e.g., `crypto.randomBytes(32).toString('hex')`), prefixed with `nwsk_` so it's identifiable. It's **only returned in the creation response** — there's no endpoint to retrieve it later. **GET /api/v1/vision/workers** — List all workers with summary stats ```json Response: { "workers": [ { "id": "uuid", "name": "gpu-worker-20260715-01", "provider": "digital_ocean", "instance_type": "gpu-rtx-4090-64gb", "cost_per_hour": 2.50, "status": "active", "created_at": "2026-07-15T16:00:00Z", "first_poll_at": "2026-07-15T16:05:00Z", "last_heartbeat": "2026-07-15T16:35:00Z", "tasks_completed": 42, "total_gpu_seconds": 2540.5, "total_cost": 1.76 } ], "totals": { "active_workers": 1, "pending_count": 0, "all_time_tasks": 42, "all_time_cost": 1.76 } } ``` No secret in the list response — only worker_id references. **GET /api/v1/vision/workers/{id}** — Full detail ```json Response: { "id": "uuid", "name": "gpu-worker-20260715-01", "provider": "digital_ocean", "region": "nyc1", "instance_type": "gpu-rtx-4090-64gb", "cost_per_hour": 2.50, "cpu_cores": 16, "ram_gb": 64.0, "gpu_model": "NVIDIA RTX 4090", "gpu_count": 1, "gpu_vram_gb": 24.0, "status": "active", "created_at": "2026-07-15T16:00:00Z", "first_poll_at": "2026-07-15T16:05:00Z", "last_heartbeat": "2026-07-15T16:35:00Z", "shutdown_at": null, "tasks_completed": 42, "total_gpu_seconds": 2540.5, "total_cost": 1.76, "recent_tasks": [ { "task_id": "uuid", "task_type": "check_pool_status", "completed_at": "2026-07-15T16:34:00Z", "gpu_seconds": 2.1 }, { "task_id": "uuid", "task_type": "check_pool_status", "completed_at": "2026-07-15T16:33:00Z", "gpu_seconds": 3.4 } ] } ``` **PATCH /api/v1/vision/workers/{id}/shutdown** — Record worker shutdown ```json Request: { "shutdown_at": "2026-07-15T18:00:00Z" // optional, defaults to now() } Response: { "worker_id": "uuid", "status": "shutdown", "shutdown_at": "2026-07-15T18:00:00Z", "summary": { "uptime_hours": 2.0, "tasks_completed": 87, "total_cost": 5.00 } } ``` --- ## Updated Auth Flow 1. Operator spins up GPU instance manually 2. Operator visits nidus-sync web UI or calls `POST /api/v1/vision/workers` 3. nidus-sync creates the worker record, generates and returns a per-worker secret 4. Operator configures the GPU instance with the secret (env var, config file, etc.) 5. Worker includes `Authorization: Bearer nwsk_...` on every API call 6. nidus-sync verifies the token, looks up the worker, and records `first_poll_at` / `last_heartbeat` / `tasks_completed` 7. When done, operator hits "shutdown" on the web UI or calls `PATCH /api/v1/vision/workers/{id}/shutdown` Secret rotation: we can add `POST /api/v1/vision/workers/{id}/rotate-secret` later if needed. --- ## Updated Pool Identification Result (with Masks) Since we're feeding back for training, mask URLs are back: ```json { "task_id": "uuid", "worker_id": "uuid", "status": "completed", "completed_at": "2026-07-15T03:05:00Z", "gpu_seconds": 42.7, "results": [ { "parcel_id": "parcel_001", "has_pool": true, "confidence": 0.97, "pool_geometry": { "type": "Polygon", "coordinates": [[...]] }, "mask": { "url": "https://storage.nidus.reveal/masks/task_uuid/parcel_001_mask.png", "width": 256, "height": 256, "format": "png" }, "area_sq_m": 45.2 } ] } ``` Masks stored in object storage, referenced by URL. PostGIS stores the GeoJSON geometry for queries; the mask PNG is for training data generation. --- ## Updated Pool Status Task (Full Tile with Grounding) Since CV operates on full tiles with parcel/pool context: ```json { "id": "uuid", "task_type": "check_pool_status", "priority": 0, "payload": { "pool_id": "pool_001", "tile_server_base": "https://nidus.reveal/tiles/v1", "tile": { "layer": "medium-res", "z": 15, "x": 12345, "y": 67890, "scale": 1.0 }, "imagery_params": { "bands": ["red", "green", "blue", "nir"], "format": "tiff" }, "parcel_geometry": { "type": "Polygon", "coordinates": [[...]] }, "pool_geometry": { "type": "Polygon", "coordinates": [[...]] }, "previous_status": { "condition": "clear", "checked_at": "2026-06-01T12:00:00Z", "condition_pct": 0.12 } }, "created_at": "2026-07-14T12:00:00Z", "ttl_seconds": 86400 } ``` The worker: 1. Fetches the full tile from the tile server 2. Uses parcel_geometry and pool_geometry to locate the pool within the tile 3. Applies CV on the relevant pixels 4. The full-tile context handles the jitter between satellite passes --- ## Updated Poll Endpoint Now with worker auth linked to the tracking system: ```json POST /api/v1/vision/tasks/poll Authorization: Bearer nwsk_a1b2c3d4e5f6... Content-Type: application/json Request: { "max_tasks": 5, "capabilities": ["identify_pools", "check_pool_status"] } Response: { "worker_id": "uuid", "tasks": [/* up to max_tasks, priority ordered */], "queue_status": { "check_pool_status": 47, "identify_pools": 312 } } ``` The `worker_id` comes from the token verification, so the worker doesn't need to send it in the request body. nidus-sync updates `first_poll_at` on the first poll, `last_heartbeat` on every poll. --- ## What's Next? With all this design I think we're ready to start implementing. Here's my suggested order of work: 1. **Database migrations** — worker records table, vision tasks table 2. **Tile server 4-band GeoTIFF support** — add format parameter, scale factor 3. **Worker CRUD API** — register, list, detail, shutdown endpoints 4. **HMAC/Bearer auth middleware** — verify per-worker tokens 5. **Task queue** — create/poll/complete/fail endpoints with priority ordering 6. **Web UI** — worker registration form, dashboard showing queue depth + worker stats 7. **Mask storage** — object storage bucket setup for mask PNGs Want me to dive into any particular piece next? I can write up the full OpenAPI spec, start on the migration schema, or dig into the tile server changes.
Author
Owner

Great, I'd like you to create a pull request for nidus sync that contains both a design document and an implementation plan. They should live in a new directory, doc/nidus-reveal/. The design document should cover in detail everything we've discussed here including the architecture, process flow, authentication, and high-level APIs. The implementation document should focus on distinct phases that we can test where we can successively build up layers and ensure that each layer works before moving on to the next layer.

Great, I'd like you to create a pull request for nidus sync that contains both a design document and an implementation plan. They should live in a new directory, `doc/nidus-reveal/`. The design document should cover in detail everything we've discussed here including the architecture, process flow, authentication, and high-level APIs. The implementation document should focus on distinct phases that we can test where we can successively build up layers and ensure that each layer works before moving on to the next layer.
Member

Okaly-dokely do! PR #110 is ready for your review, neighborino!

What's in the PR

doc/nidus-reveal/design.md - The full design document covering:

  • System architecture (nidus-sync left-right arrow GPU workers)
  • Process flow diagrams for pool identification and status monitoring
  • Tile server protocol with 4-band GeoTIFF, band selection, and scale factor
  • Complete API specs for task queue (poll, submit, fail, batch complete, queue status)
  • Worker management API (register, list, detail, shutdown) with per-worker secrets
  • All data models we discussed, updated with your feedback
  • HMAC/Bearer auth flow
  • Full PostGIS schema (vision_workers, vision_task_queue, vision_pools)
  • PlanetScope details (4-band RGB+NIR)
  • Priority queue design (status checks greater than initial ID)

doc/nidus-reveal/implementation.md - Nine-phase implementation plan:

Phase What Why first
1 Schema and migrations Everything depends on this
2 GeoTIFF tile server Parallelizable with 3-6
3 Worker CRUD API Needed for auth
4 HMAC auth middleware Needed for task endpoints
5 Task polling Core worker loop
6 Result submission + retry Completion of polling
7 Operator UI After API is stable
8 Task automation After manual flow works
9 Metrics and cost Last, layered on top

Each phase has a full test plan, dependency mapping, and rollback considerations.

#110

Okaly-dokely do! PR #110 is ready for your review, neighborino! ## What's in the PR **`doc/nidus-reveal/design.md`** - The full design document covering: - System architecture (nidus-sync left-right arrow GPU workers) - Process flow diagrams for pool identification and status monitoring - Tile server protocol with 4-band GeoTIFF, band selection, and scale factor - Complete API specs for task queue (poll, submit, fail, batch complete, queue status) - Worker management API (register, list, detail, shutdown) with per-worker secrets - All data models we discussed, updated with your feedback - HMAC/Bearer auth flow - Full PostGIS schema (vision_workers, vision_task_queue, vision_pools) - PlanetScope details (4-band RGB+NIR) - Priority queue design (status checks greater than initial ID) **`doc/nidus-reveal/implementation.md`** - Nine-phase implementation plan: | Phase | What | Why first | |---|---|---| | 1 | Schema and migrations | Everything depends on this | | 2 | GeoTIFF tile server | Parallelizable with 3-6 | | 3 | Worker CRUD API | Needed for auth | | 4 | HMAC auth middleware | Needed for task endpoints | | 5 | Task polling | Core worker loop | | 6 | Result submission + retry | Completion of polling | | 7 | Operator UI | After API is stable | | 8 | Task automation | After manual flow works | | 9 | Metrics and cost | Last, layered on top | Each phase has a full test plan, dependency mapping, and rollback considerations. https://source.gleipnir.technology/Gleipnir/nidus-sync/pulls/110
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Gleipnir/nidus-sync#108
No description provided.