Mingze commited on
Commit
edf7ac3
·
verified ·
1 Parent(s): b221043

Upgrade to professional LULC workbench

Browse files
.codex-plugin/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "name": "satellite-vision-toolkit",
3
- "version": "0.1.0",
4
- "description": "Detect remote-sensing objects and segment land cover in satellite and aerial imagery.",
5
  "author": {
6
  "name": "Mingze Chen",
7
  "url": "https://github.com/LabMingzeChen"
@@ -12,16 +12,16 @@
12
  "skills": "./skills/",
13
  "interface": {
14
  "displayName": "Satellite Vision Toolkit",
15
- "shortDescription": "Detect objects and map land cover in overhead imagery.",
16
- "longDescription": "Analyze satellite and aerial images with a remote-sensing object detector and an OpenEarthMap land-cover segmentation model, then export visual, tabular, and pixel-coordinate results.",
17
  "developerName": "Mingze Chen",
18
  "category": "Developer Tools",
19
- "capabilities": ["Analyze", "Export"],
20
  "defaultPrompt": [
21
- "Detect objects in this satellite image.",
22
- "Segment land cover and summarize the area share.",
 
23
  "Explain the limits of these remote-sensing results."
24
  ]
25
  }
26
  }
27
-
 
1
  {
2
  "name": "satellite-vision-toolkit",
3
+ "version": "0.2.0",
4
+ "description": "Classify land use and land cover, segment surface classes, and detect objects in satellite and aerial imagery.",
5
  "author": {
6
  "name": "Mingze Chen",
7
  "url": "https://github.com/LabMingzeChen"
 
12
  "skills": "./skills/",
13
  "interface": {
14
  "displayName": "Satellite Vision Toolkit",
15
+ "shortDescription": "Professional multi-level analysis for overhead imagery.",
16
+ "longDescription": "Analyze satellite and aerial images with scene-level EuroSAT LULC classification, OpenEarthMap pixel segmentation, and remote-sensing object detection, then export visual, tabular, JSON, and pixel-coordinate evidence.",
17
  "developerName": "Mingze Chen",
18
  "category": "Developer Tools",
19
+ "capabilities": ["Classify", "Analyze", "Export"],
20
  "defaultPrompt": [
21
+ "Run a complete professional assessment of this satellite image.",
22
+ "Classify the scene into land-use and land-cover categories.",
23
+ "Segment land cover and detect supported objects.",
24
  "Explain the limits of these remote-sensing results."
25
  ]
26
  }
27
  }
 
.gitignore CHANGED
@@ -4,4 +4,3 @@ __pycache__/
4
  .venv/
5
  .DS_Store
6
  outputs/
7
-
 
4
  .venv/
5
  .DS_Store
6
  outputs/
 
LICENSE CHANGED
@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
  SOFTWARE.
22
-
 
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
  SOFTWARE.
 
README.md CHANGED
@@ -9,21 +9,22 @@ python_version: "3.11"
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
- short_description: Satellite object detection and land-cover segmentation.
13
  ---
14
 
15
  <div align="center">
16
 
17
  # 🛰️ Satellite Vision Toolkit
18
 
19
- ### Object detection and pixel-level land-cover mapping for overhead imagery
20
 
21
  [![Hugging Face Space](https://img.shields.io/badge/🤗_Hugging_Face-Live_Demo-FFD21E)](https://huggingface.co/spaces/Mingze/SatelliteVisionToolkit)
22
  [![Detection](https://img.shields.io/badge/Detection-YOLOv8n-2563EB)](https://huggingface.co/bluelabel/satellite-equipment-detection-yolov8n-vhr10)
23
  [![Segmentation](https://img.shields.io/badge/Segmentation-Mask2Former-16A34A)](https://huggingface.co/mfaytin/mask2former-satellite)
 
24
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
25
 
26
- **Upload one satellite or aerial image, detect remote-sensing objects, map land cover, and download reusable evidence.**
27
 
28
  [**🚀 Launch the live app**](https://huggingface.co/spaces/Mingze/SatelliteVisionToolkit) ·
29
  [**💻 GitHub source**](https://github.com/LabMingzeChen/SatelliteVisionToolkit)
@@ -32,24 +33,32 @@ short_description: Satellite object detection and land-cover segmentation.
32
 
33
  ## What it does
34
 
35
- The app provides two complementary analysis modes:
36
 
37
  | Mode | Model | Output vocabulary |
38
  |---|---|---|
 
39
  | Object detection | YOLOv8n fine-tuned on NWPU VHR-10 | airplane, ship, storage tank, baseball diamond, tennis court, basketball court, ground track field, harbor, bridge, vehicle |
40
  | Land-cover segmentation | Mask2Former fine-tuned on OpenEarthMap | background, bare land, grass, pavement, road, tree, water, cropland, building |
41
 
42
  Every run creates visual and machine-readable outputs:
43
 
 
44
  - Detection overlay, per-class summary, per-object CSV, and pixel-coordinate GeoJSON.
45
  - Land-cover overlay, categorical mask, raw class-ID PNG, and class-area CSV.
46
- - Independent Gradio API endpoints at `/detect` and `/segment`.
 
47
  - A reusable Codex skill and command-line Space client.
48
 
49
  ## How it works
50
 
51
  ```text
52
  Satellite or aerial RGB image
 
 
 
 
 
53
  ├── YOLOv8n / NWPU VHR-10
54
  │ ├── labeled bounding-box overlay
55
  │ ├── class counts and confidence
@@ -85,6 +94,12 @@ from gradio_client import Client, handle_file
85
 
86
  client = Client("Mingze/SatelliteVisionToolkit")
87
 
 
 
 
 
 
 
88
  detection = client.predict(
89
  handle_file("satellite.jpg"),
90
  0.25,
@@ -98,13 +113,21 @@ segmentation = client.predict(
98
  0.10,
99
  api_name="/segment",
100
  )
 
 
 
 
 
 
101
  ```
102
 
103
  The bundled CLI wraps the same endpoints:
104
 
105
  ```bash
 
106
  python scripts/satellite_client.py detect satellite.jpg --output detection.json
107
  python scripts/satellite_client.py segment satellite.jpg --output segmentation.json
 
108
  ```
109
 
110
  ## Project structure
@@ -125,6 +148,8 @@ The application code is MIT licensed. Model software, weights, and training data
125
 
126
  | Resource | Role | Terms noted by source |
127
  |---|---|---|
 
 
128
  | [`bluelabel/satellite-equipment-detection-yolov8n-vhr10`](https://huggingface.co/bluelabel/satellite-equipment-detection-yolov8n-vhr10) | Remote-sensing object detector | Model card lists MIT; Ultralytics runtime has separate licensing |
129
  | [NWPU VHR-10](https://gcheng-nwpu.github.io/#Datasets) | Detection training dataset | Review dataset terms and cite its authors |
130
  | [`mfaytin/mask2former-satellite`](https://huggingface.co/mfaytin/mask2former-satellite) | Land-cover segmentation model | Model card lists MIT |
@@ -133,6 +158,8 @@ The application code is MIT licensed. Model software, weights, and training data
133
  ## Limitations and responsible use
134
 
135
  - Results vary with spatial resolution, sensor, geography, season, atmospheric conditions, shadows, and image preprocessing.
 
 
136
  - Small objects may disappear during resizing or fall below the confidence threshold.
137
  - Detection counts describe visible predictions, not complete inventories.
138
  - Segmentation shares describe processed image pixels, not surveyed ground area.
@@ -152,4 +179,3 @@ The application code is MIT licensed. Model software, weights, and training data
152
  ```
153
 
154
  Please also cite NWPU VHR-10, OpenEarthMap, YOLO/Ultralytics, and Mask2Former as applicable.
155
-
 
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
+ short_description: LULC classification, segmentation, and object detection.
13
  ---
14
 
15
  <div align="center">
16
 
17
  # 🛰️ Satellite Vision Toolkit
18
 
19
+ ### Professional scene, pixel, and object-level analysis for overhead imagery
20
 
21
  [![Hugging Face Space](https://img.shields.io/badge/🤗_Hugging_Face-Live_Demo-FFD21E)](https://huggingface.co/spaces/Mingze/SatelliteVisionToolkit)
22
  [![Detection](https://img.shields.io/badge/Detection-YOLOv8n-2563EB)](https://huggingface.co/bluelabel/satellite-equipment-detection-yolov8n-vhr10)
23
  [![Segmentation](https://img.shields.io/badge/Segmentation-Mask2Former-16A34A)](https://huggingface.co/mfaytin/mask2former-satellite)
24
+ [![LULC](https://img.shields.io/badge/LULC-ConvNeXT--Tiny-0F766E)](https://huggingface.co/mrm8488/convnext-tiny-finetuned-eurosat)
25
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
26
 
27
+ **Upload one satellite or aerial image, run a multi-model assessment, and download reusable visual and machine-readable evidence.**
28
 
29
  [**🚀 Launch the live app**](https://huggingface.co/spaces/Mingze/SatelliteVisionToolkit) ·
30
  [**💻 GitHub source**](https://github.com/LabMingzeChen/SatelliteVisionToolkit)
 
33
 
34
  ## What it does
35
 
36
+ The app provides three complementary analytical levels plus a one-click combined workflow:
37
 
38
  | Mode | Model | Output vocabulary |
39
  |---|---|---|
40
+ | Scene-level LULC classification | ConvNeXT-Tiny fine-tuned on EuroSAT | annual crop, forest, herbaceous vegetation, highway, industrial, pasture, permanent crop, residential, river, sea/lake |
41
  | Object detection | YOLOv8n fine-tuned on NWPU VHR-10 | airplane, ship, storage tank, baseball diamond, tennis court, basketball court, ground track field, harbor, bridge, vehicle |
42
  | Land-cover segmentation | Mask2Former fine-tuned on OpenEarthMap | background, bare land, grass, pavement, road, tree, water, cropland, building |
43
 
44
  Every run creates visual and machine-readable outputs:
45
 
46
+ - Ranked LULC probabilities, a confidence tier, normalized entropy, and CSV/JSON exports.
47
  - Detection overlay, per-class summary, per-object CSV, and pixel-coordinate GeoJSON.
48
  - Land-cover overlay, categorical mask, raw class-ID PNG, and class-area CSV.
49
+ - A professional executive dashboard and complete JSON evidence package.
50
+ - Independent Gradio API endpoints at `/classify`, `/segment`, `/detect`, and `/analyze`.
51
  - A reusable Codex skill and command-line Space client.
52
 
53
  ## How it works
54
 
55
  ```text
56
  Satellite or aerial RGB image
57
+ ├── ConvNeXT-Tiny / EuroSAT
58
+ │ ├── ranked scene-level LULC probabilities
59
+ │ ├── normalized uncertainty (entropy)
60
+ │ └── classification CSV + JSON
61
+
62
  ├── YOLOv8n / NWPU VHR-10
63
  │ ├── labeled bounding-box overlay
64
  │ ├── class counts and confidence
 
94
 
95
  client = Client("Mingze/SatelliteVisionToolkit")
96
 
97
+ classification = client.predict(
98
+ handle_file("satellite.jpg"),
99
+ 5,
100
+ api_name="/classify",
101
+ )
102
+
103
  detection = client.predict(
104
  handle_file("satellite.jpg"),
105
  0.25,
 
113
  0.10,
114
  api_name="/segment",
115
  )
116
+
117
+ complete = client.predict(
118
+ handle_file("satellite.jpg"),
119
+ 5, 0.55, 0.10, 0.25, 0.45,
120
+ api_name="/analyze",
121
+ )
122
  ```
123
 
124
  The bundled CLI wraps the same endpoints:
125
 
126
  ```bash
127
+ python scripts/satellite_client.py classify satellite.jpg --output classification.json
128
  python scripts/satellite_client.py detect satellite.jpg --output detection.json
129
  python scripts/satellite_client.py segment satellite.jpg --output segmentation.json
130
+ python scripts/satellite_client.py analyze satellite.jpg --output complete.json
131
  ```
132
 
133
  ## Project structure
 
148
 
149
  | Resource | Role | Terms noted by source |
150
  |---|---|---|
151
+ | [`mrm8488/convnext-tiny-finetuned-eurosat`](https://huggingface.co/mrm8488/convnext-tiny-finetuned-eurosat) | Scene-level LULC classifier | Model card lists Apache-2.0 |
152
+ | [EuroSAT](https://huggingface.co/datasets/GFM-Bench/EuroSAT) | LULC classification dataset | Review dataset terms and cite Helber et al. |
153
  | [`bluelabel/satellite-equipment-detection-yolov8n-vhr10`](https://huggingface.co/bluelabel/satellite-equipment-detection-yolov8n-vhr10) | Remote-sensing object detector | Model card lists MIT; Ultralytics runtime has separate licensing |
154
  | [NWPU VHR-10](https://gcheng-nwpu.github.io/#Datasets) | Detection training dataset | Review dataset terms and cite its authors |
155
  | [`mfaytin/mask2former-satellite`](https://huggingface.co/mfaytin/mask2former-satellite) | Land-cover segmentation model | Model card lists MIT |
 
158
  ## Limitations and responsible use
159
 
160
  - Results vary with spatial resolution, sensor, geography, season, atmospheric conditions, shadows, and image preprocessing.
161
+ - EuroSAT classification is a whole-scene hypothesis learned from small European Sentinel-2 RGB tiles; it is not parcel delineation, zoning, cadastral, or legal land-use evidence.
162
+ - Review ranked alternatives and normalized entropy. A confident prediction can still be wrong under domain shift.
163
  - Small objects may disappear during resizing or fall below the confidence threshold.
164
  - Detection counts describe visible predictions, not complete inventories.
165
  - Segmentation shares describe processed image pixels, not surveyed ground area.
 
179
  ```
180
 
181
  Please also cite NWPU VHR-10, OpenEarthMap, YOLO/Ultralytics, and Mask2Former as applicable.
 
app.py CHANGED
@@ -1,4 +1,4 @@
1
- """Hugging Face Space for satellite object detection and land-cover segmentation."""
2
 
3
  from __future__ import annotations
4
 
@@ -13,7 +13,11 @@ import numpy as np
13
  import torch
14
  from huggingface_hub import hf_hub_download
15
  from PIL import Image
16
- from transformers import AutoImageProcessor, Mask2FormerForUniversalSegmentation
 
 
 
 
17
 
18
  try:
19
  import spaces
@@ -27,18 +31,25 @@ except ImportError:
27
  spaces = _SpacesFallback()
28
 
29
  from satellite_utils import (
 
30
  build_class_table,
31
  build_detection_summary,
32
  build_detection_table,
 
 
33
  render_detections,
 
34
  render_segmentation,
35
  resize_for_inference,
36
  write_class_csv,
37
  write_detection_csv,
 
 
38
  write_pixel_geojson,
39
  )
40
 
41
 
 
42
  SEGMENTATION_MODEL_ID = "mfaytin/mask2former-satellite"
43
  DETECTION_MODEL_ID = "bluelabel/satellite-equipment-detection-yolov8n-vhr10"
44
  DETECTION_FILENAME = "best.pt"
@@ -56,16 +67,25 @@ OPEN_EARTH_MAP_LABELS = {
56
  }
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  @lru_cache(maxsize=1)
60
  def load_segmenter():
61
- torch.set_num_threads(max(1, min(4, os.cpu_count() or 1)))
62
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
63
  processor = AutoImageProcessor.from_pretrained(SEGMENTATION_MODEL_ID)
64
- model = (
65
- Mask2FormerForUniversalSegmentation.from_pretrained(SEGMENTATION_MODEL_ID)
66
- .to(device)
67
- .eval()
68
- )
69
  return processor, model, OPEN_EARTH_MAP_LABELS, device
70
 
71
 
@@ -83,34 +103,62 @@ def _new_output_dir() -> Path:
83
  return output_dir
84
 
85
 
86
- @spaces.GPU(duration=120)
87
- def segment_satellite_image(
88
- image: Image.Image | None,
89
- opacity: float,
90
- min_share_percent: float,
91
- ):
92
  if image is None:
93
  raise gr.Error("Please upload a satellite or aerial image first.")
94
- started_at = time.perf_counter()
95
- prepared = resize_for_inference(image)
96
- try:
97
- processor, model, id2label, device = load_segmenter()
98
- inputs = processor(images=prepared, return_tensors="pt")
99
- inputs = {name: tensor.to(device) for name, tensor in inputs.items()}
100
- with torch.inference_mode():
101
- outputs = model(**inputs)
102
- class_map = processor.post_process_semantic_segmentation(
103
- outputs,
104
- target_sizes=[(prepared.height, prepared.width)],
105
- )[0].cpu().numpy().astype(np.uint8)
106
- except Exception as exc:
107
- raise gr.Error(f"Land-cover segmentation failed: {type(exc).__name__}: {exc}") from exc
108
 
109
- overlay, color_mask = render_segmentation(
110
- prepared, class_map, id2label, float(opacity)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  rows = build_class_table(class_map, id2label, float(min_share_percent))
113
- output_dir = _new_output_dir()
114
  overlay_path = output_dir / "land_cover_overlay.png"
115
  mask_path = output_dir / "land_cover_color_mask.png"
116
  ids_path = output_dir / "land_cover_class_ids.png"
@@ -119,110 +167,250 @@ def segment_satellite_image(
119
  color_mask.save(mask_path)
120
  Image.fromarray(class_map).save(ids_path)
121
  write_class_csv(csv_path, rows)
122
- elapsed = time.perf_counter() - started_at
123
- status = (
124
- f"Done · {prepared.width}×{prepared.height} · "
125
- f"{len(np.unique(class_map))} land-cover classes · {elapsed:.1f}s · device={device.type}"
126
- )
127
- return overlay, color_mask, rows, [str(overlay_path), str(mask_path), str(ids_path), str(csv_path)], status
 
128
 
129
 
130
- @spaces.GPU(duration=120)
131
- def detect_satellite_objects(
132
- image: Image.Image | None,
133
  confidence_threshold: float,
134
  iou_threshold: float,
135
- ):
136
- if image is None:
137
- raise gr.Error("Please upload a satellite or aerial image first.")
138
- started_at = time.perf_counter()
139
- prepared = resize_for_inference(image)
140
  device = "cuda" if torch.cuda.is_available() else "cpu"
141
- try:
142
- detector = load_detector()
143
- prediction = detector.predict(
144
- source=np.asarray(prepared),
145
- conf=float(confidence_threshold),
146
- iou=float(iou_threshold),
147
- imgsz=1024,
148
- device=device,
149
- max_det=500,
150
- verbose=False,
151
- )[0]
152
- detections: list[dict[str, object]] = []
153
- if prediction.boxes is not None:
154
- for coordinates, confidence, class_id_value in zip(
155
- prediction.boxes.xyxy.detach().cpu().tolist(),
156
- prediction.boxes.conf.detach().cpu().tolist(),
157
- prediction.boxes.cls.detach().cpu().tolist(),
158
- ):
159
- class_id = int(class_id_value)
160
- detections.append(
161
- {
162
- "class_id": class_id,
163
- "class_name": str(prediction.names[class_id]),
164
- "confidence": float(confidence),
165
- "x1": float(coordinates[0]),
166
- "y1": float(coordinates[1]),
167
- "x2": float(coordinates[2]),
168
- "y2": float(coordinates[3]),
169
- }
170
- )
171
- except Exception as exc:
172
- raise gr.Error(f"Satellite object detection failed: {type(exc).__name__}: {exc}") from exc
173
-
174
  overlay = render_detections(prepared, detections)
175
  summary_rows = build_detection_summary(detections)
176
  detail_rows = build_detection_table(detections, prepared.size)
177
- output_dir = _new_output_dir()
178
  overlay_path = output_dir / "satellite_detection_overlay.png"
179
  csv_path = output_dir / "satellite_detections.csv"
180
  geojson_path = output_dir / "satellite_detections_pixel_coordinates.geojson"
181
  overlay.save(overlay_path)
182
  write_detection_csv(csv_path, detail_rows)
183
  write_pixel_geojson(geojson_path, detections, prepared.size)
184
- elapsed = time.perf_counter() - started_at
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  status = (
186
- f"Done · {prepared.width}×{prepared.height} · {len(detections)} objects · "
187
- f"{len(summary_rows)} classes · {elapsed:.1f}s · device={device}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  )
189
- return overlay, summary_rows, detail_rows, [str(overlay_path), str(csv_path), str(geojson_path)], status
190
 
191
 
192
  CSS = """
193
- .gradio-container {max-width: 1280px !important;}
194
- .hero {text-align: center; margin: 0 auto 1rem;}
195
- .hero h1 {font-size: 2.15rem; margin-bottom: .3rem;}
196
- .note {color: #64748b;}
 
 
 
 
 
 
 
 
 
 
 
 
197
  """
198
 
199
- with gr.Blocks(title="Satellite Vision Toolkit", css=CSS, theme=gr.themes.Soft()) as demo:
 
200
  gr.HTML("""
201
  <div class="hero">
202
- <h1>🛰️ Satellite Vision Toolkit</h1>
203
- <p>Detect remote-sensing objects and map land cover from one satellite or aerial image.</p>
204
- <p><a href="https://github.com/LabMingzeChen/SatelliteVisionToolkit">GitHub</a> ·
205
- <a href="https://huggingface.co/mfaytin/mask2former-satellite">Segmentation model</a> ·
206
- <a href="https://huggingface.co/bluelabel/satellite-equipment-detection-yolov8n-vhr10">Detection model</a></p>
 
 
 
 
207
  </div>
208
  """)
209
- with gr.Row():
210
- image_input = gr.Image(type="pil", label="Satellite / aerial image", height=420)
211
  with gr.Column():
212
- gr.Markdown("""
213
- ### Supported analysis
 
 
 
 
 
214
 
215
- - **Object detection:** airplane, ship, storage tank, sports fields/courts, harbor, bridge, and vehicle.
216
- - **Land-cover segmentation:** background, bare land, grass, pavement, road, tree, water, cropland, and building.
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
- RGB PNG/JPEG/WebP/TIFF images work best. Results are image-space estimates, not surveyed GIS data.
219
- """)
 
 
 
 
 
 
 
 
 
220
 
221
- with gr.Tabs():
222
  with gr.Tab("Land-cover segmentation"):
223
- with gr.Row():
224
- opacity = gr.Slider(0.1, 0.9, value=0.55, step=0.05, label="Overlay opacity")
225
- min_share = gr.Slider(0.0, 5.0, value=0.1, step=0.1, label="Minimum table share (%)")
226
  segment_button = gr.Button("Segment land cover", variant="primary")
227
  segment_status = gr.Markdown()
228
  with gr.Row():
@@ -230,22 +418,18 @@ RGB PNG/JPEG/WebP/TIFF images work best. Results are image-space estimates, not
230
  segment_mask = gr.Image(label="Categorical mask")
231
  segment_table = gr.Dataframe(
232
  headers=["Class ID", "Class", "Pixels", "Share (%)", "Color"],
233
- datatype=["number", "str", "number", "number", "str"],
234
  interactive=False,
235
  label="Land-cover area summary",
236
  )
237
  segment_files = gr.File(label="Download segmentation outputs", file_count="multiple")
238
 
239
  with gr.Tab("Object detection"):
240
- with gr.Row():
241
- confidence = gr.Slider(0.05, 0.9, value=0.25, step=0.05, label="Confidence threshold")
242
- iou = gr.Slider(0.1, 0.9, value=0.45, step=0.05, label="NMS IoU threshold")
243
  detect_button = gr.Button("Detect satellite objects", variant="primary")
244
  detect_status = gr.Markdown()
245
  detect_overlay = gr.Image(label="Detection overlay")
246
  detection_summary = gr.Dataframe(
247
  headers=["Class", "Count", "Average confidence", "Maximum confidence"],
248
- datatype=["str", "number", "number", "number"],
249
  interactive=False,
250
  label="Detection summary",
251
  )
@@ -256,10 +440,25 @@ RGB PNG/JPEG/WebP/TIFF images work best. Results are image-space estimates, not
256
  )
257
  detection_files = gr.File(label="Download detection outputs", file_count="multiple")
258
 
259
- gr.Markdown("""
260
- > **Responsible use:** Models can miss small objects and may generalize poorly across sensors, regions, seasons, cloud cover, and ground resolution. Pixel-coordinate GeoJSON is not georeferenced. Do not use outputs for navigation, surveillance, legal boundaries, emergency response, or other safety-critical decisions without qualified review.
261
- """)
 
 
 
 
 
 
 
 
 
262
 
 
 
 
 
 
 
263
  segment_button.click(
264
  segment_satellite_image,
265
  inputs=[image_input, opacity, min_share],
@@ -272,6 +471,24 @@ RGB PNG/JPEG/WebP/TIFF images work best. Results are image-space estimates, not
272
  outputs=[detect_overlay, detection_summary, detection_details, detection_files, detect_status],
273
  api_name="detect",
274
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
 
277
  if __name__ == "__main__":
 
1
+ """Professional Hugging Face Space for multi-level satellite image analysis."""
2
 
3
  from __future__ import annotations
4
 
 
13
  import torch
14
  from huggingface_hub import hf_hub_download
15
  from PIL import Image
16
+ from transformers import (
17
+ AutoImageProcessor,
18
+ AutoModelForImageClassification,
19
+ Mask2FormerForUniversalSegmentation,
20
+ )
21
 
22
  try:
23
  import spaces
 
31
  spaces = _SpacesFallback()
32
 
33
  from satellite_utils import (
34
+ build_analysis_summary,
35
  build_class_table,
36
  build_detection_summary,
37
  build_detection_table,
38
+ build_lulc_table,
39
+ normalized_entropy,
40
  render_detections,
41
+ render_lulc_assessment,
42
  render_segmentation,
43
  resize_for_inference,
44
  write_class_csv,
45
  write_detection_csv,
46
+ write_json,
47
+ write_lulc_csv,
48
  write_pixel_geojson,
49
  )
50
 
51
 
52
+ CLASSIFICATION_MODEL_ID = "mrm8488/convnext-tiny-finetuned-eurosat"
53
  SEGMENTATION_MODEL_ID = "mfaytin/mask2former-satellite"
54
  DETECTION_MODEL_ID = "bluelabel/satellite-equipment-detection-yolov8n-vhr10"
55
  DETECTION_FILENAME = "best.pt"
 
67
  }
68
 
69
 
70
+ def _device() -> torch.device:
71
+ torch.set_num_threads(max(1, min(4, os.cpu_count() or 1)))
72
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
73
+
74
+
75
+ @lru_cache(maxsize=1)
76
+ def load_classifier():
77
+ device = _device()
78
+ processor = AutoImageProcessor.from_pretrained(CLASSIFICATION_MODEL_ID)
79
+ model = AutoModelForImageClassification.from_pretrained(CLASSIFICATION_MODEL_ID).to(device).eval()
80
+ id2label = {int(key): str(value) for key, value in model.config.id2label.items()}
81
+ return processor, model, id2label, device
82
+
83
+
84
  @lru_cache(maxsize=1)
85
  def load_segmenter():
86
+ device = _device()
 
87
  processor = AutoImageProcessor.from_pretrained(SEGMENTATION_MODEL_ID)
88
+ model = Mask2FormerForUniversalSegmentation.from_pretrained(SEGMENTATION_MODEL_ID).to(device).eval()
 
 
 
 
89
  return processor, model, OPEN_EARTH_MAP_LABELS, device
90
 
91
 
 
103
  return output_dir
104
 
105
 
106
+ def _require_image(image: Image.Image | None) -> Image.Image:
 
 
 
 
 
107
  if image is None:
108
  raise gr.Error("Please upload a satellite or aerial image first.")
109
+ return resize_for_inference(image)
110
+
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
+ def _classify_impl(prepared: Image.Image, top_k: int, output_dir: Path) -> dict[str, object]:
113
+ processor, model, id2label, device = load_classifier()
114
+ inputs = {name: tensor.to(device) for name, tensor in processor(images=prepared, return_tensors="pt").items()}
115
+ with torch.inference_mode():
116
+ logits = model(**inputs).logits[0]
117
+ probabilities = torch.softmax(logits, dim=-1).detach().cpu().tolist()
118
+ rows = build_lulc_table(probabilities, id2label, top_k)
119
+ entropy = normalized_entropy(probabilities)
120
+ csv_path = output_dir / "lulc_classification.csv"
121
+ json_path = output_dir / "lulc_classification.json"
122
+ write_lulc_csv(csv_path, rows)
123
+ write_json(
124
+ json_path,
125
+ {
126
+ "task": "scene_level_lulc_classification",
127
+ "model": CLASSIFICATION_MODEL_ID,
128
+ "processed_image_size": {"width": prepared.width, "height": prepared.height},
129
+ "normalized_entropy": round(entropy, 6),
130
+ "predictions": [
131
+ {"rank": row[0], "class": row[1], "probability_percent": row[2], "confidence_tier": row[3]}
132
+ for row in rows
133
+ ],
134
+ "scope_note": "Whole-scene EuroSAT class; not a cadastral or planning land-use designation.",
135
+ },
136
  )
137
+ return {
138
+ "rows": rows,
139
+ "entropy": entropy,
140
+ "assessment": render_lulc_assessment(rows, entropy),
141
+ "files": [str(csv_path), str(json_path)],
142
+ "device": device.type,
143
+ }
144
+
145
+
146
+ def _segment_impl(
147
+ prepared: Image.Image,
148
+ opacity: float,
149
+ min_share_percent: float,
150
+ output_dir: Path,
151
+ ) -> dict[str, object]:
152
+ processor, model, id2label, device = load_segmenter()
153
+ inputs = {name: tensor.to(device) for name, tensor in processor(images=prepared, return_tensors="pt").items()}
154
+ with torch.inference_mode():
155
+ outputs = model(**inputs)
156
+ class_map = processor.post_process_semantic_segmentation(
157
+ outputs,
158
+ target_sizes=[(prepared.height, prepared.width)],
159
+ )[0].cpu().numpy().astype(np.uint8)
160
+ overlay, color_mask = render_segmentation(prepared, class_map, id2label, float(opacity))
161
  rows = build_class_table(class_map, id2label, float(min_share_percent))
 
162
  overlay_path = output_dir / "land_cover_overlay.png"
163
  mask_path = output_dir / "land_cover_color_mask.png"
164
  ids_path = output_dir / "land_cover_class_ids.png"
 
167
  color_mask.save(mask_path)
168
  Image.fromarray(class_map).save(ids_path)
169
  write_class_csv(csv_path, rows)
170
+ return {
171
+ "overlay": overlay,
172
+ "mask": color_mask,
173
+ "rows": rows,
174
+ "files": [str(overlay_path), str(mask_path), str(ids_path), str(csv_path)],
175
+ "device": device.type,
176
+ }
177
 
178
 
179
+ def _detect_impl(
180
+ prepared: Image.Image,
 
181
  confidence_threshold: float,
182
  iou_threshold: float,
183
+ output_dir: Path,
184
+ ) -> dict[str, object]:
 
 
 
185
  device = "cuda" if torch.cuda.is_available() else "cpu"
186
+ detector = load_detector()
187
+ prediction = detector.predict(
188
+ source=np.asarray(prepared),
189
+ conf=float(confidence_threshold),
190
+ iou=float(iou_threshold),
191
+ imgsz=1024,
192
+ device=device,
193
+ max_det=500,
194
+ verbose=False,
195
+ )[0]
196
+ detections: list[dict[str, object]] = []
197
+ if prediction.boxes is not None:
198
+ for coordinates, confidence, class_id_value in zip(
199
+ prediction.boxes.xyxy.detach().cpu().tolist(),
200
+ prediction.boxes.conf.detach().cpu().tolist(),
201
+ prediction.boxes.cls.detach().cpu().tolist(),
202
+ ):
203
+ class_id = int(class_id_value)
204
+ detections.append(
205
+ {
206
+ "class_id": class_id,
207
+ "class_name": str(prediction.names[class_id]),
208
+ "confidence": float(confidence),
209
+ "x1": float(coordinates[0]),
210
+ "y1": float(coordinates[1]),
211
+ "x2": float(coordinates[2]),
212
+ "y2": float(coordinates[3]),
213
+ }
214
+ )
 
 
 
 
215
  overlay = render_detections(prepared, detections)
216
  summary_rows = build_detection_summary(detections)
217
  detail_rows = build_detection_table(detections, prepared.size)
 
218
  overlay_path = output_dir / "satellite_detection_overlay.png"
219
  csv_path = output_dir / "satellite_detections.csv"
220
  geojson_path = output_dir / "satellite_detections_pixel_coordinates.geojson"
221
  overlay.save(overlay_path)
222
  write_detection_csv(csv_path, detail_rows)
223
  write_pixel_geojson(geojson_path, detections, prepared.size)
224
+ return {
225
+ "overlay": overlay,
226
+ "summary": summary_rows,
227
+ "details": detail_rows,
228
+ "files": [str(overlay_path), str(csv_path), str(geojson_path)],
229
+ "device": device,
230
+ }
231
+
232
+
233
+ @spaces.GPU(duration=120)
234
+ def classify_lulc(image: Image.Image | None, top_k: int):
235
+ started_at = time.perf_counter()
236
+ prepared = _require_image(image)
237
+ try:
238
+ result = _classify_impl(prepared, int(top_k), _new_output_dir())
239
+ except Exception as exc:
240
+ raise gr.Error(f"LULC classification failed: {type(exc).__name__}: {exc}") from exc
241
+ status = (
242
+ f"Complete · {prepared.width}×{prepared.height} · top class {result['rows'][0][1]} "
243
+ f"({result['rows'][0][2]:.1f}%) · {time.perf_counter() - started_at:.1f}s · device={result['device']}"
244
+ )
245
+ return result["assessment"], result["rows"], result["files"], status
246
+
247
+
248
+ @spaces.GPU(duration=120)
249
+ def segment_satellite_image(image: Image.Image | None, opacity: float, min_share_percent: float):
250
+ started_at = time.perf_counter()
251
+ prepared = _require_image(image)
252
+ try:
253
+ result = _segment_impl(prepared, opacity, min_share_percent, _new_output_dir())
254
+ except Exception as exc:
255
+ raise gr.Error(f"Land-cover segmentation failed: {type(exc).__name__}: {exc}") from exc
256
  status = (
257
+ f"Complete · {prepared.width}×{prepared.height} · {len(result['rows'])} reported cover classes · "
258
+ f"{time.perf_counter() - started_at:.1f}s · device={result['device']}"
259
+ )
260
+ return result["overlay"], result["mask"], result["rows"], result["files"], status
261
+
262
+
263
+ @spaces.GPU(duration=120)
264
+ def detect_satellite_objects(image: Image.Image | None, confidence_threshold: float, iou_threshold: float):
265
+ started_at = time.perf_counter()
266
+ prepared = _require_image(image)
267
+ try:
268
+ result = _detect_impl(prepared, confidence_threshold, iou_threshold, _new_output_dir())
269
+ except Exception as exc:
270
+ raise gr.Error(f"Satellite object detection failed: {type(exc).__name__}: {exc}") from exc
271
+ status = (
272
+ f"Complete · {prepared.width}×{prepared.height} · {len(result['details'])} objects · "
273
+ f"{len(result['summary'])} classes · {time.perf_counter() - started_at:.1f}s · device={result['device']}"
274
+ )
275
+ return result["overlay"], result["summary"], result["details"], result["files"], status
276
+
277
+
278
+ @spaces.GPU(duration=180)
279
+ def analyze_satellite_image(
280
+ image: Image.Image | None,
281
+ top_k: int,
282
+ opacity: float,
283
+ min_share_percent: float,
284
+ confidence_threshold: float,
285
+ iou_threshold: float,
286
+ ):
287
+ started_at = time.perf_counter()
288
+ prepared = _require_image(image)
289
+ output_dir = _new_output_dir()
290
+ try:
291
+ classification = _classify_impl(prepared, int(top_k), output_dir)
292
+ segmentation = _segment_impl(prepared, opacity, min_share_percent, output_dir)
293
+ detection = _detect_impl(prepared, confidence_threshold, iou_threshold, output_dir)
294
+ except Exception as exc:
295
+ raise gr.Error(f"Complete analysis failed: {type(exc).__name__}: {exc}") from exc
296
+ elapsed = time.perf_counter() - started_at
297
+ summary = build_analysis_summary(
298
+ classification["rows"],
299
+ float(classification["entropy"]),
300
+ segmentation["rows"],
301
+ detection["summary"],
302
+ elapsed,
303
+ )
304
+ report_path = output_dir / "analysis_report.json"
305
+ write_json(
306
+ report_path,
307
+ {
308
+ "processed_image_size": {"width": prepared.width, "height": prepared.height},
309
+ "models": {
310
+ "classification": CLASSIFICATION_MODEL_ID,
311
+ "segmentation": SEGMENTATION_MODEL_ID,
312
+ "detection": DETECTION_MODEL_ID,
313
+ },
314
+ "lulc_classification": classification["rows"],
315
+ "lulc_normalized_entropy": round(float(classification["entropy"]), 6),
316
+ "land_cover_pixel_shares": segmentation["rows"],
317
+ "detection_summary": detection["summary"],
318
+ "detection_details": detection["details"],
319
+ "elapsed_seconds": round(elapsed, 3),
320
+ "coordinate_note": "Detection GeoJSON is in top-left-origin image pixels and has no geographic CRS.",
321
+ },
322
+ )
323
+ files = classification["files"] + segmentation["files"] + detection["files"] + [str(report_path)]
324
+ status = f"Complete multi-model assessment · {prepared.width}×{prepared.height} · {elapsed:.1f}s"
325
+ return (
326
+ summary,
327
+ classification["assessment"],
328
+ classification["rows"],
329
+ segmentation["overlay"],
330
+ segmentation["mask"],
331
+ segmentation["rows"],
332
+ detection["overlay"],
333
+ detection["summary"],
334
+ detection["details"],
335
+ files,
336
+ status,
337
  )
 
338
 
339
 
340
  CSS = """
341
+ .gradio-container {max-width: 1440px !important; background: #f6f8fb;}
342
+ .hero {padding: 2rem; border-radius: 22px; color: white; background: linear-gradient(125deg,#071c33,#0a4b5c 56%,#198f75); box-shadow: 0 18px 44px rgba(7,28,51,.18); margin-bottom: 1rem;}
343
+ .hero h1 {font-size: 2.35rem; margin: 0 0 .35rem; letter-spacing: -.03em;}
344
+ .hero p {max-width: 850px; margin: .35rem 0; color: #d8f3ee;}
345
+ .hero a {color: #fff; font-weight: 650;}
346
+ .pipeline {display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:14px 0 20px;}
347
+ .pipeline div,.assessment-card,.metric-card {background:white;border:1px solid #dce6ed;border-radius:16px;padding:16px;box-shadow:0 6px 18px rgba(20,50,70,.06);}
348
+ .pipeline b {display:block;color:#0c5262;margin-bottom:5px}.pipeline span,.micro-note {color:#667985;font-size:.87rem;}
349
+ .summary-grid {display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:12px 0;}
350
+ .metric-card span,.eyebrow {display:block;color:#66808c;font-size:.72rem;font-weight:750;letter-spacing:.1em;text-transform:uppercase;}
351
+ .metric-card strong {display:block;font-size:1.45rem;margin:6px 0;color:#113544;}.metric-card small {color:#60747e;}
352
+ .assessment-card h2 {margin:.25rem 0;color:#123c49}.assessment-card p {color:#526b76;}
353
+ .prob-row {display:grid;grid-template-columns:155px 1fr 62px;gap:10px;align-items:center;margin:8px 0;font-size:.86rem;}
354
+ .prob-row b {text-align:right}.prob-track {height:9px;background:#e5edf1;border-radius:20px;overflow:hidden}.prob-track i {display:block;height:100%;background:linear-gradient(90deg,#169c7d,#36b7c5);border-radius:20px;}
355
+ .section-note {padding:12px 14px;border-left:4px solid #15947a;background:#eef9f6;border-radius:8px;color:#315c62;}
356
+ @media(max-width:850px){.pipeline,.summary-grid{grid-template-columns:1fr}.prob-row{grid-template-columns:115px 1fr 56px}}
357
  """
358
 
359
+
360
+ with gr.Blocks(title="Satellite Vision Toolkit Pro", css=CSS, theme=gr.themes.Soft()) as demo:
361
  gr.HTML("""
362
  <div class="hero">
363
+ <div class="eyebrow" style="color:#8ee5d2">REMOTE SENSING DECISION SUPPORT</div>
364
+ <h1>🛰️ Satellite Vision Toolkit Pro</h1>
365
+ <p>A multi-level workbench for scene-level land-use/land-cover classification, pixel-level cover mapping, and overhead object detection.</p>
366
+ <p><a href="https://github.com/LabMingzeChen/SatelliteVisionToolkit">GitHub</a> · <a href="https://huggingface.co/mrm8488/convnext-tiny-finetuned-eurosat">LULC model</a> · <a href="https://huggingface.co/mfaytin/mask2former-satellite">Segmentation model</a> · <a href="https://huggingface.co/bluelabel/satellite-equipment-detection-yolov8n-vhr10">Detection model</a></p>
367
+ </div>
368
+ <div class="pipeline">
369
+ <div><b>01 · Scene classification</b><span>EuroSAT probability profile across 10 LULC scene types.</span></div>
370
+ <div><b>02 · Semantic segmentation</b><span>Per-pixel OpenEarthMap land-cover composition and masks.</span></div>
371
+ <div><b>03 · Object detection</b><span>Bounding boxes and inventory-style summaries for 10 VHR object types.</span></div>
372
  </div>
373
  """)
374
+ with gr.Row(equal_height=True):
375
+ image_input = gr.Image(type="pil", label="Satellite / aerial RGB image", height=430)
376
  with gr.Column():
377
+ gr.Markdown("### Analysis controls\nTune reproducible thresholds, then run the complete assessment or an individual method.")
378
+ top_k = gr.Slider(3, 10, value=5, step=1, label="LULC alternatives (top-k)")
379
+ opacity = gr.Slider(0.1, 0.9, value=0.55, step=0.05, label="Segmentation overlay opacity")
380
+ min_share = gr.Slider(0.0, 5.0, value=0.1, step=0.1, label="Minimum reported cover share (%)")
381
+ confidence = gr.Slider(0.05, 0.9, value=0.25, step=0.05, label="Detection confidence threshold")
382
+ iou = gr.Slider(0.1, 0.9, value=0.45, step=0.05, label="Detection NMS IoU threshold")
383
+ analyze_button = gr.Button("Run complete professional assessment", variant="primary", size="lg")
384
 
385
+ with gr.Tabs():
386
+ with gr.Tab("Executive overview"):
387
+ analysis_status = gr.Markdown()
388
+ executive_summary = gr.HTML()
389
+ overview_lulc = gr.HTML()
390
+ overview_lulc_table = gr.Dataframe(
391
+ headers=["Rank", "LULC class", "Probability (%)", "Confidence tier"],
392
+ interactive=False,
393
+ label="Scene classification probability profile",
394
+ )
395
+ with gr.Row():
396
+ overview_segment = gr.Image(label="Pixel-level land-cover overlay")
397
+ overview_detection = gr.Image(label="Detected objects")
398
+ overview_files = gr.File(label="Download complete evidence package", file_count="multiple")
399
 
400
+ with gr.Tab("LULC classification"):
401
+ gr.Markdown("<div class='section-note'><b>Scene-level interpretation.</b> Assigns the whole image to EuroSAT land-use/land-cover classes. This is distinct from pixel segmentation and is not a legal land-use designation.</div>")
402
+ classify_button = gr.Button("Classify scene LULC", variant="primary")
403
+ classify_status = gr.Markdown()
404
+ classification_assessment = gr.HTML()
405
+ classification_table = gr.Dataframe(
406
+ headers=["Rank", "LULC class", "Probability (%)", "Confidence tier"],
407
+ interactive=False,
408
+ label="Ranked LULC alternatives",
409
+ )
410
+ classification_files = gr.File(label="Download classification CSV / JSON", file_count="multiple")
411
 
 
412
  with gr.Tab("Land-cover segmentation"):
413
+ gr.Markdown("<div class='section-note'><b>Pixel-level interpretation.</b> Maps nine OpenEarthMap surface classes and reports image-pixel composition.</div>")
 
 
414
  segment_button = gr.Button("Segment land cover", variant="primary")
415
  segment_status = gr.Markdown()
416
  with gr.Row():
 
418
  segment_mask = gr.Image(label="Categorical mask")
419
  segment_table = gr.Dataframe(
420
  headers=["Class ID", "Class", "Pixels", "Share (%)", "Color"],
 
421
  interactive=False,
422
  label="Land-cover area summary",
423
  )
424
  segment_files = gr.File(label="Download segmentation outputs", file_count="multiple")
425
 
426
  with gr.Tab("Object detection"):
427
+ gr.Markdown("<div class='section-note'><b>Instance-level interpretation.</b> Locates supported objects with confidence-filtered bounding boxes.</div>")
 
 
428
  detect_button = gr.Button("Detect satellite objects", variant="primary")
429
  detect_status = gr.Markdown()
430
  detect_overlay = gr.Image(label="Detection overlay")
431
  detection_summary = gr.Dataframe(
432
  headers=["Class", "Count", "Average confidence", "Maximum confidence"],
 
433
  interactive=False,
434
  label="Detection summary",
435
  )
 
440
  )
441
  detection_files = gr.File(label="Download detection outputs", file_count="multiple")
442
 
443
+ with gr.Tab("Methodology & scope"):
444
+ gr.Markdown("""
445
+ ### Analytical hierarchy
446
+
447
+ | Level | Question answered | Model / training domain | Output |
448
+ |---|---|---|---|
449
+ | Scene | What broad LULC type best characterizes this image? | ConvNeXT-Tiny / EuroSAT Sentinel-2 RGB | Ranked probabilities + entropy |
450
+ | Pixel | Which cover class is predicted at each pixel? | Mask2Former / OpenEarthMap | Overlay, mask, pixel shares |
451
+ | Object | Where are supported discrete objects? | YOLOv8n / NWPU VHR-10 | Boxes, counts, CSV, pixel GeoJSON |
452
+
453
+ **Interpretation guardrails:** EuroSAT is a European Sentinel-2 scene dataset; classification may shift on other sensors, regions, resolutions, or crops. Pixel shares are not automatically physical ground-area shares. Pixel-coordinate GeoJSON is not georeferenced. Models can miss small or obscured objects. Do not use outputs alone for legal, surveillance, emergency, navigation, or safety-critical decisions.
454
+ """)
455
 
456
+ classify_button.click(
457
+ classify_lulc,
458
+ inputs=[image_input, top_k],
459
+ outputs=[classification_assessment, classification_table, classification_files, classify_status],
460
+ api_name="classify",
461
+ )
462
  segment_button.click(
463
  segment_satellite_image,
464
  inputs=[image_input, opacity, min_share],
 
471
  outputs=[detect_overlay, detection_summary, detection_details, detection_files, detect_status],
472
  api_name="detect",
473
  )
474
+ analyze_button.click(
475
+ analyze_satellite_image,
476
+ inputs=[image_input, top_k, opacity, min_share, confidence, iou],
477
+ outputs=[
478
+ executive_summary,
479
+ overview_lulc,
480
+ overview_lulc_table,
481
+ overview_segment,
482
+ segment_mask,
483
+ segment_table,
484
+ overview_detection,
485
+ detection_summary,
486
+ detection_details,
487
+ overview_files,
488
+ analysis_status,
489
+ ],
490
+ api_name="analyze",
491
+ )
492
 
493
 
494
  if __name__ == "__main__":
satellite_utils.py CHANGED
@@ -3,7 +3,9 @@
3
  from __future__ import annotations
4
 
5
  import csv
 
6
  import json
 
7
  from collections import defaultdict
8
  from pathlib import Path
9
  from typing import Iterable
@@ -27,11 +29,119 @@ LAND_COVER_PALETTE: dict[str, tuple[int, int, int]] = {
27
  "building": (218, 73, 73),
28
  }
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  def normalize_label(label: str) -> str:
32
  return label.lower().replace("_", " ").strip()
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def fallback_color(class_id: int) -> tuple[int, int, int]:
36
  return (
37
  int((67 * class_id + 41) % 190 + 35),
@@ -257,4 +367,3 @@ def write_pixel_geojson(
257
  "features": features,
258
  }
259
  path.write_text(json.dumps(collection, indent=2), encoding="utf-8")
260
-
 
3
  from __future__ import annotations
4
 
5
  import csv
6
+ import html
7
  import json
8
+ import math
9
  from collections import defaultdict
10
  from pathlib import Path
11
  from typing import Iterable
 
29
  "building": (218, 73, 73),
30
  }
31
 
32
+ LULC_DISPLAY_NAMES = {
33
+ "annualcrop": "Annual crop",
34
+ "forest": "Forest",
35
+ "herbaceousvegetation": "Herbaceous vegetation",
36
+ "highway": "Highway",
37
+ "industrial": "Industrial",
38
+ "pasture": "Pasture",
39
+ "permanentcrop": "Permanent crop",
40
+ "residential": "Residential",
41
+ "river": "River",
42
+ "sealake": "Sea / lake",
43
+ }
44
+
45
 
46
  def normalize_label(label: str) -> str:
47
  return label.lower().replace("_", " ").strip()
48
 
49
 
50
+ def display_lulc_label(label: str) -> str:
51
+ """Convert EuroSAT model labels into compact report labels."""
52
+ key = "".join(character for character in label.lower() if character.isalnum())
53
+ return LULC_DISPLAY_NAMES.get(key, label.replace("_", " ").strip().title())
54
+
55
+
56
+ def confidence_tier(probability: float) -> str:
57
+ if probability >= 0.80:
58
+ return "High"
59
+ if probability >= 0.55:
60
+ return "Moderate"
61
+ return "Low"
62
+
63
+
64
+ def normalized_entropy(probabilities: Iterable[float]) -> float:
65
+ """Return Shannon entropy normalized to 0–1 for model ambiguity."""
66
+ values = [max(0.0, float(value)) for value in probabilities]
67
+ total = sum(values)
68
+ if not values or total <= 0.0 or len(values) == 1:
69
+ return 0.0
70
+ normalized = [value / total for value in values if value > 0.0]
71
+ entropy = -sum(value * math.log(value) for value in normalized)
72
+ return entropy / math.log(len(values))
73
+
74
+
75
+ def build_lulc_table(
76
+ probabilities: Iterable[float],
77
+ id2label: dict[int, str],
78
+ top_k: int = 5,
79
+ ) -> list[list[object]]:
80
+ ranked = sorted(
81
+ enumerate(float(value) for value in probabilities),
82
+ key=lambda item: item[1],
83
+ reverse=True,
84
+ )[: max(1, int(top_k))]
85
+ return [
86
+ [rank, display_lulc_label(id2label.get(class_id, f"class_{class_id}")), round(score * 100, 2), confidence_tier(score)]
87
+ for rank, (class_id, score) in enumerate(ranked, start=1)
88
+ ]
89
+
90
+
91
+ def render_lulc_assessment(rows: list[list[object]], entropy: float) -> str:
92
+ """Render an accessible probability profile and uncertainty note."""
93
+ if not rows:
94
+ return "<div class='assessment-card'>No classification result.</div>"
95
+ top_probability = float(rows[0][2])
96
+ bars = "".join(
97
+ "<div class='prob-row'><span>{}</span><div class='prob-track'><i style='width:{:.2f}%'></i></div><b>{:.2f}%</b></div>".format(
98
+ html.escape(str(row[1])), float(row[2]), float(row[2])
99
+ )
100
+ for row in rows
101
+ )
102
+ ambiguity = "low" if entropy < 0.35 else "moderate" if entropy < 0.65 else "high"
103
+ return (
104
+ "<div class='assessment-card'>"
105
+ f"<div class='eyebrow'>SCENE-LEVEL LULC</div><h2>{html.escape(str(rows[0][1]))}</h2>"
106
+ f"<p><strong>{top_probability:.2f}%</strong> top-class confidence · "
107
+ f"{ambiguity} ambiguity (normalized entropy {entropy:.2f})</p>{bars}"
108
+ "<p class='micro-note'>A whole-scene EuroSAT label, not a cadastral or planning designation.</p></div>"
109
+ )
110
+
111
+
112
+ def build_analysis_summary(
113
+ lulc_rows: list[list[object]],
114
+ entropy: float,
115
+ land_cover_rows: list[list[object]],
116
+ detection_rows: list[list[object]],
117
+ elapsed_seconds: float,
118
+ ) -> str:
119
+ lulc_name = str(lulc_rows[0][1]) if lulc_rows else "Unavailable"
120
+ lulc_confidence = float(lulc_rows[0][2]) if lulc_rows else 0.0
121
+ cover_name = str(land_cover_rows[0][1]) if land_cover_rows else "Unavailable"
122
+ cover_share = float(land_cover_rows[0][3]) if land_cover_rows else 0.0
123
+ object_count = sum(int(row[1]) for row in detection_rows)
124
+ return f"""
125
+ <div class="summary-grid">
126
+ <div class="metric-card"><span>Scene LULC</span><strong>{html.escape(lulc_name)}</strong><small>{lulc_confidence:.1f}% confidence · entropy {entropy:.2f}</small></div>
127
+ <div class="metric-card"><span>Dominant cover</span><strong>{html.escape(cover_name)}</strong><small>{cover_share:.1f}% of processed pixels</small></div>
128
+ <div class="metric-card"><span>Detected objects</span><strong>{object_count}</strong><small>{len(detection_rows)} represented object classes</small></div>
129
+ <div class="metric-card"><span>Analysis time</span><strong>{elapsed_seconds:.1f}s</strong><small>classification + segmentation + detection</small></div>
130
+ </div>
131
+ """
132
+
133
+
134
+ def write_lulc_csv(path: Path, rows: Iterable[Iterable[object]]) -> None:
135
+ with path.open("w", newline="", encoding="utf-8") as handle:
136
+ writer = csv.writer(handle)
137
+ writer.writerow(["rank", "class", "probability_percent", "confidence_tier"])
138
+ writer.writerows(rows)
139
+
140
+
141
+ def write_json(path: Path, payload: object) -> None:
142
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
143
+
144
+
145
  def fallback_color(class_id: int) -> tuple[int, int, int]:
146
  return (
147
  int((67 * class_id + 41) % 190 + 35),
 
367
  "features": features,
368
  }
369
  path.write_text(json.dumps(collection, indent=2), encoding="utf-8")
 
scripts/satellite_client.py CHANGED
@@ -15,7 +15,7 @@ DEFAULT_SPACE = "Mingze/SatelliteVisionToolkit"
15
 
16
  def parse_args() -> argparse.Namespace:
17
  parser = argparse.ArgumentParser(description=__doc__)
18
- parser.add_argument("operation", choices=("detect", "segment"))
19
  parser.add_argument("image", type=Path)
20
  parser.add_argument("--space", default=DEFAULT_SPACE)
21
  parser.add_argument("--output", type=Path)
@@ -23,6 +23,7 @@ def parse_args() -> argparse.Namespace:
23
  parser.add_argument("--iou", type=float, default=0.45)
24
  parser.add_argument("--opacity", type=float, default=0.55)
25
  parser.add_argument("--min-share", type=float, default=0.1)
 
26
  return parser.parse_args()
27
 
28
 
@@ -31,20 +32,36 @@ def main() -> int:
31
  if not args.image.is_file():
32
  raise SystemExit(f"Image not found: {args.image}")
33
  client = Client(args.space)
34
- if args.operation == "detect":
 
 
 
 
 
 
35
  result = client.predict(
36
  handle_file(str(args.image)),
37
  args.confidence,
38
  args.iou,
39
  api_name="/detect",
40
  )
41
- else:
42
  result = client.predict(
43
  handle_file(str(args.image)),
44
  args.opacity,
45
  args.min_share,
46
  api_name="/segment",
47
  )
 
 
 
 
 
 
 
 
 
 
48
  rendered = json.dumps(result, ensure_ascii=False, indent=2, default=str)
49
  if args.output:
50
  args.output.write_text(rendered + "\n", encoding="utf-8")
@@ -55,4 +72,3 @@ def main() -> int:
55
 
56
  if __name__ == "__main__":
57
  raise SystemExit(main())
58
-
 
15
 
16
  def parse_args() -> argparse.Namespace:
17
  parser = argparse.ArgumentParser(description=__doc__)
18
+ parser.add_argument("operation", choices=("classify", "segment", "detect", "analyze"))
19
  parser.add_argument("image", type=Path)
20
  parser.add_argument("--space", default=DEFAULT_SPACE)
21
  parser.add_argument("--output", type=Path)
 
23
  parser.add_argument("--iou", type=float, default=0.45)
24
  parser.add_argument("--opacity", type=float, default=0.55)
25
  parser.add_argument("--min-share", type=float, default=0.1)
26
+ parser.add_argument("--top-k", type=int, default=5)
27
  return parser.parse_args()
28
 
29
 
 
32
  if not args.image.is_file():
33
  raise SystemExit(f"Image not found: {args.image}")
34
  client = Client(args.space)
35
+ if args.operation == "classify":
36
+ result = client.predict(
37
+ handle_file(str(args.image)),
38
+ args.top_k,
39
+ api_name="/classify",
40
+ )
41
+ elif args.operation == "detect":
42
  result = client.predict(
43
  handle_file(str(args.image)),
44
  args.confidence,
45
  args.iou,
46
  api_name="/detect",
47
  )
48
+ elif args.operation == "segment":
49
  result = client.predict(
50
  handle_file(str(args.image)),
51
  args.opacity,
52
  args.min_share,
53
  api_name="/segment",
54
  )
55
+ else:
56
+ result = client.predict(
57
+ handle_file(str(args.image)),
58
+ args.top_k,
59
+ args.opacity,
60
+ args.min_share,
61
+ args.confidence,
62
+ args.iou,
63
+ api_name="/analyze",
64
+ )
65
  rendered = json.dumps(result, ensure_ascii=False, indent=2, default=str)
66
  if args.output:
67
  args.output.write_text(rendered + "\n", encoding="utf-8")
 
72
 
73
  if __name__ == "__main__":
74
  raise SystemExit(main())
 
skills/analyze-satellite-imagery/SKILL.md CHANGED
@@ -1,37 +1,44 @@
1
  ---
2
  name: analyze-satellite-imagery
3
- description: Detect remote-sensing objects and segment land cover in satellite or aerial RGB imagery. Use when Codex needs to analyze overhead PNG, JPEG, WebP, or TIFF images; locate airplanes, ships, vehicles, storage tanks, bridges, harbors, or sports facilities; map OpenEarthMap land-cover classes; produce overlays, masks, CSV summaries, or pixel-coordinate GeoJSON; or call the Satellite Vision Toolkit Hugging Face Space API.
4
  ---
5
 
6
  # Analyze Satellite Imagery
7
 
8
- Use the bundled Space or local app to run two complementary workflows:
9
 
 
10
  - Detect 10 NWPU VHR-10 object categories with the fine-tuned YOLOv8n model.
11
  - Segment 9 OpenEarthMap land-cover categories with Mask2Former.
12
 
13
  ## Choose a workflow
14
 
15
- 1. Use detection for discrete objects and bounding boxes.
16
  2. Use segmentation for per-pixel land-cover composition.
17
- 3. Run both when the question mixes infrastructure counts and surface coverage.
18
- 4. Inspect `references/model-guide.md` before making claims about model scope, licenses, or limitations.
 
19
 
20
  ## Run the app
21
 
22
- From the plugin root, install `requirements.txt` and run `python app.py`. For a deployed Space, use the browser UI or call the `/detect` and `/segment` endpoints with `gradio_client`.
23
 
24
  Use `scripts/satellite_client.py` for repeatable API calls:
25
 
26
  ```bash
 
27
  python scripts/satellite_client.py detect image.jpg --output result.json
28
  python scripts/satellite_client.py segment image.jpg --output result.json
 
29
  ```
30
 
31
  Set `--space` when using a fork. The default is `Mingze/SatelliteVisionToolkit`.
32
 
33
  ## Interpret outputs
34
 
 
 
 
35
  - Treat detection counts as visible-image estimates, not inventories.
36
  - Treat class shares as proportions of processed image pixels, not physical land area.
37
  - State that exported GeoJSON uses top-left-origin image pixels and has no geographic CRS.
@@ -41,5 +48,4 @@ Set `--space` when using a fork. The default is `Mingze/SatelliteVisionToolkit`.
41
 
42
  ## Report results
43
 
44
- Include the model, thresholds, processed image dimensions, detected classes/counts or land-cover shares, and the important limitations. Link or return the generated overlays and exports when available.
45
-
 
1
  ---
2
  name: analyze-satellite-imagery
3
+ description: Classify land use and land cover, segment surface classes, and detect remote-sensing objects in satellite or aerial RGB imagery. Use when Codex needs to analyze overhead PNG, JPEG, WebP, or TIFF images; assign EuroSAT LULC scene classes; locate airplanes, ships, vehicles, storage tanks, bridges, harbors, or sports facilities; map OpenEarthMap land-cover classes; produce professional summaries, overlays, masks, CSV, JSON, or pixel-coordinate GeoJSON; or call the Satellite Vision Toolkit Hugging Face Space API.
4
  ---
5
 
6
  # Analyze Satellite Imagery
7
 
8
+ Use the bundled Space or local app to run three complementary analytical levels:
9
 
10
+ - Classify the whole scene across 10 EuroSAT LULC categories with ConvNeXT-Tiny.
11
  - Detect 10 NWPU VHR-10 object categories with the fine-tuned YOLOv8n model.
12
  - Segment 9 OpenEarthMap land-cover categories with Mask2Former.
13
 
14
  ## Choose a workflow
15
 
16
+ 1. Use classification for a broad whole-scene LULC hypothesis and ranked alternatives.
17
  2. Use segmentation for per-pixel land-cover composition.
18
+ 3. Use detection for discrete objects and bounding boxes.
19
+ 4. Use complete analysis when a professional summary or cross-method evidence package is needed.
20
+ 5. Inspect `references/model-guide.md` before making claims about model scope, licenses, or limitations.
21
 
22
  ## Run the app
23
 
24
+ From the plugin root, install `requirements.txt` and run `python app.py`. For a deployed Space, use the browser UI or call `/classify`, `/segment`, `/detect`, or `/analyze` with `gradio_client`.
25
 
26
  Use `scripts/satellite_client.py` for repeatable API calls:
27
 
28
  ```bash
29
+ python scripts/satellite_client.py classify image.jpg --output classification.json
30
  python scripts/satellite_client.py detect image.jpg --output result.json
31
  python scripts/satellite_client.py segment image.jpg --output result.json
32
+ python scripts/satellite_client.py analyze image.jpg --output complete.json
33
  ```
34
 
35
  Set `--space` when using a fork. The default is `Mingze/SatelliteVisionToolkit`.
36
 
37
  ## Interpret outputs
38
 
39
+ - Keep scene-level classification separate from pixel-level segmentation in the report.
40
+ - Treat EuroSAT probabilities as a broad scene hypothesis, not zoning, cadastral, or legal land-use evidence.
41
+ - Report normalized entropy when classification ambiguity matters; a high top score does not remove domain-shift risk.
42
  - Treat detection counts as visible-image estimates, not inventories.
43
  - Treat class shares as proportions of processed image pixels, not physical land area.
44
  - State that exported GeoJSON uses top-left-origin image pixels and has no geographic CRS.
 
48
 
49
  ## Report results
50
 
51
+ Include the analytical level, model, thresholds, processed image dimensions, ranked LULC alternatives, detected classes/counts or land-cover shares, and important limitations. Link or return generated overlays and exports when available.
 
skills/analyze-satellite-imagery/agents/openai.yaml CHANGED
@@ -1,4 +1,4 @@
1
  interface:
2
  display_name: "Satellite Imagery Analysis"
3
- short_description: "Detect objects and map land cover in satellite images"
4
- default_prompt: "Use $analyze-satellite-imagery to detect objects and segment land cover in this satellite image."
 
1
  interface:
2
  display_name: "Satellite Imagery Analysis"
3
+ short_description: "Classify LULC, segment cover, and detect overhead objects"
4
+ default_prompt: "Use $analyze-satellite-imagery to run a professional scene, pixel, and object-level assessment of this satellite image."
skills/analyze-satellite-imagery/references/model-guide.md CHANGED
@@ -1,5 +1,16 @@
1
  # Model and output guide
2
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## Object detection
4
 
5
  - Model: `bluelabel/satellite-equipment-detection-yolov8n-vhr10`
@@ -24,8 +35,9 @@ Area share is computed as `class pixels / all processed pixels`. It is a two-dim
24
 
25
  ## Export semantics
26
 
 
 
27
  - Segmentation class-ID PNG preserves numeric predicted labels.
28
  - Segmentation CSV contains class ID, name, pixel count, share percent, and display color.
29
  - Detection CSV contains image-pixel boxes, pixel area, and normalized box centers.
30
  - Detection GeoJSON stores box polygons in image-pixel coordinates with origin at the top left. It is intentionally not assigned a geographic coordinate reference system.
31
-
 
1
  # Model and output guide
2
 
3
+ ## Scene-level LULC classification
4
+
5
+ - Model: `mrm8488/convnext-tiny-finetuned-eurosat`
6
+ - Architecture: ConvNeXT-Tiny
7
+ - Training data: EuroSAT RGB images derived from European Sentinel-2 imagery
8
+ - Classes: annual crop, forest, herbaceous vegetation, highway, industrial, pasture, permanent crop, residential, river, sea/lake
9
+ - Model-card license: Apache-2.0
10
+ - Model-card reported evaluation accuracy: 0.9805
11
+
12
+ The classifier assigns one broad label to the processed image. It does not delineate parcels or pixels and must not be represented as a cadastral, zoning, or legal land-use conclusion. EuroSAT images are small European Sentinel-2 tiles; other sensors, countries, seasons, scales, and image crops introduce domain shift. Review the full probability profile and normalized entropy, not only the top label.
13
+
14
  ## Object detection
15
 
16
  - Model: `bluelabel/satellite-equipment-detection-yolov8n-vhr10`
 
35
 
36
  ## Export semantics
37
 
38
+ - Classification CSV and JSON preserve ranked probabilities, confidence tiers, and normalized entropy.
39
+ - Complete-analysis JSON records all three model IDs, summaries, thresholds-derived outputs, timing, and coordinate caveats.
40
  - Segmentation class-ID PNG preserves numeric predicted labels.
41
  - Segmentation CSV contains class ID, name, pixel count, share percent, and display color.
42
  - Detection CSV contains image-pixel boxes, pixel area, and normalized box centers.
43
  - Detection GeoJSON stores box polygons in image-pixel coordinates with origin at the top left. It is intentionally not assigned a geographic coordinate reference system.
 
tests/test_app_contract.py CHANGED
@@ -2,7 +2,7 @@ import ast
2
  from pathlib import Path
3
 
4
 
5
- def test_app_exposes_both_api_endpoints():
6
  source = Path("app.py").read_text(encoding="utf-8")
7
  tree = ast.parse(source)
8
  constants = {
@@ -12,6 +12,9 @@ def test_app_exposes_both_api_endpoints():
12
  }
13
  assert "segment" in constants
14
  assert "detect" in constants
 
 
 
15
  assert "mfaytin/mask2former-satellite" in constants
16
  assert "bluelabel/satellite-equipment-detection-yolov8n-vhr10" in constants
17
-
 
2
  from pathlib import Path
3
 
4
 
5
+ def test_app_exposes_all_api_endpoints_and_models():
6
  source = Path("app.py").read_text(encoding="utf-8")
7
  tree = ast.parse(source)
8
  constants = {
 
12
  }
13
  assert "segment" in constants
14
  assert "detect" in constants
15
+ assert "classify" in constants
16
+ assert "analyze" in constants
17
+ assert "mrm8488/convnext-tiny-finetuned-eurosat" in constants
18
  assert "mfaytin/mask2former-satellite" in constants
19
  assert "bluelabel/satellite-equipment-detection-yolov8n-vhr10" in constants
20
+ assert "cropland" in constants
tests/test_satellite_utils.py CHANGED
@@ -52,6 +52,23 @@ def test_class_table_is_sorted_and_thresholded():
52
  assert rows == [[4, "road", 3, 75.0, "#5C5C5C"]]
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def test_segmentation_outputs_match_input_size():
56
  image = Image.new("RGB", (3, 2), "black")
57
  class_map = np.array([[4, 4, 6], [4, 6, 6]], dtype=np.uint8)
@@ -81,4 +98,3 @@ def test_pixel_geojson_is_explicitly_unreferenced(tmp_path):
81
  assert data["properties"]["coordinate_system"] == "image_pixels"
82
  assert data["properties"]["origin"] == "top_left"
83
  assert data["features"][0]["geometry"]["coordinates"][0][0] == [10.0, 20.0]
84
-
 
52
  assert rows == [[4, "road", 3, 75.0, "#5C5C5C"]]
53
 
54
 
55
+ def test_lulc_table_is_ranked_and_human_readable():
56
+ rows = utils.build_lulc_table(
57
+ [0.1, 0.65, 0.25],
58
+ {0: "AnnualCrop", 1: "SeaLake", 2: "HerbaceousVegetation"},
59
+ top_k=2,
60
+ )
61
+ assert rows == [
62
+ [1, "Sea / lake", 65.0, "Moderate"],
63
+ [2, "Herbaceous vegetation", 25.0, "Low"],
64
+ ]
65
+
66
+
67
+ def test_normalized_entropy_has_expected_extremes():
68
+ assert utils.normalized_entropy([1.0, 0.0, 0.0]) == 0.0
69
+ assert round(utils.normalized_entropy([1 / 3, 1 / 3, 1 / 3]), 6) == 1.0
70
+
71
+
72
  def test_segmentation_outputs_match_input_size():
73
  image = Image.new("RGB", (3, 2), "black")
74
  class_map = np.array([[4, 4, 6], [4, 6, 6]], dtype=np.uint8)
 
98
  assert data["properties"]["coordinate_system"] == "image_pixels"
99
  assert data["properties"]["origin"] == "top_left"
100
  assert data["features"][0]["geometry"]["coordinates"][0][0] == [10.0, 20.0]