File size: 7,657 Bytes
99e1f27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
"""Centralized, env-driven detection configuration.

Single source of truth for detection tuning knobs so the engine, model
inference, and tiling utilities stay consistent. Every value is read from the
environment with conservative defaults that preserve the previous behavior
(``downscaled`` inference, ``smart_union`` fusion, no multi-scale).

Env vars
--------
DETECTION_MAX_SIDE        Max pixel dimension for the legacy downscaled path.
DETECTION_INFERENCE_MODE  ``downscaled`` (default) | ``fullres_tiled``.
DETECTION_FULLRES_MAX_SIDE Cap for full-resolution tiled mode (0 = native).
DETECTION_TILE_SIZE       Tile size for full-res scoring (256..2048).
DETECTION_TILE_OVERLAP    Fractional tile overlap (0.0..0.5).
DETECTION_MULTISCALE      ``off`` | comma list e.g. ``0.5,1.0,1.5``.
DETECTION_FUSION          ``smart_union`` (default) | ``hysteresis``.
DETECTION_SKIP_PREBLUR    ``true`` | ``false`` (auto: on for fullres_tiled).
DETECTION_TTA             ``off`` | ``hflip`` | ``full`` | ``auto`` (model_inference).
"""
from __future__ import annotations

import logging
import os
from typing import List

_log = logging.getLogger(__name__)


def _env(name: str, default: str = "") -> str:
    return os.environ.get(name, default).strip()


def get_detection_max_side() -> int:
    """Max pixel dimension for image load + the downscaled detection path."""
    hosted = bool(_env("SPACE_ID"))
    default = "2048" if hosted else "4096"
    try:
        value = int(os.environ.get("DETECTION_MAX_SIDE", default))
    except ValueError:
        value = int(default)
    return max(1024, min(8192, value))


def get_inference_mode() -> str:
    """Detection inference mode: ``downscaled`` or ``fullres_tiled``."""
    mode = _env("DETECTION_INFERENCE_MODE", "downscaled").lower()
    return mode if mode in ("downscaled", "fullres_tiled") else "downscaled"


def get_fullres_max_side() -> int:
    """Upper bound for full-res tiled mode. 0 means use native resolution.

    Defaults to 8192 so very large GeoTIFFs stay bounded in RAM while still
    running detection at far higher detail than the 2048/4096 downscaled cap.
    """
    raw = _env("DETECTION_FULLRES_MAX_SIDE", "8192")
    try:
        value = int(raw)
    except ValueError:
        value = 8192
    if value <= 0:
        return 0  # native resolution, no cap
    return max(2048, min(20000, value))


def get_load_max_side() -> int:
    """Effective image-load / inference pixel cap for the active mode.

    In ``fullres_tiled`` mode this returns the (much larger) full-res cap so
    images keep their detail; otherwise it returns the standard downscale cap.
    Always a concrete positive int so callers can pass it straight to loaders.
    """
    if get_inference_mode() == "fullres_tiled":
        cap = get_fullres_max_side()
        return cap if cap > 0 else 20000
    return get_detection_max_side()


def get_windowed_threshold() -> int:
    """Native max-side above which GeoTIFF inference streams via rasterio windows.

    Keeps peak RAM bounded for very large rasters: when a GeoTIFF's native
    longest side exceeds this, the deep score map is built tile-by-tile from
    disk instead of loading the whole array. 0 disables windowed streaming.
    """
    raw = _env("DETECTION_WINDOWED_THRESHOLD", "8192")
    try:
        value = int(raw)
    except ValueError:
        value = 8192
    return max(0, value)


def get_tile_size() -> int:
    """Tile size (px) for full-resolution scoring."""
    try:
        value = int(os.environ.get("DETECTION_TILE_SIZE", "512"))
    except ValueError:
        value = 512
    return max(256, min(2048, value))


def get_tile_overlap() -> float:
    """Fractional overlap between adjacent tiles (0.0..0.5)."""
    try:
        value = float(os.environ.get("DETECTION_TILE_OVERLAP", "0.25"))
    except ValueError:
        value = 0.25
    return min(0.5, max(0.0, value))


def get_multiscale_scales() -> List[float]:
    """Scales for multi-scale fusion. Empty list disables it."""
    raw = _env("DETECTION_MULTISCALE", "off").lower()
    if raw in ("off", "", "none", "0", "false"):
        return []
    scales: List[float] = []
    for part in raw.split(","):
        part = part.strip()
        if not part:
            continue
        try:
            scale = float(part)
        except ValueError:
            continue
        if 0.1 <= scale <= 4.0:
            scales.append(scale)
    # Always include native scale so recall never drops below single-scale
    if scales and 1.0 not in scales:
        scales.append(1.0)
    return sorted(set(scales))


def get_fusion_mode() -> str:
    """DL + classical fusion strategy: ``smart_union`` or ``hysteresis``."""
    mode = _env("DETECTION_FUSION", "smart_union").lower()
    return mode if mode in ("smart_union", "hysteresis") else "smart_union"


def get_skip_preblur() -> bool:
    """Whether to skip the preprocessing Gaussian/bilateral blur.

    Explicit env wins; otherwise auto-skip in full-res mode to preserve the
    fine detail that motivates running detection at native resolution.
    """
    raw = _env("DETECTION_SKIP_PREBLUR", "").lower()
    if raw in ("1", "true", "yes", "on"):
        return True
    if raw in ("0", "false", "no", "off"):
        return False
    return get_inference_mode() == "fullres_tiled"


def _flag(name: str, default: bool) -> bool:
    raw = _env(name, "").lower()
    if raw in ("1", "true", "yes", "on"):
        return True
    if raw in ("0", "false", "no", "off"):
        return False
    return default


def get_enable_clahe() -> bool:
    """CLAHE contrast equalization during radiometric normalization (default on)."""
    return _flag("DETECTION_CLAHE", True)


def get_hist_match() -> bool:
    """Optional histogram matching of the after image to the before (default off)."""
    return _flag("DETECTION_HIST_MATCH", False)


def get_tile_batch() -> int:
    """Tiles per model forward pass (GPU throughput). 1 = no batching (default)."""
    try:
        value = int(os.environ.get("DETECTION_TILE_BATCH", "1"))
    except ValueError:
        value = 1
    return max(1, min(64, value))


def get_tile_memory_budget_mb() -> int:
    """Soft RAM budget (MB) for a single in-memory detection array.

    When loading both timestamps at the full-res cap would exceed this, large
    GeoTIFFs switch to disk-windowed streaming so peak memory stays bounded
    (lets 15 GB rasters run without OOM). 0 disables the budget check.
    """
    raw = _env("DETECTION_TILE_MEMORY_MB", "1536")
    try:
        value = int(raw)
    except ValueError:
        value = 1536
    return max(0, value)


def get_save_prob_map() -> bool:
    """Save a downsampled probability-map PNG per run for debugging (default off)."""
    return _flag("DETECTION_SAVE_PROB_MAP", False)


def get_border_margin() -> int:
    """Border pixels zeroed in mask cleanup. Smaller in full-res mode."""
    raw = _env("DETECTION_BORDER_MARGIN", "")
    if raw:
        try:
            return max(0, min(64, int(raw)))
        except ValueError:
            pass
    return 4 if get_inference_mode() == "fullres_tiled" else 12


def summary() -> dict:
    """Snapshot of effective detection config (for logs and debug output)."""
    return {
        "maxSide": get_detection_max_side(),
        "inferenceMode": get_inference_mode(),
        "fullresMaxSide": get_fullres_max_side(),
        "tileSize": get_tile_size(),
        "tileOverlap": get_tile_overlap(),
        "multiscale": get_multiscale_scales(),
        "fusion": get_fusion_mode(),
        "skipPreblur": get_skip_preblur(),
        "borderMargin": get_border_margin(),
    }