JuanHernandez-uc commited on
Commit
7129113
·
1 Parent(s): 5784344

add SAM2 segmentation

Browse files
Files changed (12) hide show
  1. .dockerignore +41 -0
  2. .gitignore +60 -0
  3. Dockerfile +45 -0
  4. README.md +21 -6
  5. main.py +129 -0
  6. requirements.txt +23 -0
  7. src/__init__.py +0 -0
  8. src/api.py +493 -0
  9. src/infer.py +439 -0
  10. src/logger.py +88 -0
  11. src/preprocess.py +50 -0
  12. test_queue.py +150 -0
.dockerignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+
4
+ __pycache__/
5
+ *.py[cod]
6
+
7
+ .venv/
8
+ venv/
9
+ env/
10
+
11
+ logs/
12
+ *.log
13
+
14
+ .cache/
15
+ hf_cache/
16
+ torch_cache/
17
+ models/
18
+ checkpoints/
19
+
20
+ *.gpkg
21
+ *.geojson
22
+ *.tif
23
+ *.tiff
24
+ *.vrt
25
+ *.aux.xml
26
+ *.ovr
27
+ *.dbf
28
+ *.shp
29
+ *.shx
30
+ *.prj
31
+ *.cpg
32
+ *.qgz
33
+ *.qgs
34
+
35
+ downloaded_result_*.gpkg
36
+ sam2_crop_*.tif
37
+ sam2_result_*.gpkg
38
+ sam2_crop_test.tif
39
+
40
+ .DS_Store
41
+ Thumbs.db
.gitignore ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Environments
8
+ .venv/
9
+ venv/
10
+ env/
11
+ ENV/
12
+
13
+ # IDE
14
+ .vscode/
15
+ .idea/
16
+
17
+ # Logs
18
+ logs/
19
+ *.log
20
+
21
+ # Local env
22
+ .env
23
+ .env.*
24
+ !.env.example
25
+
26
+ # Hugging Face / model caches
27
+ .cache/
28
+ hf_cache/
29
+ torch_cache/
30
+ models/
31
+ checkpoints/
32
+
33
+ # Generated geospatial files
34
+ *.gpkg
35
+ *.geojson
36
+ *.tif
37
+ *.tiff
38
+ *.vrt
39
+ *.aux.xml
40
+ *.ovr
41
+ *.dbf
42
+ *.shp
43
+ *.shx
44
+ *.prj
45
+ *.cpg
46
+ *.qgz
47
+ *.qgs
48
+
49
+ # Local test outputs
50
+ downloaded_result_*.gpkg
51
+ sam2_crop_*.tif
52
+ sam2_result_*.gpkg
53
+ sam2_crop_test.tif
54
+
55
+ # OS
56
+ .DS_Store
57
+ Thumbs.db
58
+
59
+ # Docker
60
+ *.tar
Dockerfile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ HF_HOME=/home/user/.cache/huggingface \
7
+ TORCH_HOME=/home/user/.cache/torch \
8
+ MPLCONFIGDIR=/tmp/matplotlib \
9
+ SAM2_BUILD_CUDA=0
10
+
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ git \
13
+ build-essential \
14
+ curl \
15
+ libglib2.0-0 \
16
+ libgomp1 \
17
+ libgl1 \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ RUN useradd -m -u 1000 user
21
+
22
+ USER user
23
+
24
+ ENV HOME=/home/user \
25
+ PATH=/home/user/.local/bin:$PATH
26
+
27
+ WORKDIR /home/user/app
28
+
29
+ COPY --chown=user requirements.txt /home/user/app/requirements.txt
30
+
31
+ RUN python -m pip install --upgrade pip setuptools wheel
32
+
33
+ # CPU PyTorch for Hugging Face CPU Spaces.
34
+ RUN python -m pip install \
35
+ torch==2.5.1 \
36
+ torchvision==0.20.1 \
37
+ --index-url https://download.pytorch.org/whl/cpu
38
+
39
+ RUN python -m pip install -r requirements.txt
40
+
41
+ COPY --chown=user . /home/user/app
42
+
43
+ EXPOSE 7860
44
+
45
+ CMD ["python", "main.py", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,26 @@
1
  ---
2
- title: Geoglyph SAM2
3
- emoji: 🏢
4
- colorFrom: red
5
- colorTo: indigo
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: GeoGlyph SAM2 API
3
+ emoji: 🛰️
4
+ colorFrom: pink
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
 
9
  ---
10
 
11
+ # GeoGlyph SAM2 API
12
+
13
+ FastAPI backend for GeoGlyph SAM2.
14
+
15
+ This Space receives a small georeferenced GeoTIFF crop, runs SAM2 on it, polygonizes the masks using the crop transform and CRS, and returns a GeoPackage.
16
+
17
+ ## Endpoints
18
+
19
+ - `GET /health`
20
+ - `POST /process`
21
+ - `GET /status/{task_id}`
22
+ - `GET /download/{task_id}`
23
+
24
+ ## Important
25
+
26
+ The API does not receive the full orthomosaic. The QGIS plugin crops the ROI locally and uploads only the small crop GeoTIFF.
main.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py
2
+ # Application entrypoint — dual-mode: API server or CLI inference on a crop GeoTIFF.
3
+
4
+ import argparse
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+
10
+ # Ensure the project root is on sys.path so `src` can be imported.
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ from src.logger import setup_logging
14
+
15
+ setup_logging()
16
+ logger = logging.getLogger("boot")
17
+
18
+
19
+ def main():
20
+ parser = argparse.ArgumentParser(
21
+ description="GeoGlyph SAM2 — API server and CLI for geoglyph detection.",
22
+ )
23
+
24
+ # ---------------------------------------------------------------------
25
+ # CLI mode
26
+ # ---------------------------------------------------------------------
27
+ parser.add_argument(
28
+ "--cli",
29
+ action="store_true",
30
+ help="Run a single inference from the command line instead of starting the API server.",
31
+ )
32
+
33
+ parser.add_argument(
34
+ "--crop",
35
+ type=str,
36
+ help="Path to a small georeferenced GeoTIFF crop. Required for --cli.",
37
+ )
38
+
39
+ parser.add_argument(
40
+ "--output",
41
+ type=str,
42
+ help="Output GeoPackage path. Required for --cli.",
43
+ )
44
+
45
+ parser.add_argument(
46
+ "--device",
47
+ type=str,
48
+ default=None,
49
+ choices=["cuda", "cpu"],
50
+ help="Inference device: cuda | cpu. Auto-detected by default.",
51
+ )
52
+
53
+ # ---------------------------------------------------------------------
54
+ # API mode
55
+ # ---------------------------------------------------------------------
56
+ parser.add_argument(
57
+ "--host",
58
+ type=str,
59
+ default="0.0.0.0",
60
+ help="API server host. Default: 0.0.0.0.",
61
+ )
62
+
63
+ parser.add_argument(
64
+ "--port",
65
+ type=int,
66
+ default=8000,
67
+ help="API server port. Default: 8000.",
68
+ )
69
+
70
+ args = parser.parse_args()
71
+
72
+ if args.cli:
73
+ if not args.crop or not args.output:
74
+ logger.error("--crop and --output are required in CLI mode.")
75
+ sys.exit(1)
76
+
77
+ from src.infer import run_geoglyph_sam2_on_crop
78
+
79
+ try:
80
+ logger.info(
81
+ "CLI inference | crop=%s output=%s device=%s",
82
+ args.crop,
83
+ args.output,
84
+ args.device,
85
+ )
86
+
87
+ result = run_geoglyph_sam2_on_crop(
88
+ crop_tif_path=args.crop,
89
+ output_gpkg=args.output,
90
+ device=args.device,
91
+ )
92
+
93
+ logger.info(
94
+ "CLI completed | n_masks=%d output=%s",
95
+ result["n_masks"],
96
+ result["output_gpkg"],
97
+ )
98
+
99
+ print(json.dumps(result, indent=2))
100
+
101
+ except Exception as exc:
102
+ logger.error("CLI inference failed: %s", exc, exc_info=True)
103
+ sys.exit(1)
104
+
105
+ else:
106
+ import uvicorn
107
+
108
+ logger.info("Starting GeoGlyph SAM2 API on %s:%d", args.host, args.port)
109
+
110
+ uvicorn.run(
111
+ "src.api:app",
112
+ host=args.host,
113
+ port=args.port,
114
+ reload=False,
115
+ )
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
120
+
121
+
122
+ """
123
+ python main.py --cli ^
124
+ --crop "C:\path\to\sam2_crop.tif" ^
125
+ --output "C:\path\to\sam2_result.gpkg" ^
126
+ --device cpu
127
+
128
+ python main.py --host 0.0.0.0 --port 8000
129
+ """
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API
2
+ fastapi==0.115.8
3
+ uvicorn[standard]==0.34.0
4
+ python-multipart==0.0.20
5
+ requests==2.32.3
6
+
7
+ # Numerical / image processing
8
+ numpy==2.1.3
9
+ opencv-python-headless==4.10.0.84
10
+ Pillow==11.1.0
11
+
12
+ # Geospatial stack
13
+ rasterio==1.4.3
14
+ geopandas==1.0.1
15
+ shapely==2.0.6
16
+ pyproj==3.7.0
17
+ pyogrio==0.10.0
18
+
19
+ # Hugging Face model download/cache
20
+ huggingface_hub==0.28.1
21
+
22
+ # SAM2
23
+ git+https://github.com/facebookresearch/sam2.git
src/__init__.py ADDED
File without changes
src/api.py ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/api.py
2
+ # FastAPI application using an async task queue.
3
+ #
4
+ # Important:
5
+ # The API does NOT receive the full orthomosaic.
6
+ # It receives a small georeferenced crop GeoTIFF uploaded as multipart/form-data.
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import tempfile
12
+ import time
13
+ import uuid
14
+ from contextlib import asynccontextmanager
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Optional, Dict, Any
18
+
19
+ from fastapi import (
20
+ FastAPI,
21
+ HTTPException,
22
+ status,
23
+ UploadFile,
24
+ File,
25
+ Form,
26
+ )
27
+ from fastapi.responses import FileResponse
28
+ from starlette.background import BackgroundTask
29
+
30
+ from src.infer import run_geoglyph_sam2_on_crop
31
+
32
+ logger = logging.getLogger("api")
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Global state
37
+ # ---------------------------------------------------------------------------
38
+
39
+ RESULTS_DIR = Path(tempfile.gettempdir()) / "geoglyph_sam2_api"
40
+ RESULTS_DIR.mkdir(parents=True, exist_ok=True)
41
+
42
+ TASKS: Dict[str, Dict[str, Any]] = {}
43
+
44
+ # Single queue. One worker means one SAM2 inference at a time.
45
+ # This avoids GPU OOM when several users submit jobs.
46
+ QUEUE: asyncio.Queue = asyncio.Queue(maxsize=20)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Internal job object
51
+ # ---------------------------------------------------------------------------
52
+
53
+ @dataclass
54
+ class InferenceJob:
55
+ crop_path: str
56
+ output_gpkg: str
57
+
58
+ device: Optional[str] = None
59
+
60
+ use_clahe: bool = True
61
+ clahe_clip: float = 4.0
62
+ clahe_grid: int = 6
63
+
64
+ sam2_points_per_side: int = 32
65
+ sam2_points_per_batch: int = 32
66
+ sam2_pred_iou_thresh: float = 0.35
67
+ sam2_stability_score_thresh: float = 0.65
68
+
69
+ filter_min_area_px: int = 1000
70
+ filter_max_area_frac: float = 0.20
71
+ filter_min_iou: float = 0.35
72
+ filter_min_stability: float = 0.65
73
+ filter_border_margin: int = 10
74
+
75
+ max_crop_side: int = 4096
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Worker
80
+ # ---------------------------------------------------------------------------
81
+
82
+ async def queue_worker():
83
+ """
84
+ Background worker.
85
+
86
+ It processes tasks sequentially to avoid GPU/CPU contention and GPU OOM.
87
+ Heavy SAM2 inference runs in a separate thread.
88
+ """
89
+
90
+ logger.info("Task queue worker started.")
91
+
92
+ while True:
93
+ try:
94
+ task_id, job = await QUEUE.get()
95
+
96
+ except asyncio.CancelledError:
97
+ logger.info("Task queue worker cancelled.")
98
+ break
99
+
100
+ except Exception as exc:
101
+ logger.error("Error retrieving task from queue: %s", exc, exc_info=True)
102
+ continue
103
+
104
+ try:
105
+ if task_id not in TASKS:
106
+ continue
107
+
108
+ TASKS[task_id]["status"] = "processing"
109
+ TASKS[task_id]["started_at"] = time.time()
110
+
111
+ logger.info("Worker started task_id=%s", task_id)
112
+
113
+ result = await asyncio.to_thread(
114
+ run_geoglyph_sam2_on_crop,
115
+ crop_tif_path=job.crop_path,
116
+ output_gpkg=job.output_gpkg,
117
+ device=job.device,
118
+ use_clahe=job.use_clahe,
119
+ clahe_clip=job.clahe_clip,
120
+ clahe_grid=job.clahe_grid,
121
+ sam2_points_per_side=job.sam2_points_per_side,
122
+ sam2_points_per_batch=job.sam2_points_per_batch,
123
+ sam2_pred_iou_thresh=job.sam2_pred_iou_thresh,
124
+ sam2_stability_score_thresh=job.sam2_stability_score_thresh,
125
+ filter_min_area_px=job.filter_min_area_px,
126
+ filter_max_area_frac=job.filter_max_area_frac,
127
+ filter_min_iou=job.filter_min_iou,
128
+ filter_min_stability=job.filter_min_stability,
129
+ filter_border_margin=job.filter_border_margin,
130
+ max_crop_side=job.max_crop_side,
131
+ )
132
+
133
+ TASKS[task_id].update(
134
+ {
135
+ "status": "completed",
136
+ "finished_at": time.time(),
137
+ "n_masks": result["n_masks"],
138
+ "output_exists": result["output_exists"],
139
+ "result": result,
140
+ "download_url": f"/download/{task_id}"
141
+ if result["output_exists"]
142
+ else None,
143
+ }
144
+ )
145
+
146
+ logger.info(
147
+ "Worker completed task_id=%s n_masks=%d output_exists=%s",
148
+ task_id,
149
+ result["n_masks"],
150
+ result["output_exists"],
151
+ )
152
+
153
+ except Exception as exc:
154
+ logger.error(
155
+ "Worker failed task_id=%s: %s",
156
+ task_id,
157
+ exc,
158
+ exc_info=True,
159
+ )
160
+
161
+ if task_id in TASKS:
162
+ TASKS[task_id].update(
163
+ {
164
+ "status": "failed",
165
+ "finished_at": time.time(),
166
+ "error": str(exc),
167
+ }
168
+ )
169
+
170
+ try:
171
+ import torch
172
+
173
+ if torch.cuda.is_available():
174
+ torch.cuda.empty_cache()
175
+ logger.info("Cleared CUDA cache after task failure.")
176
+
177
+ except Exception:
178
+ pass
179
+
180
+ finally:
181
+ # Crop is no longer needed after processing.
182
+ try:
183
+ if os.path.exists(job.crop_path):
184
+ os.remove(job.crop_path)
185
+ logger.info("Deleted temporary crop for task_id=%s", task_id)
186
+ except Exception as exc:
187
+ logger.warning(
188
+ "Could not delete temporary crop for task_id=%s: %s",
189
+ task_id,
190
+ exc,
191
+ )
192
+
193
+ QUEUE.task_done()
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Lifespan
198
+ # ---------------------------------------------------------------------------
199
+
200
+ @asynccontextmanager
201
+ async def lifespan(app: FastAPI):
202
+ worker_task = asyncio.create_task(queue_worker())
203
+
204
+ yield
205
+
206
+ worker_task.cancel()
207
+
208
+ try:
209
+ await worker_task
210
+ except asyncio.CancelledError:
211
+ pass
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # FastAPI app
216
+ # ---------------------------------------------------------------------------
217
+
218
+ app = FastAPI(
219
+ title="GeoGlyph SAM2 API",
220
+ description=(
221
+ "Backend API for geoglyph detection using SAM2. "
222
+ "Receives small georeferenced crop GeoTIFFs, not full orthomosaics."
223
+ ),
224
+ version="3.0.0",
225
+ lifespan=lifespan,
226
+ )
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Endpoints
231
+ # ---------------------------------------------------------------------------
232
+
233
+ @app.get("/health", status_code=status.HTTP_200_OK)
234
+ async def health_check():
235
+ return {
236
+ "status": "ok",
237
+ "message": "GeoGlyph SAM2 API is running.",
238
+ "queue_size": QUEUE.qsize(),
239
+ "results_dir": str(RESULTS_DIR),
240
+ }
241
+
242
+
243
+ @app.post("/process", status_code=status.HTTP_202_ACCEPTED)
244
+ async def process_geoglyphs(
245
+ crop: UploadFile = File(...),
246
+ device: Optional[str] = Form(None),
247
+
248
+ use_clahe: bool = Form(True),
249
+ clahe_clip: float = Form(4.0),
250
+ clahe_grid: int = Form(6),
251
+
252
+ sam2_points_per_side: int = Form(32),
253
+ sam2_points_per_batch: int = Form(32),
254
+ sam2_pred_iou_thresh: float = Form(0.35),
255
+ sam2_stability_score_thresh: float = Form(0.65),
256
+
257
+ filter_min_area_px: int = Form(1000),
258
+ filter_max_area_frac: float = Form(0.20),
259
+ filter_min_iou: float = Form(0.35),
260
+ filter_min_stability: float = Form(0.65),
261
+ filter_border_margin: int = Form(10),
262
+
263
+ max_crop_side: int = Form(4096),
264
+ ):
265
+ """
266
+ Submit a SAM2 inference job.
267
+
268
+ The client uploads a georeferenced crop GeoTIFF.
269
+ The API never receives the original orthomosaic.
270
+ """
271
+
272
+ if QUEUE.full():
273
+ raise HTTPException(
274
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
275
+ detail="Task queue is full. Try again later.",
276
+ )
277
+
278
+ if device not in {None, "cuda", "cpu"}:
279
+ raise HTTPException(
280
+ status_code=status.HTTP_400_BAD_REQUEST,
281
+ detail="Invalid device. Expected 'cuda', 'cpu', or omitted.",
282
+ )
283
+
284
+ task_id = uuid.uuid4().hex[:12]
285
+
286
+ crop_path = RESULTS_DIR / f"{task_id}_crop.tif"
287
+ output_gpkg = RESULTS_DIR / f"{task_id}.gpkg"
288
+
289
+ try:
290
+ with open(crop_path, "wb") as f:
291
+ while True:
292
+ chunk = await crop.read(1024 * 1024)
293
+ if not chunk:
294
+ break
295
+ f.write(chunk)
296
+
297
+ await crop.close()
298
+
299
+ except Exception as exc:
300
+ raise HTTPException(
301
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
302
+ detail=f"Could not save uploaded crop: {exc}",
303
+ )
304
+
305
+ if not crop_path.is_file() or crop_path.stat().st_size == 0:
306
+ raise HTTPException(
307
+ status_code=status.HTTP_400_BAD_REQUEST,
308
+ detail="Uploaded crop is empty or could not be saved.",
309
+ )
310
+
311
+ job = InferenceJob(
312
+ crop_path=str(crop_path),
313
+ output_gpkg=str(output_gpkg),
314
+ device=device,
315
+ use_clahe=use_clahe,
316
+ clahe_clip=clahe_clip,
317
+ clahe_grid=clahe_grid,
318
+ sam2_points_per_side=sam2_points_per_side,
319
+ sam2_points_per_batch=sam2_points_per_batch,
320
+ sam2_pred_iou_thresh=sam2_pred_iou_thresh,
321
+ sam2_stability_score_thresh=sam2_stability_score_thresh,
322
+ filter_min_area_px=filter_min_area_px,
323
+ filter_max_area_frac=filter_max_area_frac,
324
+ filter_min_iou=filter_min_iou,
325
+ filter_min_stability=filter_min_stability,
326
+ filter_border_margin=filter_border_margin,
327
+ max_crop_side=max_crop_side,
328
+ )
329
+
330
+ TASKS[task_id] = {
331
+ "status": "pending",
332
+ "created_at": time.time(),
333
+ "original_filename": crop.filename,
334
+ "crop_path": str(crop_path),
335
+ "output_gpkg": str(output_gpkg),
336
+ "n_masks": None,
337
+ "output_exists": None,
338
+ "error": None,
339
+ }
340
+
341
+ await QUEUE.put((task_id, job))
342
+
343
+ logger.info(
344
+ "Enqueued task_id=%s filename=%s queue_size=%d",
345
+ task_id,
346
+ crop.filename,
347
+ QUEUE.qsize(),
348
+ )
349
+
350
+ return {
351
+ "task_id": task_id,
352
+ "status": "pending",
353
+ "queue_size": QUEUE.qsize(),
354
+ }
355
+
356
+
357
+ @app.get("/status/{task_id}", status_code=status.HTTP_200_OK)
358
+ async def get_status(task_id: str):
359
+ """
360
+ Check task status.
361
+
362
+ Possible statuses:
363
+ - pending
364
+ - processing
365
+ - completed
366
+ - failed
367
+ """
368
+
369
+ if task_id not in TASKS:
370
+ raise HTTPException(status_code=404, detail="Task not found")
371
+
372
+ task_info = TASKS[task_id]
373
+
374
+ if task_info["status"] == "pending":
375
+ pending_ids = [
376
+ tid
377
+ for tid, data in TASKS.items()
378
+ if data["status"] == "pending"
379
+ ]
380
+
381
+ try:
382
+ position = pending_ids.index(task_id) + 1
383
+ except ValueError:
384
+ position = 0
385
+
386
+ return {
387
+ "task_id": task_id,
388
+ "status": "pending",
389
+ "queue_position": position,
390
+ }
391
+
392
+ if task_info["status"] == "processing":
393
+ return {
394
+ "task_id": task_id,
395
+ "status": "processing",
396
+ "started_at": task_info.get("started_at"),
397
+ }
398
+
399
+ if task_info["status"] == "completed":
400
+ return {
401
+ "task_id": task_id,
402
+ "status": "completed",
403
+ "n_masks": task_info.get("n_masks"),
404
+ "output_exists": task_info.get("output_exists"),
405
+ "download_url": task_info.get("download_url"),
406
+ "result": task_info.get("result"),
407
+ }
408
+
409
+ if task_info["status"] == "failed":
410
+ return {
411
+ "task_id": task_id,
412
+ "status": "failed",
413
+ "error": task_info.get("error"),
414
+ }
415
+
416
+ return task_info
417
+
418
+
419
+ def cleanup_task(task_id: str):
420
+ """
421
+ Delete generated files and remove task metadata.
422
+ Called after successful download.
423
+ """
424
+
425
+ task = TASKS.get(task_id, {})
426
+
427
+ paths_to_delete = [
428
+ task.get("output_gpkg"),
429
+ task.get("crop_path"),
430
+ ]
431
+
432
+ for path_str in paths_to_delete:
433
+ if not path_str:
434
+ continue
435
+
436
+ path = Path(path_str)
437
+
438
+ if path.exists():
439
+ try:
440
+ path.unlink()
441
+ logger.info("Deleted file for task_id=%s: %s", task_id, path)
442
+ except Exception as exc:
443
+ logger.warning(
444
+ "Could not delete file for task_id=%s: %s",
445
+ task_id,
446
+ exc,
447
+ )
448
+
449
+ if task_id in TASKS:
450
+ del TASKS[task_id]
451
+
452
+
453
+ @app.get("/download/{task_id}")
454
+ async def download_result(task_id: str):
455
+ """
456
+ Download the generated GeoPackage.
457
+
458
+ The task is cleaned after transfer.
459
+ """
460
+
461
+ if task_id not in TASKS:
462
+ raise HTTPException(status_code=404, detail="Task not found")
463
+
464
+ task = TASKS[task_id]
465
+
466
+ if task["status"] != "completed":
467
+ raise HTTPException(
468
+ status_code=400,
469
+ detail="Task is not completed yet.",
470
+ )
471
+
472
+ if not task.get("output_exists"):
473
+ raise HTTPException(
474
+ status_code=404,
475
+ detail="Task completed but no GeoPackage was created. No masks passed the filters.",
476
+ )
477
+
478
+ gpkg_path = Path(task["output_gpkg"])
479
+
480
+ if not gpkg_path.is_file():
481
+ raise HTTPException(
482
+ status_code=404,
483
+ detail="Result file is missing.",
484
+ )
485
+
486
+ logger.info("Serving GPKG download | task_id=%s", task_id)
487
+
488
+ return FileResponse(
489
+ path=str(gpkg_path),
490
+ media_type="application/geopackage+sqlite3",
491
+ filename=f"sam2_result_{task_id}.gpkg",
492
+ background=BackgroundTask(cleanup_task, task_id),
493
+ )
src/infer.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/infer.py
2
+ # SAM2 geoglyph inference pipeline using a georeferenced crop GeoTIFF.
3
+ #
4
+ # Important:
5
+ # This file does NOT receive or open the full orthomosaic.
6
+ # It only receives a small crop GeoTIFF containing:
7
+ # - RGB pixels
8
+ # - CRS
9
+ # - affine transform
10
+
11
+ from pathlib import Path
12
+ import logging
13
+
14
+ import numpy as np
15
+ import torch
16
+ import rasterio
17
+ from rasterio.features import shapes
18
+ import geopandas as gpd
19
+ from shapely.geometry import shape
20
+
21
+ from src.preprocess import preprocess
22
+
23
+ logger = logging.getLogger("pipeline")
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Model cache
28
+ # ---------------------------------------------------------------------------
29
+
30
+ _MODEL_CACHE = {}
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Device handling
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def resolve_device(device: str | None) -> str:
38
+ if device is None:
39
+ return "cuda" if torch.cuda.is_available() else "cpu"
40
+
41
+ device = device.lower().strip()
42
+
43
+ if device not in {"cuda", "cpu"}:
44
+ raise ValueError(f"Invalid device: {device}. Expected 'cuda' or 'cpu'.")
45
+
46
+ if device == "cuda" and not torch.cuda.is_available():
47
+ raise RuntimeError("CUDA was requested, but torch.cuda.is_available() is False.")
48
+
49
+ return device
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Model loading
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def load_sam2_model(
57
+ device: str = "cuda",
58
+ points_per_side: int = 32,
59
+ points_per_batch: int = 32,
60
+ pred_iou_thresh: float = 0.35,
61
+ stability_score_thresh: float = 0.65,
62
+ ):
63
+ """
64
+ Load the SAM2 automatic mask generator from Hugging Face.
65
+
66
+ The model is cached by device and SAM2 hyperparameters so the API does not
67
+ reload the model for every task.
68
+ """
69
+
70
+ from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator
71
+
72
+ key = (
73
+ device,
74
+ points_per_side,
75
+ points_per_batch,
76
+ pred_iou_thresh,
77
+ stability_score_thresh,
78
+ )
79
+
80
+ if key in _MODEL_CACHE:
81
+ logger.info("Reusing cached SAM2 model | device=%s", device)
82
+ return _MODEL_CACHE[key]
83
+
84
+ logger.info(
85
+ "Loading SAM2 model | device=%s PPS=%d PPB=%d IOU_thresh=%.2f Stability_thresh=%.2f",
86
+ device,
87
+ points_per_side,
88
+ points_per_batch,
89
+ pred_iou_thresh,
90
+ stability_score_thresh,
91
+ )
92
+
93
+ mask_generator = SAM2AutomaticMaskGenerator.from_pretrained(
94
+ "facebook/sam2.1-hiera-large",
95
+ device=device,
96
+ points_per_side=points_per_side,
97
+ points_per_batch=points_per_batch,
98
+ crop_n_layers=0,
99
+ multimask_output=False,
100
+ use_m2m=False,
101
+ pred_iou_thresh=pred_iou_thresh,
102
+ stability_score_thresh=stability_score_thresh,
103
+ )
104
+
105
+ predictor = getattr(mask_generator, "predictor", None)
106
+ model = getattr(predictor, "model", None)
107
+
108
+ if model is not None:
109
+ actual_device = next(model.parameters()).device
110
+ logger.info("Actual SAM2 model device: %s", actual_device)
111
+
112
+ if device == "cpu" and actual_device.type != "cpu":
113
+ raise RuntimeError(
114
+ f"Expected SAM2 to run on CPU, but model is on {actual_device}."
115
+ )
116
+
117
+ if device == "cuda" and actual_device.type != "cuda":
118
+ raise RuntimeError(
119
+ f"Expected SAM2 to run on CUDA, but model is on {actual_device}."
120
+ )
121
+ else:
122
+ logger.warning("Could not inspect actual SAM2 model device.")
123
+
124
+ _MODEL_CACHE[key] = mask_generator
125
+ return mask_generator
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Crop GeoTIFF I/O
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def read_georeferenced_crop(
133
+ crop_tif_path: str,
134
+ rgb_bands: tuple = (1, 2, 3),
135
+ max_crop_side: int = 4096,
136
+ ):
137
+ """
138
+ Read a small georeferenced GeoTIFF crop.
139
+
140
+ This function does NOT need access to the original orthomosaic.
141
+
142
+ The crop GeoTIFF must contain:
143
+ - RGB pixel data
144
+ - CRS
145
+ - affine transform
146
+
147
+ Returns:
148
+ - RGB uint8 image
149
+ - crop transform
150
+ - crop CRS
151
+ - crop metadata
152
+ """
153
+
154
+ crop_tif_path = str(crop_tif_path)
155
+
156
+ logger.info("Reading georeferenced crop: %s", crop_tif_path)
157
+
158
+ with rasterio.open(crop_tif_path) as src:
159
+ if src.crs is None:
160
+ raise ValueError("The crop GeoTIFF has no CRS.")
161
+
162
+ if src.transform is None:
163
+ raise ValueError("The crop GeoTIFF has no affine transform.")
164
+
165
+ if src.count < max(rgb_bands):
166
+ raise ValueError(
167
+ f"The crop has only {src.count} band(s), "
168
+ f"but rgb_bands={rgb_bands} was requested."
169
+ )
170
+
171
+ if src.width > max_crop_side or src.height > max_crop_side:
172
+ raise ValueError(
173
+ f"Crop too large: {src.width}x{src.height} px. "
174
+ f"Maximum allowed side is {max_crop_side} px."
175
+ )
176
+
177
+ arr = src.read(rgb_bands)
178
+
179
+ crop_transform = src.transform
180
+ crop_crs = src.crs
181
+ crop_bounds = src.bounds
182
+
183
+ metadata = {
184
+ "width": int(src.width),
185
+ "height": int(src.height),
186
+ "count": int(src.count),
187
+ "crs": str(src.crs),
188
+ "bounds": {
189
+ "left": float(crop_bounds.left),
190
+ "bottom": float(crop_bounds.bottom),
191
+ "right": float(crop_bounds.right),
192
+ "top": float(crop_bounds.top),
193
+ },
194
+ }
195
+
196
+ arr = np.transpose(arr, (1, 2, 0))
197
+ arr = np.nan_to_num(arr)
198
+
199
+ if arr.dtype != np.uint8:
200
+ logger.warning(
201
+ "Crop dtype is %s, converting to uint8 by clipping to [0, 255].",
202
+ arr.dtype,
203
+ )
204
+ arr = np.clip(arr, 0, 255).astype(np.uint8)
205
+
206
+ logger.info(
207
+ "Crop loaded | shape=%s crs=%s",
208
+ arr.shape,
209
+ metadata["crs"],
210
+ )
211
+
212
+ return arr, crop_transform, crop_crs, metadata
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Mask → GeoDataFrame
217
+ # ---------------------------------------------------------------------------
218
+
219
+ def masks_to_geodataframe(
220
+ masks_data: list,
221
+ crop_transform,
222
+ crop_crs,
223
+ image_shape: tuple,
224
+ min_area_px: int = 1000,
225
+ max_area_frac: float = 0.20,
226
+ min_iou: float = 0.35,
227
+ min_stability: float = 0.65,
228
+ border_margin: int = 10,
229
+ ) -> gpd.GeoDataFrame:
230
+ """
231
+ Convert raw SAM2 masks to a filtered GeoDataFrame of polygons.
232
+
233
+ The important line is:
234
+
235
+ shapes(..., transform=crop_transform)
236
+
237
+ This converts mask pixel coordinates into real map coordinates using
238
+ the crop GeoTIFF georeference.
239
+ """
240
+
241
+ H, W = image_shape[:2]
242
+ max_area_px = int(H * W * max_area_frac)
243
+
244
+ logger.info(
245
+ "Filtering masks | area=[%d, %d] px IOU>=%.2f Stability>=%.2f border_margin=%d",
246
+ min_area_px,
247
+ max_area_px,
248
+ min_iou,
249
+ min_stability,
250
+ border_margin,
251
+ )
252
+
253
+ records = []
254
+
255
+ for mask_id, m in enumerate(masks_data):
256
+ area_px = int(m["area"])
257
+
258
+ if area_px < min_area_px or area_px > max_area_px:
259
+ continue
260
+
261
+ if m["predicted_iou"] < min_iou:
262
+ continue
263
+
264
+ if m["stability_score"] < min_stability:
265
+ continue
266
+
267
+ mask_u8 = m["segmentation"].astype(np.uint8)
268
+
269
+ rows, cols = np.where(mask_u8)
270
+ if len(rows) == 0:
271
+ continue
272
+
273
+ touches_border = (
274
+ rows.min() < border_margin
275
+ or rows.max() > H - border_margin
276
+ or cols.min() < border_margin
277
+ or cols.max() > W - border_margin
278
+ )
279
+
280
+ if touches_border:
281
+ continue
282
+
283
+ for geom_dict, val in shapes(
284
+ mask_u8,
285
+ mask=mask_u8.astype(bool),
286
+ transform=crop_transform,
287
+ ):
288
+ if val != 1:
289
+ continue
290
+
291
+ geom = shape(geom_dict)
292
+
293
+ if geom.is_empty:
294
+ continue
295
+
296
+ records.append(
297
+ {
298
+ "geometry": geom,
299
+ "mask_id": mask_id,
300
+ "predicted_iou": float(m["predicted_iou"]),
301
+ "stability_score": float(m["stability_score"]),
302
+ "area_px": area_px,
303
+ }
304
+ )
305
+
306
+ gdf = gpd.GeoDataFrame(records, geometry="geometry", crs=crop_crs)
307
+
308
+ logger.info("Retained %d mask geometries after filtering.", len(gdf))
309
+
310
+ return gdf
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Main orchestrator
315
+ # ---------------------------------------------------------------------------
316
+
317
+ def run_geoglyph_sam2_on_crop(
318
+ crop_tif_path: str,
319
+ output_gpkg: str,
320
+ layer_name: str = "sam2_geoglyph_detections",
321
+ device: str | None = None,
322
+ # Preprocessing
323
+ use_clahe: bool = True,
324
+ clahe_clip: float = 4.0,
325
+ clahe_grid: int = 6,
326
+ # SAM2 hyperparameters
327
+ sam2_points_per_side: int = 32,
328
+ sam2_points_per_batch: int = 32,
329
+ sam2_pred_iou_thresh: float = 0.35,
330
+ sam2_stability_score_thresh: float = 0.65,
331
+ # Postprocessing filters
332
+ filter_min_area_px: int = 1000,
333
+ filter_max_area_frac: float = 0.20,
334
+ filter_min_iou: float = 0.35,
335
+ filter_min_stability: float = 0.65,
336
+ filter_border_margin: int = 10,
337
+ # Safety
338
+ max_crop_side: int = 4096,
339
+ ) -> dict:
340
+ """
341
+ End-to-end geoglyph detection pipeline from a georeferenced crop.
342
+
343
+ This function does NOT receive:
344
+ - original orthomosaic path
345
+ - bbox
346
+ - bbox CRS
347
+
348
+ It only receives a small crop GeoTIFF with CRS and transform.
349
+ """
350
+
351
+ device = resolve_device(device)
352
+
353
+ logger.info("=" * 60)
354
+ logger.info("STARTING GEOGLYPH SAM2 INFERENCE ON CROP")
355
+ logger.info("=" * 60)
356
+ logger.info("Input crop: %s", crop_tif_path)
357
+ logger.info("Output GPKG: %s", output_gpkg)
358
+ logger.info("Requested device: %s", device)
359
+
360
+ crop_tif_path = str(crop_tif_path)
361
+ output_gpkg = Path(output_gpkg)
362
+
363
+ arr_raw, crop_transform, crop_crs, crop_metadata = read_georeferenced_crop(
364
+ crop_tif_path=crop_tif_path,
365
+ max_crop_side=max_crop_side,
366
+ )
367
+
368
+ if use_clahe:
369
+ logger.info(
370
+ "Applying CLAHE | clip=%.1f grid=%d",
371
+ clahe_clip,
372
+ clahe_grid,
373
+ )
374
+
375
+ arr_processed = preprocess(
376
+ arr_raw,
377
+ use_clahe=use_clahe,
378
+ clip=clahe_clip,
379
+ grid=clahe_grid,
380
+ )
381
+
382
+ mask_generator = load_sam2_model(
383
+ device=device,
384
+ points_per_side=sam2_points_per_side,
385
+ points_per_batch=sam2_points_per_batch,
386
+ pred_iou_thresh=sam2_pred_iou_thresh,
387
+ stability_score_thresh=sam2_stability_score_thresh,
388
+ )
389
+
390
+ logger.info("Generating masks...")
391
+ with torch.inference_mode():
392
+ masks_data = mask_generator.generate(arr_processed)
393
+
394
+ logger.info("SAM2 generated %d raw masks.", len(masks_data))
395
+
396
+ gdf = masks_to_geodataframe(
397
+ masks_data=masks_data,
398
+ crop_transform=crop_transform,
399
+ crop_crs=crop_crs,
400
+ image_shape=arr_processed.shape,
401
+ min_area_px=filter_min_area_px,
402
+ max_area_frac=filter_max_area_frac,
403
+ min_iou=filter_min_iou,
404
+ min_stability=filter_min_stability,
405
+ border_margin=filter_border_margin,
406
+ )
407
+
408
+ if len(gdf) > 0:
409
+ gdf["source_crop"] = crop_tif_path
410
+ gdf["input_mode"] = "georeferenced_crop"
411
+ gdf["crop_width"] = crop_metadata["width"]
412
+ gdf["crop_height"] = crop_metadata["height"]
413
+ gdf["crop_crs"] = crop_metadata["crs"]
414
+
415
+ logger.info(
416
+ "Exporting %d geometries → %s layer=%s",
417
+ len(gdf),
418
+ output_gpkg,
419
+ layer_name,
420
+ )
421
+
422
+ gdf.to_file(output_gpkg, layer=layer_name, driver="GPKG")
423
+ output_exists = True
424
+ else:
425
+ logger.warning("No geometries to export after filtering.")
426
+ output_exists = False
427
+
428
+ logger.info("=" * 60)
429
+ logger.info("INFERENCE COMPLETED")
430
+ logger.info("=" * 60)
431
+
432
+ return {
433
+ "output_gpkg": str(output_gpkg),
434
+ "layer_name": layer_name,
435
+ "n_masks": len(gdf),
436
+ "input_mode": "georeferenced_crop",
437
+ "crop": crop_metadata,
438
+ "output_exists": output_exists,
439
+ }
src/logger.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/logger.py
2
+ # Structured logging configuration.
3
+
4
+ import logging
5
+ import logging.config
6
+ import os
7
+
8
+
9
+ def setup_logging():
10
+ logs_dir = os.path.join(
11
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
12
+ "logs",
13
+ )
14
+
15
+ os.makedirs(logs_dir, exist_ok=True)
16
+
17
+ LOG_FORMAT = "%(asctime)s | %(name)-8s | %(levelname)-7s | %(message)s"
18
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
19
+
20
+ logging_config = {
21
+ "version": 1,
22
+ "disable_existing_loggers": False,
23
+ "formatters": {
24
+ "standard": {
25
+ "format": LOG_FORMAT,
26
+ "datefmt": DATE_FORMAT,
27
+ },
28
+ },
29
+ "handlers": {
30
+ "console": {
31
+ "class": "logging.StreamHandler",
32
+ "level": "INFO",
33
+ "formatter": "standard",
34
+ # Use stderr so stdout can stay clean for JSON in CLI mode.
35
+ "stream": "ext://sys.stderr",
36
+ },
37
+ "file_boot": {
38
+ "class": "logging.handlers.RotatingFileHandler",
39
+ "level": "INFO",
40
+ "formatter": "standard",
41
+ "filename": os.path.join(logs_dir, "boot.log"),
42
+ "maxBytes": 10_485_760,
43
+ "backupCount": 3,
44
+ "encoding": "utf8",
45
+ },
46
+ "file_api": {
47
+ "class": "logging.handlers.RotatingFileHandler",
48
+ "level": "INFO",
49
+ "formatter": "standard",
50
+ "filename": os.path.join(logs_dir, "api.log"),
51
+ "maxBytes": 10_485_760,
52
+ "backupCount": 3,
53
+ "encoding": "utf8",
54
+ },
55
+ "file_pipeline": {
56
+ "class": "logging.handlers.RotatingFileHandler",
57
+ "level": "INFO",
58
+ "formatter": "standard",
59
+ "filename": os.path.join(logs_dir, "pipeline.log"),
60
+ "maxBytes": 10_485_760,
61
+ "backupCount": 3,
62
+ "encoding": "utf8",
63
+ },
64
+ },
65
+ "loggers": {
66
+ "boot": {
67
+ "level": "INFO",
68
+ "handlers": ["console", "file_boot"],
69
+ "propagate": False,
70
+ },
71
+ "api": {
72
+ "level": "INFO",
73
+ "handlers": ["console", "file_api"],
74
+ "propagate": False,
75
+ },
76
+ "pipeline": {
77
+ "level": "INFO",
78
+ "handlers": ["console", "file_pipeline"],
79
+ "propagate": False,
80
+ },
81
+ },
82
+ "root": {
83
+ "level": "INFO",
84
+ "handlers": ["console"],
85
+ },
86
+ }
87
+
88
+ logging.config.dictConfig(logging_config)
src/preprocess.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/preprocess.py
2
+ # Image preprocessing utilities.
3
+
4
+ import cv2
5
+ import numpy as np
6
+
7
+
8
+ def apply_clahe(
9
+ img_rgb: np.ndarray,
10
+ clip_limit: float = 4.0,
11
+ grid_size: int = 6,
12
+ ) -> np.ndarray:
13
+ """
14
+ Apply CLAHE on the L channel of the LAB colour space.
15
+
16
+ Flow:
17
+ RGB → LAB → enhance L channel → RGB
18
+ """
19
+
20
+ clahe = cv2.createCLAHE(
21
+ clipLimit=clip_limit,
22
+ tileGridSize=(grid_size, grid_size),
23
+ )
24
+
25
+ lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB)
26
+ lab[:, :, 0] = clahe.apply(lab[:, :, 0])
27
+
28
+ return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
29
+
30
+
31
+ def preprocess(
32
+ img_rgb: np.ndarray,
33
+ use_clahe: bool = True,
34
+ clip: float = 4.0,
35
+ grid: int = 6,
36
+ ) -> np.ndarray:
37
+ """
38
+ Run preprocessing on an RGB uint8 array.
39
+ """
40
+
41
+ out = img_rgb.copy()
42
+
43
+ if use_clahe:
44
+ out = apply_clahe(
45
+ out,
46
+ clip_limit=clip,
47
+ grid_size=grid,
48
+ )
49
+
50
+ return out
test_queue.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # test_queue.py
2
+ # Simulate several geologists submitting crop GeoTIFF jobs concurrently.
3
+
4
+ import os
5
+ import threading
6
+ import time
7
+
8
+ import requests
9
+
10
+ URL = "http://localhost:8000"
11
+
12
+ # Use a real georeferenced crop GeoTIFF here.
13
+ # For example, one generated by your QGIS plugin:
14
+ # C:\Users\juan_\AppData\Local\Temp\sam2_crop_XXXXXXXXXX.tif
15
+ CROP_PATH = os.path.abspath("sam2_crop_test.tif")
16
+
17
+
18
+ def geologist_client(geologist_id: int):
19
+ print(f"[Geólogo {geologist_id}] Enviando crop GeoTIFF al servidor...")
20
+
21
+ if not os.path.isfile(CROP_PATH):
22
+ print(f"[Geólogo {geologist_id}] No existe el crop: {CROP_PATH}")
23
+ return
24
+
25
+ try:
26
+ with open(CROP_PATH, "rb") as f:
27
+ files = {
28
+ "crop": (
29
+ os.path.basename(CROP_PATH),
30
+ f,
31
+ "image/tiff",
32
+ )
33
+ }
34
+
35
+ data = {
36
+ "device": "cpu",
37
+ "use_clahe": "true",
38
+ "clahe_clip": "4.0",
39
+ "clahe_grid": "6",
40
+ "sam2_points_per_side": "32",
41
+ "sam2_points_per_batch": "32",
42
+ "sam2_pred_iou_thresh": "0.35",
43
+ "sam2_stability_score_thresh": "0.65",
44
+ "filter_min_area_px": "1000",
45
+ "filter_max_area_frac": "0.20",
46
+ "filter_min_iou": "0.35",
47
+ "filter_min_stability": "0.65",
48
+ "filter_border_margin": "10",
49
+ "max_crop_side": "4096",
50
+ }
51
+
52
+ resp = requests.post(
53
+ f"{URL}/process",
54
+ files=files,
55
+ data=data,
56
+ )
57
+
58
+ resp.raise_for_status()
59
+
60
+ except Exception as e:
61
+ print(f"[Geólogo {geologist_id}] Error enviando tarea: {e}")
62
+ return
63
+
64
+ data = resp.json()
65
+ task_id = data["task_id"]
66
+
67
+ print(f"[Geólogo {geologist_id}] Tarea aceptada. ID: {task_id}")
68
+
69
+ last_status = None
70
+
71
+ while True:
72
+ try:
73
+ status_resp = requests.get(f"{URL}/status/{task_id}")
74
+ status_resp.raise_for_status()
75
+ s_data = status_resp.json()
76
+
77
+ status_value = s_data["status"]
78
+
79
+ if status_value == "pending":
80
+ pos = s_data.get("queue_position")
81
+ current_status = f"pending_pos_{pos}"
82
+
83
+ if current_status != last_status:
84
+ print(
85
+ f"[Geólogo {geologist_id}] Estado: En cola | Posición: {pos}"
86
+ )
87
+ last_status = current_status
88
+
89
+ elif status_value == "processing":
90
+ if last_status != "processing":
91
+ print(
92
+ f"[Geólogo {geologist_id}] Estado: Procesando inferencia SAM2..."
93
+ )
94
+ last_status = "processing"
95
+
96
+ elif status_value == "completed":
97
+ n_masks = s_data.get("n_masks")
98
+ output_exists = s_data.get("output_exists")
99
+ download_url = s_data.get("download_url")
100
+
101
+ print(
102
+ f"[Geólogo {geologist_id}] COMPLETO | "
103
+ f"n_masks={n_masks} | output_exists={output_exists}"
104
+ )
105
+
106
+ if output_exists and download_url:
107
+ out_path = f"downloaded_result_{geologist_id}_{task_id}.gpkg"
108
+ download_resp = requests.get(f"{URL}{download_url}")
109
+ download_resp.raise_for_status()
110
+
111
+ with open(out_path, "wb") as out:
112
+ out.write(download_resp.content)
113
+
114
+ print(
115
+ f"[Geólogo {geologist_id}] Resultado descargado: {out_path}"
116
+ )
117
+
118
+ break
119
+
120
+ elif status_value == "failed":
121
+ print(
122
+ f"[Geólogo {geologist_id}] FALLÓ: {s_data.get('error')}"
123
+ )
124
+ break
125
+
126
+ except Exception as e:
127
+ print(f"[Geólogo {geologist_id}] Error consultando estado: {e}")
128
+ break
129
+
130
+ time.sleep(0.5)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ print("Iniciando prueba con 5 geólogos concurrentes...\n")
135
+
136
+ threads = []
137
+
138
+ for i in range(1, 6):
139
+ t = threading.Thread(
140
+ target=geologist_client,
141
+ args=(i,),
142
+ )
143
+
144
+ threads.append(t)
145
+ t.start()
146
+
147
+ for t in threads:
148
+ t.join()
149
+
150
+ print("\nPrueba finalizada.")