prateeksharmacoder commited on
Commit
db89723
·
1 Parent(s): 38f516f

Deploy ZeroGPU codebase

Browse files
app.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import time
3
+ import sys
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import gradio as gr
8
+ from PIL import Image
9
+ import spaces
10
+
11
+ # ============================================================
12
+ # PATHS
13
+ # ============================================================
14
+
15
+ ROOT = Path(__file__).resolve().parent
16
+
17
+ if str(ROOT) not in sys.path:
18
+ sys.path.insert(0, str(ROOT))
19
+
20
+
21
+ # ============================================================
22
+ # ZERO-GPU MODEL LOADING (CPU initially)
23
+ # ============================================================
24
+
25
+ from models.hatsat.hatsat_inference import HATSATInference
26
+ from models.esrgan.esrgan_inference import ESRGANInference
27
+
28
+ print("Loading models to CPU for ZeroGPU deployment...")
29
+ try:
30
+ hatsat_model = HATSATInference(device="cpu")
31
+ esrgan_model = ESRGANInference(
32
+ checkpoint_path=str(ROOT / "weights" / "esrgan" / "RRDB_ESRGAN_x4.pth"),
33
+ device="cpu"
34
+ )
35
+ print("Models loaded successfully on CPU.")
36
+ except Exception as e:
37
+ print(f"Error loading models: {e}")
38
+ hatsat_model = None
39
+ esrgan_model = None
40
+
41
+ # ============================================================
42
+ # ZERO-GPU INFERENCE WRAPPERS
43
+ # ============================================================
44
+
45
+ @spaces.GPU
46
+ def infer_hatsat(image):
47
+ if hatsat_model is None:
48
+ raise RuntimeError("HATSAT model is not loaded.")
49
+ return hatsat_model.predict(image)
50
+
51
+ @spaces.GPU
52
+ def infer_esrgan(image):
53
+ if esrgan_model is None:
54
+ raise RuntimeError("ESRGAN model is not loaded.")
55
+ return esrgan_model.predict(image)
56
+
57
+
58
+ # ============================================================
59
+ # CUSTOM CSS
60
+ # ============================================================
61
+
62
+ CSS = """
63
+ /* ── Google Font ── */
64
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
65
+
66
+ body, .gradio-container {
67
+ font-family: 'Inter', sans-serif !important;
68
+ }
69
+
70
+ /* ── Hero header ── */
71
+ .hero-header {
72
+ background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 50%, #0f172a 100%);
73
+ border-radius: 16px;
74
+ padding: 36px 40px 28px;
75
+ margin-bottom: 8px;
76
+ border: 1px solid rgba(56, 189, 248, 0.2);
77
+ box-shadow: 0 0 40px rgba(56, 189, 248, 0.08);
78
+ position: relative;
79
+ overflow: hidden;
80
+ }
81
+
82
+ .hero-header::before {
83
+ content: '';
84
+ position: absolute;
85
+ top: -60px; right: -60px;
86
+ width: 200px; height: 200px;
87
+ background: radial-gradient(circle, rgba(56,189,248,0.12) 0%, transparent 70%);
88
+ pointer-events: none;
89
+ }
90
+
91
+ .hero-title {
92
+ font-size: 2.1rem;
93
+ font-weight: 700;
94
+ color: #f0f9ff;
95
+ margin: 0 0 8px;
96
+ letter-spacing: -0.5px;
97
+ }
98
+
99
+ .hero-subtitle {
100
+ font-size: 0.95rem;
101
+ color: #94a3b8;
102
+ margin: 0;
103
+ line-height: 1.6;
104
+ }
105
+
106
+ .hero-badge {
107
+ display: inline-block;
108
+ background: rgba(56, 189, 248, 0.15);
109
+ color: #38bdf8;
110
+ border: 1px solid rgba(56, 189, 248, 0.3);
111
+ border-radius: 20px;
112
+ padding: 3px 12px;
113
+ font-size: 0.75rem;
114
+ font-weight: 600;
115
+ letter-spacing: 0.5px;
116
+ margin-bottom: 14px;
117
+ }
118
+
119
+ /* ── Section cards ── */
120
+ .card {
121
+ background: #1e293b;
122
+ border: 1px solid #334155;
123
+ border-radius: 12px;
124
+ padding: 20px;
125
+ margin-bottom: 12px;
126
+ }
127
+
128
+ .section-label {
129
+ font-size: 0.7rem;
130
+ font-weight: 600;
131
+ letter-spacing: 1.2px;
132
+ text-transform: uppercase;
133
+ color: #64748b;
134
+ margin-bottom: 10px;
135
+ }
136
+
137
+ /* ── Generate button ── */
138
+ #generate-btn {
139
+ background: linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%) !important;
140
+ border: none !important;
141
+ color: white !important;
142
+ font-weight: 600 !important;
143
+ font-size: 1rem !important;
144
+ padding: 14px !important;
145
+ border-radius: 10px !important;
146
+ letter-spacing: 0.3px;
147
+ transition: opacity 0.2s ease, transform 0.1s ease !important;
148
+ box-shadow: 0 4px 14px rgba(14, 165, 233, 0.35) !important;
149
+ width: 100%;
150
+ }
151
+ #generate-btn:hover {
152
+ opacity: 0.92 !important;
153
+ transform: translateY(-1px) !important;
154
+ }
155
+ #generate-btn:active {
156
+ transform: translateY(0px) !important;
157
+ }
158
+
159
+ /* ── Download button ── */
160
+ #download-btn {
161
+ background: #0f172a !important;
162
+ border: 1px solid #38bdf8 !important;
163
+ color: #38bdf8 !important;
164
+ font-weight: 600 !important;
165
+ border-radius: 8px !important;
166
+ padding: 10px !important;
167
+ width: 100%;
168
+ transition: background 0.2s ease !important;
169
+ }
170
+ #download-btn:hover {
171
+ background: rgba(56,189,248,0.1) !important;
172
+ }
173
+
174
+ /* ── Status box ── */
175
+ .status-box {
176
+ background: #0f172a;
177
+ border: 1px solid #1e3a5f;
178
+ border-radius: 10px;
179
+ padding: 16px 20px;
180
+ font-size: 0.88rem;
181
+ color: #94a3b8;
182
+ min-height: 120px;
183
+ }
184
+
185
+ /* ── Model info chips ── */
186
+ .model-chip-hat {
187
+ display: inline-block;
188
+ background: rgba(16, 185, 129, 0.12);
189
+ color: #34d399;
190
+ border: 1px solid rgba(16,185,129,0.3);
191
+ border-radius: 6px;
192
+ padding: 2px 10px;
193
+ font-size: 0.72rem;
194
+ font-weight: 600;
195
+ margin-right: 6px;
196
+ }
197
+
198
+ .model-chip-esr {
199
+ display: inline-block;
200
+ background: rgba(168, 85, 247, 0.12);
201
+ color: #c084fc;
202
+ border: 1px solid rgba(168,85,247,0.3);
203
+ border-radius: 6px;
204
+ padding: 2px 10px;
205
+ font-size: 0.72rem;
206
+ font-weight: 600;
207
+ }
208
+
209
+ /* ── Footer ── */
210
+ .footer-note {
211
+ background: rgba(249, 115, 22, 0.07);
212
+ border: 1px solid rgba(249, 115, 22, 0.25);
213
+ border-radius: 8px;
214
+ padding: 12px 16px;
215
+ font-size: 0.82rem;
216
+ color: #fb923c;
217
+ margin-top: 4px;
218
+ }
219
+ """
220
+
221
+
222
+ # ============================================================
223
+ # INFERENCE FUNCTION
224
+ # ============================================================
225
+
226
+ def run_super_resolution(image, model_choice, progress=gr.Progress()):
227
+
228
+ # --------------------------------------------------------
229
+ # Validate input
230
+ # --------------------------------------------------------
231
+ if image is None:
232
+ raise gr.Error("⚠️ Please upload a satellite image before running.")
233
+
234
+ # --------------------------------------------------------
235
+ # Normalize model name
236
+ # --------------------------------------------------------
237
+ model_map = {"HATSAT — Recommended": "hatsat", "ESRGAN — Baseline": "esrgan"}
238
+ selected_model = model_map.get(model_choice)
239
+
240
+ if selected_model is None:
241
+ raise gr.Error("⚠️ Please select a valid model.")
242
+
243
+ # --------------------------------------------------------
244
+ # Ensure PIL image + RGB
245
+ # --------------------------------------------------------
246
+ if not isinstance(image, Image.Image):
247
+ image = Image.fromarray(image)
248
+
249
+ image = image.convert("RGB")
250
+ input_w, input_h = image.size
251
+
252
+ # --------------------------------------------------------
253
+ # Basic sanity check
254
+ # --------------------------------------------------------
255
+ if input_w < 16 or input_h < 16:
256
+ raise gr.Error(
257
+ f"⚠️ Image too small ({input_w}×{input_h}). "
258
+ "Please upload an image at least 16×16 pixels."
259
+ )
260
+
261
+ if input_w > 2000 or input_h > 2000:
262
+ raise gr.Error(
263
+ f"⚠️ Image too large ({input_w}×{input_h}). "
264
+ "For local testing please use images up to 2000×2000 pixels."
265
+ )
266
+
267
+ # --------------------------------------------------------
268
+ # Run inference
269
+ # --------------------------------------------------------
270
+ model_label = "HATSAT" if selected_model == "hatsat" else "ESRGAN"
271
+
272
+ start_time = time.time()
273
+
274
+ try:
275
+ progress(0.5, desc=f"Running {model_label} 4× super-resolution (ZeroGPU) …")
276
+ if selected_model == "hatsat":
277
+ result = infer_hatsat(image)
278
+ else:
279
+ result = infer_esrgan(image)
280
+ except Exception as e:
281
+ raise gr.Error(f"{model_label} inference failed: {e}")
282
+
283
+ elapsed = time.time() - start_time
284
+ progress(0.9, desc="Finalising output …")
285
+
286
+ output_w, output_h = result.size
287
+
288
+ # --------------------------------------------------------
289
+ # Save to a temp PNG for download
290
+ # --------------------------------------------------------
291
+ tmp = tempfile.NamedTemporaryFile(
292
+ suffix=f"_sr_{selected_model}_4x.png",
293
+ delete=False
294
+ )
295
+ result.save(tmp.name, format="PNG")
296
+ tmp.close()
297
+
298
+ # --------------------------------------------------------
299
+ # Info markdown
300
+ # --------------------------------------------------------
301
+ info = f"""
302
+ <div class="status-box">
303
+
304
+ **✅ Super-Resolution Complete**
305
+
306
+ | Field | Value |
307
+ |---|---|
308
+ | **Model** | {model_label} |
309
+ | **Scale** | 4× |
310
+ | **Input size** | {input_w} × {input_h} px |
311
+ | **Output size** | {output_w} × {output_h} px |
312
+ | **Processing time** | {elapsed:.2f} s |
313
+ | **Nominal output sampling** | ~2.5 m (from 10 m input) |
314
+
315
+ </div>
316
+ """
317
+
318
+ progress(1.0, desc="Done.")
319
+ return result, info, tmp.name
320
+
321
+
322
+ # ============================================================
323
+ # CLEAR FUNCTION
324
+ # ============================================================
325
+
326
+ def clear_all():
327
+ return None, None, "", None
328
+
329
+
330
+ # ============================================================
331
+ # UI
332
+ # ============================================================
333
+
334
+ THEME = gr.themes.Base(
335
+ primary_hue="sky",
336
+ neutral_hue="slate",
337
+ )
338
+
339
+ with gr.Blocks(
340
+ title="Satellite Super-Resolution — SIH 26142 (ZeroGPU)",
341
+ ) as demo:
342
+
343
+ # ── Hero header ─────────────────────────────────────────
344
+ gr.HTML("""
345
+ <div class="hero-header">
346
+ <div class="hero-badge">SIH PROBLEM STATEMENT 26142 · NTRO · SPACE TECHNOLOGY</div>
347
+ <div class="hero-title">🛰️ Satellite Image Super-Resolution</div>
348
+ <div class="hero-subtitle">
349
+ Deep learning–based <strong style="color:#38bdf8">4× super-resolution</strong>
350
+ for medium-resolution satellite imagery (Sentinel-2 and similar sources).<br>
351
+ Upload an image, select a model, and generate a sharper super-resolved output.
352
+ </div>
353
+ </div>
354
+ """)
355
+
356
+ # ── Main layout ─────────────────────────────────────────
357
+ with gr.Row(equal_height=False):
358
+
359
+ # ── LEFT column: inputs ─────────────────────────────
360
+ with gr.Column(scale=5):
361
+
362
+ gr.HTML('<div class="section-label">📂 Input Image</div>')
363
+ input_image = gr.Image(
364
+ label="Upload Satellite Image",
365
+ type="pil",
366
+ sources=["upload", "clipboard"],
367
+ elem_id="input-img",
368
+ )
369
+
370
+ gr.HTML('<div class="section-label" style="margin-top:16px">🤖 Model Selection</div>')
371
+ model_selector = gr.Radio(
372
+ choices=["HATSAT — Recommended", "ESRGAN — Baseline"],
373
+ value="HATSAT — Recommended",
374
+ label="Super-Resolution Model",
375
+ info=(
376
+ "HATSAT is fine-tuned for satellite imagery and is the primary model. "
377
+ "ESRGAN is a general-purpose baseline included for comparison."
378
+ ),
379
+ )
380
+
381
+ gr.HTML("""
382
+ <div class="card" style="margin-top:4px; font-size:0.82rem; color:#94a3b8; line-height:1.7;">
383
+ <span class="model-chip-hat">HATSAT</span>
384
+ Satellite-oriented · HAT transformer · 4× upscaling · Recommended<br>
385
+ <span class="model-chip-esr" style="margin-top:6px; display:inline-block;">ESRGAN</span>
386
+ General-purpose · RRDB architecture · 4× upscaling · Baseline comparison
387
+ </div>
388
+ """)
389
+
390
+ with gr.Row():
391
+ generate_btn = gr.Button(
392
+ "🚀 Run Super-Resolution",
393
+ variant="primary",
394
+ elem_id="generate-btn",
395
+ )
396
+ clear_btn = gr.Button(
397
+ "✕ Clear",
398
+ variant="secondary",
399
+ )
400
+
401
+ # ── RIGHT column: outputs ────────────────────────────
402
+ with gr.Column(scale=5):
403
+
404
+ gr.HTML('<div class="section-label">🖼️ Super-Resolved Output</div>')
405
+ output_image = gr.Image(
406
+ label="4× Super-Resolved Output",
407
+ type="pil",
408
+ interactive=False,
409
+ elem_id="output-img",
410
+ )
411
+
412
+ download_file = gr.File(
413
+ label="⬇️ Download Result (PNG)",
414
+ interactive=False,
415
+ visible=True,
416
+ elem_id="download-btn",
417
+ )
418
+
419
+ gr.HTML('<div class="section-label" style="margin-top:12px">📊 Processing Info</div>')
420
+ output_info = gr.Markdown(
421
+ value='<div class="status-box">Results will appear here after processing.</div>',
422
+ )
423
+
424
+ # ── Disclaimer footer ────────────────────────────────────
425
+ gr.HTML("""
426
+ <div class="footer-note">
427
+ ⚠️ <strong>Important:</strong>
428
+ 4× super-resolution produces approximately <strong>2.5 m nominal output sampling</strong>
429
+ from a 10 m Sentinel-2 input. This does <em>not</em> mean the system physically recovers
430
+ true 2.5 m ground resolution. Results are generated outputs, not real sensor measurements.
431
+ </div>
432
+ """)
433
+
434
+ # ── Events ──────────────────────────────────────────────
435
+
436
+ generate_btn.click(
437
+ fn=run_super_resolution,
438
+ inputs=[input_image, model_selector],
439
+ outputs=[output_image, output_info, download_file],
440
+ show_progress="full",
441
+ )
442
+
443
+ clear_btn.click(
444
+ fn=clear_all,
445
+ inputs=[],
446
+ outputs=[input_image, output_image, output_info, download_file],
447
+ )
448
+
449
+
450
+ # ============================================================
451
+ # LAUNCH
452
+ # ============================================================
453
+
454
+ if __name__ == "__main__":
455
+ demo.launch(
456
+ server_name="0.0.0.0",
457
+ server_port=7860,
458
+ show_error=True,
459
+ theme=THEME,
460
+ css=CSS,
461
+ )
inference/geotiff.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ import rasterio
6
+ from rasterio.transform import Affine
7
+ from PIL import Image
8
+
9
+
10
+ # ==========================================================
11
+ # Read Sentinel-2 RGB bands
12
+ # ==========================================================
13
+
14
+ def read_sentinel_rgb(
15
+ b02_path,
16
+ b03_path,
17
+ b04_path
18
+ ):
19
+ """
20
+ Read Sentinel-2 B02, B03 and B04 GeoTIFF bands.
21
+
22
+ Band mapping:
23
+
24
+ B04 -> Red
25
+ B03 -> Green
26
+ B02 -> Blue
27
+
28
+ Returns
29
+ -------
30
+ rgb : numpy.ndarray
31
+ RGB array with shape (H, W, 3)
32
+
33
+ profile : rasterio profile
34
+ Original geospatial metadata.
35
+ """
36
+
37
+ b02_path = Path(b02_path)
38
+ b03_path = Path(b03_path)
39
+ b04_path = Path(b04_path)
40
+
41
+ with rasterio.open(b02_path) as src_b02:
42
+ b02 = src_b02.read(1)
43
+ profile = src_b02.profile.copy()
44
+ transform = src_b02.transform
45
+ crs = src_b02.crs
46
+ width = src_b02.width
47
+ height = src_b02.height
48
+
49
+ with rasterio.open(b03_path) as src_b03:
50
+ b03 = src_b03.read(1)
51
+
52
+ with rasterio.open(b04_path) as src_b04:
53
+ b04 = src_b04.read(1)
54
+
55
+ # Make sure all bands have identical dimensions.
56
+ if b02.shape != b03.shape or b02.shape != b04.shape:
57
+ raise ValueError(
58
+ "B02, B03 and B04 dimensions do not match."
59
+ )
60
+
61
+ # ------------------------------------------------------
62
+ # Sentinel-2 RGB order:
63
+ # Red = B04
64
+ # Green = B03
65
+ # Blue = B02
66
+ # ------------------------------------------------------
67
+
68
+ rgb = np.stack(
69
+ [
70
+ b04,
71
+ b03,
72
+ b02
73
+ ],
74
+ axis=-1
75
+ )
76
+
77
+ return rgb, profile
78
+
79
+
80
+ # ==========================================================
81
+ # Convert raw Sentinel data to visualization RGB
82
+ # ==========================================================
83
+
84
+ def normalize_rgb(
85
+ rgb,
86
+ lower_percentile=2,
87
+ upper_percentile=98
88
+ ):
89
+ """
90
+ Convert Sentinel-2 reflectance/DN values to uint8 RGB.
91
+
92
+ Percentile normalization is performed independently
93
+ for each channel.
94
+ """
95
+
96
+ rgb = rgb.astype(np.float32)
97
+
98
+ result = np.zeros_like(
99
+ rgb,
100
+ dtype=np.uint8
101
+ )
102
+
103
+ for channel in range(3):
104
+
105
+ band = rgb[:, :, channel]
106
+
107
+ low = np.percentile(
108
+ band,
109
+ lower_percentile
110
+ )
111
+
112
+ high = np.percentile(
113
+ band,
114
+ upper_percentile
115
+ )
116
+
117
+ if high <= low:
118
+ result[:, :, channel] = np.clip(
119
+ band,
120
+ 0,
121
+ 255
122
+ ).astype(np.uint8)
123
+
124
+ else:
125
+
126
+ normalized = (
127
+ (band - low) /
128
+ (high - low)
129
+ )
130
+
131
+ normalized = np.clip(
132
+ normalized,
133
+ 0,
134
+ 1
135
+ )
136
+
137
+ result[:, :, channel] = (
138
+ normalized * 255
139
+ ).astype(np.uint8)
140
+
141
+ return result
142
+
143
+
144
+ # ==========================================================
145
+ # GeoTIFF writer
146
+ # ==========================================================
147
+
148
+ def save_sr_geotiff(
149
+ sr_image,
150
+ source_profile,
151
+ output_path,
152
+ scale=4
153
+ ):
154
+ """
155
+ Save a super-resolved RGB image as a GeoTIFF.
156
+
157
+ The geographic extent remains unchanged while
158
+ the pixel dimensions increase by `scale`.
159
+
160
+ Parameters
161
+ ----------
162
+ sr_image : PIL.Image.Image or numpy.ndarray
163
+
164
+ source_profile : dict
165
+ Rasterio profile from the original Sentinel image.
166
+
167
+ output_path : str or Path
168
+
169
+ scale : int
170
+ Super-resolution scale factor.
171
+ """
172
+
173
+ output_path = Path(output_path)
174
+ output_path.parent.mkdir(
175
+ parents=True,
176
+ exist_ok=True
177
+ )
178
+
179
+ # ------------------------------------------------------
180
+ # Convert image to numpy
181
+ # ------------------------------------------------------
182
+
183
+ if isinstance(
184
+ sr_image,
185
+ Image.Image
186
+ ):
187
+ rgb = np.asarray(
188
+ sr_image.convert("RGB")
189
+ )
190
+
191
+ else:
192
+ rgb = np.asarray(
193
+ sr_image
194
+ )
195
+
196
+ if rgb.ndim != 3 or rgb.shape[2] != 3:
197
+ raise ValueError(
198
+ "sr_image must have shape (H, W, 3)."
199
+ )
200
+
201
+ rgb = np.clip(
202
+ rgb,
203
+ 0,
204
+ 255
205
+ ).astype(np.uint8)
206
+
207
+ height, width, _ = rgb.shape
208
+
209
+ # ------------------------------------------------------
210
+ # Original transform
211
+ # ------------------------------------------------------
212
+
213
+ original_transform = source_profile[
214
+ "transform"
215
+ ]
216
+
217
+ # ------------------------------------------------------
218
+ # Scale pixel size by 1/scale
219
+ #
220
+ # Geographic bounds stay unchanged.
221
+ # ------------------------------------------------------
222
+
223
+ new_transform = (
224
+ original_transform *
225
+ Affine.scale(
226
+ 1.0 / scale,
227
+ 1.0 / scale
228
+ )
229
+ )
230
+
231
+ # ------------------------------------------------------
232
+ # Build output profile
233
+ # ------------------------------------------------------
234
+
235
+ profile = source_profile.copy()
236
+
237
+ profile.update(
238
+ {
239
+ "driver": "GTiff",
240
+ "height": height,
241
+ "width": width,
242
+ "count": 3,
243
+ "dtype": "uint8",
244
+ "transform": new_transform,
245
+ "compress": "deflate",
246
+ "photometric": "RGB",
247
+ "tiled": True,
248
+ "blockxsize": 256,
249
+ "blockysize": 256,
250
+ }
251
+ )
252
+
253
+ # Remove incompatible fields where necessary.
254
+ profile.pop(
255
+ "nodata",
256
+ None
257
+ )
258
+
259
+ # ------------------------------------------------------
260
+ # Write GeoTIFF
261
+ # ------------------------------------------------------
262
+ # Remove block settings when not using tiled GeoTIFF
263
+ profile.pop("blockxsize", None)
264
+ profile.pop("blockysize", None)
265
+ profile.pop("tiled", None)
266
+
267
+ with rasterio.open(
268
+ output_path,
269
+ "w",
270
+ **profile
271
+ ) as dst:
272
+
273
+ dst.write(
274
+ rgb[:, :, 0],
275
+ 1
276
+ )
277
+
278
+ dst.write(
279
+ rgb[:, :, 1],
280
+ 2
281
+ )
282
+
283
+ dst.write(
284
+ rgb[:, :, 2],
285
+ 3
286
+ )
287
+
288
+ return output_path
289
+
290
+
291
+ # ==========================================================
292
+ # Convenience function
293
+ # ==========================================================
294
+
295
+ def sentinel_rgb_from_bands(
296
+ b02_path,
297
+ b03_path,
298
+ b04_path
299
+ ):
300
+ """
301
+ Read B02/B03/B04 and return a normalized PIL RGB image
302
+ together with the source GeoTIFF profile.
303
+ """
304
+
305
+ rgb, profile = read_sentinel_rgb(
306
+ b02_path,
307
+ b03_path,
308
+ b04_path
309
+ )
310
+
311
+ rgb_uint8 = normalize_rgb(rgb)
312
+
313
+ image = Image.fromarray(
314
+ rgb_uint8,
315
+ mode="RGB"
316
+ )
317
+
318
+ return image, profile
inference/pipeline.py ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from pathlib import Path
3
+ from PIL import Image
4
+
5
+ from .tiling import create_tiles
6
+ from .stitching import stitch_tiles
7
+
8
+ from models.hatsat.hatsat_inference import HATSATInference
9
+ from models.esrgan.esrgan_inference import ESRGANInference
10
+
11
+
12
+ # ==========================================================
13
+ # Project paths
14
+ # ==========================================================
15
+
16
+ # satellite_sr_deploy/ is the parent of inference/
17
+ ROOT = Path(__file__).resolve().parent.parent
18
+
19
+ HATSAT_CHECKPOINT = (
20
+ ROOT /
21
+ "weights/hatsat/net_g_150000.pth"
22
+ )
23
+
24
+ ESRGAN_CHECKPOINT = (
25
+ ROOT /
26
+ "weights/esrgan/RRDB_ESRGAN_x4.pth"
27
+ )
28
+
29
+
30
+ # ==========================================================
31
+ # Model cache
32
+ # ==========================================================
33
+
34
+ _MODEL_CACHE = {}
35
+
36
+
37
+ def get_model(model_name, device=None):
38
+ """
39
+ Load a model once and reuse it.
40
+
41
+ Parameters
42
+ ----------
43
+ model_name : str
44
+ 'hatsat' or 'esrgan'
45
+
46
+ device : optional
47
+ 'cuda' or 'cpu'
48
+
49
+ Returns
50
+ -------
51
+ Loaded inference model.
52
+ """
53
+
54
+ model_name = model_name.lower().strip()
55
+
56
+ if model_name not in ("hatsat", "esrgan"):
57
+ raise ValueError(
58
+ "model_name must be 'hatsat' or 'esrgan'."
59
+ )
60
+
61
+ cache_key = (
62
+ model_name,
63
+ str(device) if device is not None else "auto"
64
+ )
65
+
66
+ if cache_key in _MODEL_CACHE:
67
+ return _MODEL_CACHE[cache_key]
68
+
69
+ # ------------------------------------------------------
70
+ # HATSAT
71
+ # ------------------------------------------------------
72
+
73
+ if model_name == "hatsat":
74
+
75
+ model = HATSATInference(
76
+ checkpoint_path=HATSAT_CHECKPOINT,
77
+ device=device
78
+ )
79
+
80
+ # ------------------------------------------------------
81
+ # ESRGAN
82
+ # ------------------------------------------------------
83
+
84
+ else:
85
+
86
+ model = ESRGANInference(
87
+ checkpoint_path=ESRGAN_CHECKPOINT,
88
+ device=device
89
+ )
90
+
91
+ _MODEL_CACHE[cache_key] = model
92
+
93
+ return model
94
+
95
+
96
+ # ==========================================================
97
+ # Main pipeline
98
+ # ==========================================================
99
+
100
+ def super_resolve(
101
+ image,
102
+ model_name="hatsat",
103
+ tile_size=128,
104
+ overlap=32,
105
+ device=None
106
+ ):
107
+ """
108
+ Complete satellite super-resolution pipeline.
109
+
110
+ Pipeline:
111
+
112
+ Input image
113
+ ↓
114
+ Tiling
115
+ ↓
116
+ Selected model
117
+ ↓
118
+ 4x SR
119
+ ↓
120
+ Overlap stitching
121
+ ↓
122
+ Final SR image
123
+
124
+ Parameters
125
+ ----------
126
+ image : PIL.Image.Image
127
+
128
+ model_name : str
129
+ 'hatsat' or 'esrgan'
130
+
131
+ tile_size : int
132
+ Input tile size.
133
+
134
+ overlap : int
135
+ Input overlap.
136
+
137
+ device : optional
138
+ 'cuda' or 'cpu'
139
+
140
+ Returns
141
+ -------
142
+ PIL.Image.Image
143
+ Final 4x super-resolved image.
144
+ """
145
+
146
+ # ------------------------------------------------------
147
+ # Validate image
148
+ # ------------------------------------------------------
149
+
150
+ if not isinstance(image, Image.Image):
151
+ raise TypeError(
152
+ "image must be a PIL.Image.Image"
153
+ )
154
+
155
+ image = image.convert("RGB")
156
+
157
+ original_size = image.size
158
+
159
+ print(
160
+ f"Pipeline input: "
161
+ f"{original_size[0]} × {original_size[1]}"
162
+ )
163
+
164
+ # ------------------------------------------------------
165
+ # Get selected model
166
+ # ------------------------------------------------------
167
+
168
+ model = get_model(
169
+ model_name,
170
+ device=device
171
+ )
172
+
173
+ print(
174
+ f"Selected model: "
175
+ f"{model_name.upper()}"
176
+ )
177
+
178
+ # ------------------------------------------------------
179
+ # Create tiles
180
+ # ------------------------------------------------------
181
+
182
+ tiles = create_tiles(
183
+ image,
184
+ tile_size=tile_size,
185
+ overlap=overlap
186
+ )
187
+
188
+ print(
189
+ f"Number of tiles: {len(tiles)}"
190
+ )
191
+
192
+ # ------------------------------------------------------
193
+ # Run SR on each tile
194
+ # ------------------------------------------------------
195
+
196
+ sr_tiles = []
197
+
198
+ for tile_info in tiles:
199
+
200
+ print(
201
+ f"Processing tile "
202
+ f"{tile_info['index'] + 1}/"
203
+ f"{len(tiles)} "
204
+ f"(x={tile_info['x']}, "
205
+ f"y={tile_info['y']})"
206
+ )
207
+
208
+ sr_image = model.predict(
209
+ tile_info["image"]
210
+ )
211
+
212
+ sr_tiles.append(
213
+ {
214
+ "image": sr_image,
215
+ "x": tile_info["x"],
216
+ "y": tile_info["y"]
217
+ }
218
+ )
219
+
220
+ # ------------------------------------------------------
221
+ # Stitch SR tiles
222
+ # ------------------------------------------------------
223
+
224
+ result = stitch_tiles(
225
+ sr_tiles,
226
+ scale=4,
227
+ original_size=original_size,
228
+ overlap=overlap
229
+ )
230
+
231
+ print(
232
+ f"Pipeline output: "
233
+ f"{result.size[0]} × "
234
+ f"{result.size[1]}"
235
+ )
236
+
237
+ return result
238
+
239
+
240
+ # ==========================================================
241
+ # Convenience functions
242
+ # ==========================================================
243
+
244
+ def run_hatsat_pipeline(
245
+ image,
246
+ tile_size=128,
247
+ overlap=32,
248
+ device=None
249
+ ):
250
+ return super_resolve(
251
+ image=image,
252
+ model_name="hatsat",
253
+ tile_size=tile_size,
254
+ overlap=overlap,
255
+ device=device
256
+ )
257
+
258
+
259
+ def run_esrgan_pipeline(
260
+ image,
261
+ tile_size=128,
262
+ overlap=32,
263
+ device=None
264
+ ):
265
+ return super_resolve(
266
+ image=image,
267
+ model_name="esrgan",
268
+ tile_size=tile_size,
269
+ overlap=overlap,
270
+ device=device
271
+ )
272
+
273
+
274
+ # ==========================================================
275
+ # COMPLETE SATELLITE PROCESSING
276
+ # ==========================================================
277
+
278
+ from .geotiff import (
279
+ sentinel_rgb_from_bands,
280
+ save_sr_geotiff
281
+ )
282
+
283
+
284
+ def process_satellite(
285
+ b02_path,
286
+ b03_path,
287
+ b04_path,
288
+ model_name="hatsat",
289
+ output_dir=None,
290
+ tile_size=128,
291
+ overlap=32,
292
+ device=None
293
+ ):
294
+ """
295
+ Complete Sentinel-2 satellite super-resolution workflow.
296
+
297
+ Input:
298
+ B02, B03 and B04 Sentinel-2 GeoTIFF files.
299
+
300
+ Processing:
301
+ B02/B03/B04
302
+ ↓
303
+ RGB conversion
304
+ ↓
305
+ Tiling
306
+ ↓
307
+ HATSAT / ESRGAN
308
+ ↓
309
+ Stitching
310
+ ↓
311
+ 4x SR
312
+
313
+ Output:
314
+ PNG preview
315
+ Georeferenced RGB GeoTIFF
316
+
317
+ Returns
318
+ -------
319
+ dict
320
+ {
321
+ "model": model_name,
322
+ "input_size": (...),
323
+ "output_size": (...),
324
+ "png": Path,
325
+ "geotiff": Path
326
+ }
327
+ """
328
+
329
+ # ------------------------------------------------------
330
+ # Paths
331
+ # ------------------------------------------------------
332
+
333
+ b02_path = Path(b02_path)
334
+ b03_path = Path(b03_path)
335
+ b04_path = Path(b04_path)
336
+
337
+ if output_dir is None:
338
+ output_dir = (
339
+ ROOT /
340
+ "outputs" /
341
+ model_name.lower() /
342
+ "results"
343
+ )
344
+
345
+ output_dir = Path(output_dir)
346
+ output_dir.mkdir(
347
+ parents=True,
348
+ exist_ok=True
349
+ )
350
+
351
+ # ------------------------------------------------------
352
+ # Validate inputs
353
+ # ------------------------------------------------------
354
+
355
+ for path in [
356
+ b02_path,
357
+ b03_path,
358
+ b04_path
359
+ ]:
360
+
361
+ if not path.exists():
362
+ raise FileNotFoundError(
363
+ f"Input file not found: {path}"
364
+ )
365
+
366
+ # ------------------------------------------------------
367
+ # Read Sentinel-2 bands
368
+ # ------------------------------------------------------
369
+
370
+ print("\n========================================")
371
+ print("SATELLITE SUPER-RESOLUTION PIPELINE")
372
+ print("========================================")
373
+
374
+ print("\nReading Sentinel-2 bands...")
375
+
376
+ image, source_profile = sentinel_rgb_from_bands(
377
+ b02_path,
378
+ b03_path,
379
+ b04_path
380
+ )
381
+
382
+ print(
383
+ "Input RGB size:",
384
+ image.size
385
+ )
386
+
387
+ print(
388
+ "Source CRS:",
389
+ source_profile["crs"]
390
+ )
391
+
392
+ # ------------------------------------------------------
393
+ # Run existing SR pipeline
394
+ # ------------------------------------------------------
395
+
396
+ print(
397
+ "\nRunning model:",
398
+ model_name.upper()
399
+ )
400
+
401
+ sr_image = super_resolve(
402
+ image=image,
403
+ model_name=model_name,
404
+ tile_size=tile_size,
405
+ overlap=overlap,
406
+ device=device
407
+ )
408
+
409
+ print(
410
+ "\nSR image size:",
411
+ sr_image.size
412
+ )
413
+
414
+ # ------------------------------------------------------
415
+ # Output filenames
416
+ # ------------------------------------------------------
417
+
418
+ model_tag = model_name.lower()
419
+
420
+ png_path = (
421
+ output_dir /
422
+ f"sentinel2_{model_tag}_4x.png"
423
+ )
424
+
425
+ geotiff_path = (
426
+ output_dir /
427
+ f"sentinel2_{model_tag}_4x_georef.tif"
428
+ )
429
+
430
+ # ------------------------------------------------------
431
+ # Save PNG preview
432
+ # ------------------------------------------------------
433
+
434
+ sr_image.save(
435
+ png_path,
436
+ format="PNG"
437
+ )
438
+
439
+ print(
440
+ "\nPNG saved:",
441
+ png_path
442
+ )
443
+
444
+ # ------------------------------------------------------
445
+ # Save georeferenced GeoTIFF
446
+ # ------------------------------------------------------
447
+
448
+ save_sr_geotiff(
449
+ sr_image=sr_image,
450
+ source_profile=source_profile,
451
+ output_path=geotiff_path,
452
+ scale=4
453
+ )
454
+
455
+ print(
456
+ "GeoTIFF saved:",
457
+ geotiff_path
458
+ )
459
+
460
+ # ------------------------------------------------------
461
+ # Final summary
462
+ # ------------------------------------------------------
463
+
464
+ print("\n========================================")
465
+ print("PROCESSING COMPLETE")
466
+ print("========================================")
467
+
468
+ print(
469
+ "Model:",
470
+ model_name.upper()
471
+ )
472
+
473
+ print(
474
+ "Input:",
475
+ image.size
476
+ )
477
+
478
+ print(
479
+ "Output:",
480
+ sr_image.size
481
+ )
482
+
483
+ print(
484
+ "Scale: 4x"
485
+ )
486
+
487
+ print(
488
+ "PNG:",
489
+ png_path
490
+ )
491
+
492
+ print(
493
+ "GeoTIFF:",
494
+ geotiff_path
495
+ )
496
+
497
+ return {
498
+ "model": model_name.lower(),
499
+ "input_size": image.size,
500
+ "output_size": sr_image.size,
501
+ "png": png_path,
502
+ "geotiff": geotiff_path
503
+ }
504
+
inference/stitching.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import numpy as np
3
+ from PIL import Image
4
+
5
+
6
+ def create_blend_weight(
7
+ height,
8
+ width,
9
+ overlap
10
+ ):
11
+ """
12
+ Create a smooth 2D blending weight.
13
+
14
+ Pixels near the center receive higher weight.
15
+ Pixels near overlapping boundaries receive lower weight.
16
+ """
17
+
18
+ if overlap <= 0:
19
+ return np.ones(
20
+ (height, width),
21
+ dtype=np.float32
22
+ )
23
+
24
+ # Horizontal weights
25
+ wx = np.ones(width, dtype=np.float32)
26
+
27
+ transition = min(overlap, width // 2)
28
+
29
+ if transition > 0:
30
+
31
+ ramp = np.linspace(
32
+ 0.01,
33
+ 1.0,
34
+ transition,
35
+ dtype=np.float32
36
+ )
37
+
38
+ wx[:transition] = ramp
39
+
40
+ wx[-transition:] = ramp[::-1]
41
+
42
+ # Vertical weights
43
+ wy = np.ones(height, dtype=np.float32)
44
+
45
+ transition = min(overlap, height // 2)
46
+
47
+ if transition > 0:
48
+
49
+ ramp = np.linspace(
50
+ 0.01,
51
+ 1.0,
52
+ transition,
53
+ dtype=np.float32
54
+ )
55
+
56
+ wy[:transition] = ramp
57
+
58
+ wy[-transition:] = ramp[::-1]
59
+
60
+ return wy[:, None] * wx[None, :]
61
+
62
+
63
+ def stitch_tiles(
64
+ sr_tiles,
65
+ scale=4,
66
+ original_size=None,
67
+ overlap=32
68
+ ):
69
+ """
70
+ Stitch overlapping super-resolution tiles.
71
+
72
+ Parameters
73
+ ----------
74
+ sr_tiles : list of dictionaries
75
+
76
+ Each dictionary must contain:
77
+
78
+ image
79
+ x
80
+ y
81
+
82
+ where x/y are coordinates in the ORIGINAL image.
83
+
84
+ scale : int
85
+ Super-resolution scale factor.
86
+
87
+ original_size : tuple
88
+ (width, height) of original image.
89
+
90
+ overlap : int
91
+ Overlap in ORIGINAL-image pixels.
92
+
93
+ Returns
94
+ -------
95
+ PIL.Image
96
+ Final stitched SR image.
97
+ """
98
+
99
+ if not sr_tiles:
100
+ raise ValueError("No SR tiles supplied.")
101
+
102
+ if original_size is None:
103
+ raise ValueError(
104
+ "original_size must be provided."
105
+ )
106
+
107
+ original_width, original_height = original_size
108
+
109
+ output_width = original_width * scale
110
+ output_height = original_height * scale
111
+
112
+ # Accumulate weighted RGB values
113
+ canvas = np.zeros(
114
+ (
115
+ output_height,
116
+ output_width,
117
+ 3
118
+ ),
119
+ dtype=np.float32
120
+ )
121
+
122
+ # Accumulate weights
123
+ weights = np.zeros(
124
+ (
125
+ output_height,
126
+ output_width
127
+ ),
128
+ dtype=np.float32
129
+ )
130
+
131
+ sr_overlap = overlap * scale
132
+
133
+ for tile_info in sr_tiles:
134
+
135
+ image = tile_info["image"]
136
+
137
+ if not isinstance(image, Image.Image):
138
+ image = Image.fromarray(image)
139
+
140
+ image = image.convert("RGB")
141
+
142
+ tile = np.asarray(
143
+ image,
144
+ dtype=np.float32
145
+ )
146
+
147
+ tile_height, tile_width = tile.shape[:2]
148
+
149
+ # Original-image coordinates → SR coordinates
150
+ x = int(tile_info["x"] * scale)
151
+ y = int(tile_info["y"] * scale)
152
+
153
+ # Do not allow the tile to exceed final canvas
154
+ valid_width = min(
155
+ tile_width,
156
+ output_width - x
157
+ )
158
+
159
+ valid_height = min(
160
+ tile_height,
161
+ output_height - y
162
+ )
163
+
164
+ if valid_width <= 0 or valid_height <= 0:
165
+ continue
166
+
167
+ tile = tile[
168
+ :valid_height,
169
+ :valid_width
170
+ ]
171
+
172
+ # --------------------------------------------------
173
+ # Build blending weight
174
+ # --------------------------------------------------
175
+
176
+ weight = create_blend_weight(
177
+ valid_height,
178
+ valid_width,
179
+ sr_overlap
180
+ )
181
+
182
+ # --------------------------------------------------
183
+ # Accumulate
184
+ # --------------------------------------------------
185
+
186
+ canvas[
187
+ y:y + valid_height,
188
+ x:x + valid_width
189
+ ] += tile * weight[..., None]
190
+
191
+ weights[
192
+ y:y + valid_height,
193
+ x:x + valid_width
194
+ ] += weight
195
+
196
+ # ------------------------------------------------------
197
+ # Normalize overlapping pixels
198
+ # ------------------------------------------------------
199
+
200
+ weights = np.maximum(
201
+ weights,
202
+ 1e-8
203
+ )
204
+
205
+ canvas /= weights[..., None]
206
+
207
+ canvas = np.clip(
208
+ canvas,
209
+ 0,
210
+ 255
211
+ ).round().astype(np.uint8)
212
+
213
+ return Image.fromarray(
214
+ canvas,
215
+ mode="RGB"
216
+ )
inference/tiling.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from pathlib import Path
3
+ from PIL import Image
4
+
5
+
6
+ DEFAULT_TILE_SIZE = 128
7
+ DEFAULT_OVERLAP = 32
8
+
9
+
10
+ def calculate_positions(length, tile_size, overlap):
11
+ """
12
+ Calculate tile starting positions along one dimension.
13
+
14
+ The final tile always reaches the image boundary.
15
+ """
16
+
17
+ if tile_size <= 0:
18
+ raise ValueError("tile_size must be positive.")
19
+
20
+ if overlap < 0 or overlap >= tile_size:
21
+ raise ValueError(
22
+ "overlap must satisfy 0 <= overlap < tile_size."
23
+ )
24
+
25
+ stride = tile_size - overlap
26
+
27
+ if length <= tile_size:
28
+ return [0]
29
+
30
+ positions = list(range(0, length - tile_size + 1, stride))
31
+
32
+ # Ensure the final region is covered.
33
+ last_position = length - tile_size
34
+
35
+ if positions[-1] != last_position:
36
+ positions.append(last_position)
37
+
38
+ return positions
39
+
40
+
41
+ def create_tiles(
42
+ image,
43
+ tile_size=DEFAULT_TILE_SIZE,
44
+ overlap=DEFAULT_OVERLAP
45
+ ):
46
+ """
47
+ Split a PIL image into overlapping tiles.
48
+
49
+ Returns:
50
+ list of dictionaries containing:
51
+ image
52
+ x
53
+ y
54
+ width
55
+ height
56
+ index
57
+ """
58
+
59
+ if not isinstance(image, Image.Image):
60
+ raise TypeError(
61
+ "image must be a PIL.Image.Image"
62
+ )
63
+
64
+ image = image.convert("RGB")
65
+
66
+ width, height = image.size
67
+
68
+ x_positions = calculate_positions(
69
+ width,
70
+ tile_size,
71
+ overlap
72
+ )
73
+
74
+ y_positions = calculate_positions(
75
+ height,
76
+ tile_size,
77
+ overlap
78
+ )
79
+
80
+ tiles = []
81
+
82
+ index = 0
83
+
84
+ for y in y_positions:
85
+
86
+ for x in x_positions:
87
+
88
+ right = min(
89
+ x + tile_size,
90
+ width
91
+ )
92
+
93
+ bottom = min(
94
+ y + tile_size,
95
+ height
96
+ )
97
+
98
+ tile = image.crop(
99
+ (x, y, right, bottom)
100
+ )
101
+
102
+ tiles.append(
103
+ {
104
+ "image": tile,
105
+ "x": x,
106
+ "y": y,
107
+ "width": right - x,
108
+ "height": bottom - y,
109
+ "index": index
110
+ }
111
+ )
112
+
113
+ index += 1
114
+
115
+ return tiles
116
+
117
+
118
+ def save_tiles(
119
+ tiles,
120
+ output_dir,
121
+ prefix="tile"
122
+ ):
123
+ """
124
+ Save generated tiles to disk.
125
+ """
126
+
127
+ output_dir = Path(output_dir)
128
+ output_dir.mkdir(
129
+ parents=True,
130
+ exist_ok=True
131
+ )
132
+
133
+ metadata = []
134
+
135
+ for tile_info in tiles:
136
+
137
+ image = tile_info["image"]
138
+
139
+ filename = (
140
+ f"{prefix}_"
141
+ f"{tile_info['index']:03d}_"
142
+ f"x{tile_info['x']}_"
143
+ f"y{tile_info['y']}.png"
144
+ )
145
+
146
+ path = output_dir / filename
147
+
148
+ image.save(path)
149
+
150
+ metadata.append(
151
+ {
152
+ "index": tile_info["index"],
153
+ "x": tile_info["x"],
154
+ "y": tile_info["y"],
155
+ "width": tile_info["width"],
156
+ "height": tile_info["height"],
157
+ "filename": filename
158
+ }
159
+ )
160
+
161
+ return metadata
model_router.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import gc
3
+ import torch
4
+ from pathlib import Path
5
+
6
+
7
+ class SatelliteSRRouter:
8
+
9
+ def __init__(self, device=None):
10
+
11
+ from models.hatsat.hatsat_inference import HATSATInference
12
+ from models.esrgan.esrgan_inference import ESRGANInference
13
+
14
+ self.HATSATInference = HATSATInference
15
+ self.ESRGANInference = ESRGANInference
16
+
17
+ if device is None:
18
+ device = "cuda" if torch.cuda.is_available() else "cpu"
19
+
20
+ self.device = device
21
+
22
+ # Lazy loading:
23
+ # Only the selected model is loaded.
24
+ self.hatsat = None
25
+ self.esrgan = None
26
+
27
+ print("=" * 55)
28
+ print("SATELLITE SR ROUTER")
29
+ print("=" * 55)
30
+ print("Device:", self.device)
31
+ print("Router initialized.")
32
+ print("Models will be loaded only when selected.")
33
+
34
+ def _clear_gpu(self):
35
+
36
+ gc.collect()
37
+
38
+ if torch.cuda.is_available():
39
+ torch.cuda.empty_cache()
40
+ torch.cuda.ipc_collect()
41
+
42
+ def _unload_hatsat(self):
43
+
44
+ if self.hatsat is not None:
45
+
46
+ print("Unloading HATSAT...")
47
+
48
+ try:
49
+ if hasattr(self.hatsat, "model"):
50
+ self.hatsat.model.cpu()
51
+ except Exception:
52
+ pass
53
+
54
+ self.hatsat = None
55
+
56
+ self._clear_gpu()
57
+
58
+ def _unload_esrgan(self):
59
+
60
+ if self.esrgan is not None:
61
+
62
+ print("Unloading ESRGAN...")
63
+
64
+ try:
65
+ if hasattr(self.esrgan, "model"):
66
+ self.esrgan.model.cpu()
67
+ except Exception:
68
+ pass
69
+
70
+ self.esrgan = None
71
+
72
+ self._clear_gpu()
73
+
74
+ def _load_hatsat(self):
75
+
76
+ if self.hatsat is None:
77
+
78
+ print("Loading HATSAT...")
79
+
80
+ self.hatsat = self.HATSATInference(
81
+ device=self.device
82
+ )
83
+
84
+ print("HATSAT loaded successfully.")
85
+
86
+ def _load_esrgan(self):
87
+
88
+ if self.esrgan is None:
89
+
90
+ print("Loading ESRGAN...")
91
+
92
+ checkpoint = (
93
+ Path(__file__).resolve().parent
94
+ / "weights"
95
+ / "esrgan"
96
+ / "RRDB_ESRGAN_x4.pth"
97
+ )
98
+
99
+ self.esrgan = self.ESRGANInference(
100
+ checkpoint_path=checkpoint,
101
+ device=self.device
102
+ )
103
+
104
+ print("ESRGAN loaded successfully.")
105
+
106
+ def available_models(self):
107
+
108
+ return {
109
+ "hatsat": {
110
+ "name": "HATSAT",
111
+ "description": "Satellite-oriented super-resolution model",
112
+ "scale": 4
113
+ },
114
+
115
+ "esrgan": {
116
+ "name": "ESRGAN",
117
+ "description": "General-purpose super-resolution baseline",
118
+ "scale": 4
119
+ }
120
+ }
121
+
122
+ def predict(self, image, model_name="hatsat"):
123
+
124
+ if image is None:
125
+ raise ValueError("Please upload an image.")
126
+
127
+ model_name = str(model_name).lower().strip()
128
+
129
+ print("=" * 55)
130
+ print("ROUTER INFERENCE")
131
+ print("Selected model:", model_name)
132
+ print("=" * 55)
133
+
134
+ # ==========================================
135
+ # HATSAT
136
+ # ==========================================
137
+
138
+ if model_name == "hatsat":
139
+
140
+ # Free GPU memory used by ESRGAN
141
+ self._unload_esrgan()
142
+
143
+ # Load HATSAT only when required
144
+ self._load_hatsat()
145
+
146
+ print("Running HATSAT...")
147
+
148
+ result = self.hatsat.predict(image)
149
+
150
+ print("HATSAT inference complete.")
151
+
152
+ return result
153
+
154
+ # ==========================================
155
+ # ESRGAN
156
+ # ==========================================
157
+
158
+ elif model_name == "esrgan":
159
+
160
+ # Free GPU memory used by HATSAT
161
+ self._unload_hatsat()
162
+
163
+ # Load ESRGAN only when required
164
+ self._load_esrgan()
165
+
166
+ print("Running ESRGAN...")
167
+
168
+ result = self.esrgan.predict(image)
169
+
170
+ print("ESRGAN inference complete.")
171
+
172
+ return result
173
+
174
+ else:
175
+
176
+ raise ValueError(
177
+ f"Unknown model '{model_name}'. "
178
+ f"Choose 'hatsat' or 'esrgan'."
179
+ )
models/__init__.py ADDED
File without changes
models/esrgan/RRDBNet_arch.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import functools
2
+ import torch
3
+ import torch.nn as nn
4
+ import torch.nn.functional as F
5
+
6
+
7
+ def make_layer(block, n_layers):
8
+ layers = []
9
+ for _ in range(n_layers):
10
+ layers.append(block())
11
+ return nn.Sequential(*layers)
12
+
13
+
14
+ class ResidualDenseBlock_5C(nn.Module):
15
+ def __init__(self, nf=64, gc=32, bias=True):
16
+ super(ResidualDenseBlock_5C, self).__init__()
17
+ # gc: growth channel, i.e. intermediate channels
18
+ self.conv1 = nn.Conv2d(nf, gc, 3, 1, 1, bias=bias)
19
+ self.conv2 = nn.Conv2d(nf + gc, gc, 3, 1, 1, bias=bias)
20
+ self.conv3 = nn.Conv2d(nf + 2 * gc, gc, 3, 1, 1, bias=bias)
21
+ self.conv4 = nn.Conv2d(nf + 3 * gc, gc, 3, 1, 1, bias=bias)
22
+ self.conv5 = nn.Conv2d(nf + 4 * gc, nf, 3, 1, 1, bias=bias)
23
+ self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)
24
+
25
+ # initialization
26
+ # mutil.initialize_weights([self.conv1, self.conv2, self.conv3, self.conv4, self.conv5], 0.1)
27
+
28
+ def forward(self, x):
29
+ x1 = self.lrelu(self.conv1(x))
30
+ x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1)))
31
+ x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1)))
32
+ x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1)))
33
+ x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1))
34
+ return x5 * 0.2 + x
35
+
36
+
37
+ class RRDB(nn.Module):
38
+ '''Residual in Residual Dense Block'''
39
+
40
+ def __init__(self, nf, gc=32):
41
+ super(RRDB, self).__init__()
42
+ self.RDB1 = ResidualDenseBlock_5C(nf, gc)
43
+ self.RDB2 = ResidualDenseBlock_5C(nf, gc)
44
+ self.RDB3 = ResidualDenseBlock_5C(nf, gc)
45
+
46
+ def forward(self, x):
47
+ out = self.RDB1(x)
48
+ out = self.RDB2(out)
49
+ out = self.RDB3(out)
50
+ return out * 0.2 + x
51
+
52
+
53
+ class RRDBNet(nn.Module):
54
+ def __init__(self, in_nc, out_nc, nf, nb, gc=32):
55
+ super(RRDBNet, self).__init__()
56
+ RRDB_block_f = functools.partial(RRDB, nf=nf, gc=gc)
57
+
58
+ self.conv_first = nn.Conv2d(in_nc, nf, 3, 1, 1, bias=True)
59
+ self.RRDB_trunk = make_layer(RRDB_block_f, nb)
60
+ self.trunk_conv = nn.Conv2d(nf, nf, 3, 1, 1, bias=True)
61
+ #### upsampling
62
+ self.upconv1 = nn.Conv2d(nf, nf, 3, 1, 1, bias=True)
63
+ self.upconv2 = nn.Conv2d(nf, nf, 3, 1, 1, bias=True)
64
+ self.HRconv = nn.Conv2d(nf, nf, 3, 1, 1, bias=True)
65
+ self.conv_last = nn.Conv2d(nf, out_nc, 3, 1, 1, bias=True)
66
+
67
+ self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)
68
+
69
+ def forward(self, x):
70
+ fea = self.conv_first(x)
71
+ trunk = self.trunk_conv(self.RRDB_trunk(fea))
72
+ fea = fea + trunk
73
+
74
+ fea = self.lrelu(self.upconv1(F.interpolate(fea, scale_factor=2, mode='nearest')))
75
+ fea = self.lrelu(self.upconv2(F.interpolate(fea, scale_factor=2, mode='nearest')))
76
+ out = self.conv_last(self.lrelu(self.HRconv(fea)))
77
+
78
+ return out
models/esrgan/__init__.py ADDED
File without changes
models/esrgan/esrgan_inference.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import torch
3
+ import numpy as np
4
+ from PIL import Image
5
+
6
+ from .RRDBNet_arch import RRDBNet
7
+
8
+
9
+ class ESRGANInference:
10
+
11
+ def __init__(self, checkpoint_path, device=None):
12
+
13
+ self.checkpoint_path = str(checkpoint_path)
14
+
15
+ # --------------------------------------------------
16
+ # Device
17
+ # --------------------------------------------------
18
+
19
+ if device is None:
20
+ self.device = torch.device(
21
+ "cuda" if torch.cuda.is_available() else "cpu"
22
+ )
23
+ else:
24
+ self.device = torch.device(device)
25
+
26
+ print(f"ESRGAN device: {self.device}")
27
+
28
+ # --------------------------------------------------
29
+ # Original ESRGAN architecture
30
+ # --------------------------------------------------
31
+
32
+ self.model = RRDBNet(
33
+ 3,
34
+ 3,
35
+ 64,
36
+ 23,
37
+ gc=32
38
+ )
39
+
40
+ # --------------------------------------------------
41
+ # Load pretrained checkpoint
42
+ # --------------------------------------------------
43
+
44
+ checkpoint = torch.load(
45
+ self.checkpoint_path,
46
+ map_location="cpu"
47
+ )
48
+
49
+ # Different ESRGAN checkpoints may store weights
50
+ # directly or under a nested key.
51
+ if isinstance(checkpoint, dict):
52
+
53
+ if "params_ema" in checkpoint:
54
+ state_dict = checkpoint["params_ema"]
55
+
56
+ elif "params" in checkpoint:
57
+ state_dict = checkpoint["params"]
58
+
59
+ elif "state_dict" in checkpoint:
60
+ state_dict = checkpoint["state_dict"]
61
+
62
+ else:
63
+ state_dict = checkpoint
64
+
65
+ else:
66
+ state_dict = checkpoint
67
+
68
+ # Remove possible "module." prefix
69
+ cleaned_state_dict = {}
70
+
71
+ for key, value in state_dict.items():
72
+
73
+ if key.startswith("module."):
74
+ key = key[7:]
75
+
76
+ cleaned_state_dict[key] = value
77
+
78
+ self.model.load_state_dict(
79
+ cleaned_state_dict,
80
+ strict=True
81
+ )
82
+
83
+ print("ESRGAN checkpoint loaded successfully.")
84
+
85
+ self.model.eval()
86
+ self.model.to(self.device)
87
+
88
+ # ------------------------------------------------------
89
+ # Preprocessing
90
+ # ------------------------------------------------------
91
+
92
+ @staticmethod
93
+ def prepare_image(image):
94
+
95
+ if isinstance(image, Image.Image):
96
+
97
+ image = image.convert("RGB")
98
+ image = np.array(image)
99
+
100
+ elif isinstance(image, np.ndarray):
101
+
102
+ if image.ndim == 2:
103
+ image = np.stack(
104
+ [image] * 3,
105
+ axis=-1
106
+ )
107
+
108
+ if image.shape[-1] != 3:
109
+ raise ValueError(
110
+ "Input image must have 3 RGB channels."
111
+ )
112
+
113
+ image = image.astype(np.uint8)
114
+
115
+ else:
116
+
117
+ raise TypeError(
118
+ "Input must be a PIL image or NumPy array."
119
+ )
120
+
121
+ tensor = torch.from_numpy(
122
+ image.astype(np.float32) / 255.0
123
+ )
124
+
125
+ tensor = tensor.permute(
126
+ 2, 0, 1
127
+ )
128
+
129
+ tensor = tensor.unsqueeze(0)
130
+
131
+ return tensor
132
+
133
+ # ------------------------------------------------------
134
+ # Inference
135
+ # ------------------------------------------------------
136
+
137
+ @torch.no_grad()
138
+ def predict(self, image):
139
+
140
+ input_tensor = self.prepare_image(image)
141
+
142
+ device = next(self.model.parameters()).device
143
+ input_tensor = input_tensor.to(device)
144
+
145
+ # --------------------------------------------------
146
+ # ESRGAN 4x inference
147
+ # --------------------------------------------------
148
+
149
+ output = self.model(input_tensor)
150
+
151
+ # --------------------------------------------------
152
+ # Convert output to RGB uint8
153
+ # --------------------------------------------------
154
+
155
+ output = output.clamp(0, 1)
156
+
157
+ output = output.squeeze(0)
158
+
159
+ output = output.permute(
160
+ 1, 2, 0
161
+ )
162
+
163
+ output = (
164
+ output.cpu().numpy() * 255.0
165
+ ).round().astype(np.uint8)
166
+
167
+ return Image.fromarray(
168
+ output,
169
+ mode="RGB"
170
+ )
171
+
172
+
173
+ # ----------------------------------------------------------
174
+ # Convenience function
175
+ # ----------------------------------------------------------
176
+
177
+ def run_esrgan(
178
+ image,
179
+ checkpoint_path,
180
+ device=None
181
+ ):
182
+
183
+ model = ESRGANInference(
184
+ checkpoint_path=checkpoint_path,
185
+ device=device
186
+ )
187
+
188
+ return model.predict(image)
models/hatsat/__init__.py ADDED
File without changes
models/hatsat/components.py ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HAT model components and building blocks.
3
+ """
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+ import math
8
+ from einops import rearrange
9
+
10
+
11
+ def to_2tuple(x):
12
+ """Convert input to tuple of length 2."""
13
+ if isinstance(x, (tuple, list)):
14
+ return tuple(x)
15
+ return (x, x)
16
+
17
+
18
+ def trunc_normal_(tensor, mean=0., std=1., a=-2., b=2.):
19
+ """Truncated normal initialization."""
20
+ def norm_cdf(x):
21
+ return (1. + math.erf(x / math.sqrt(2.))) / 2.
22
+
23
+ with torch.no_grad():
24
+ l = norm_cdf((a - mean) / std)
25
+ u = norm_cdf((b - mean) / std)
26
+ tensor.uniform_(2 * l - 1, 2 * u - 1)
27
+ tensor.erfinv_()
28
+ tensor.mul_(std * math.sqrt(2.))
29
+ tensor.add_(mean)
30
+ tensor.clamp_(min=a, max=b)
31
+ return tensor
32
+
33
+
34
+ def drop_path(x, drop_prob: float = 0., training: bool = False):
35
+ if drop_prob == 0. or not training:
36
+ return x
37
+ keep_prob = 1 - drop_prob
38
+ shape = (x.shape[0], ) + (1, ) * (x.ndim - 1)
39
+ random_tensor = keep_prob + torch.rand(shape, dtype=x.dtype, device=x.device)
40
+ random_tensor.floor_()
41
+ output = x.div(keep_prob) * random_tensor
42
+ return output
43
+
44
+
45
+ class DropPath(nn.Module):
46
+ def __init__(self, drop_prob=None):
47
+ super(DropPath, self).__init__()
48
+ self.drop_prob = drop_prob
49
+
50
+ def forward(self, x):
51
+ return drop_path(x, self.drop_prob, self.training)
52
+
53
+
54
+ class ChannelAttention(nn.Module):
55
+ def __init__(self, num_feat, squeeze_factor=16):
56
+ super(ChannelAttention, self).__init__()
57
+ self.attention = nn.Sequential(
58
+ nn.AdaptiveAvgPool2d(1),
59
+ nn.Conv2d(num_feat, num_feat // squeeze_factor, 1, padding=0),
60
+ nn.ReLU(inplace=True),
61
+ nn.Conv2d(num_feat // squeeze_factor, num_feat, 1, padding=0),
62
+ nn.Sigmoid())
63
+
64
+ def forward(self, x):
65
+ y = self.attention(x)
66
+ return x * y
67
+
68
+
69
+ class CAB(nn.Module):
70
+ def __init__(self, num_feat, compress_ratio=3, squeeze_factor=30):
71
+ super(CAB, self).__init__()
72
+ self.cab = nn.Sequential(
73
+ nn.Conv2d(num_feat, num_feat // compress_ratio, 3, 1, 1),
74
+ nn.GELU(),
75
+ nn.Conv2d(num_feat // compress_ratio, num_feat, 3, 1, 1),
76
+ ChannelAttention(num_feat, squeeze_factor)
77
+ )
78
+
79
+ def forward(self, x):
80
+ return self.cab(x)
81
+
82
+
83
+ class Mlp(nn.Module):
84
+ def __init__(self, in_features, hidden_features=None, out_features=None, act_layer=nn.GELU, drop=0.):
85
+ super().__init__()
86
+ out_features = out_features or in_features
87
+ hidden_features = hidden_features or in_features
88
+ self.fc1 = nn.Linear(in_features, hidden_features)
89
+ self.act = act_layer()
90
+ self.fc2 = nn.Linear(hidden_features, out_features)
91
+ self.drop = nn.Dropout(drop)
92
+
93
+ def forward(self, x):
94
+ x = self.fc1(x)
95
+ x = self.act(x)
96
+ x = self.drop(x)
97
+ x = self.fc2(x)
98
+ x = self.drop(x)
99
+ return x
100
+
101
+
102
+ def window_partition(x, window_size):
103
+ b, h, w, c = x.shape
104
+ x = x.view(b, h // window_size, window_size, w // window_size, window_size, c)
105
+ windows = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(-1, window_size, window_size, c)
106
+ return windows
107
+
108
+
109
+ def window_reverse(windows, window_size, h, w):
110
+ b = int(windows.shape[0] / (h * w / window_size / window_size))
111
+ x = windows.view(b, h // window_size, w // window_size, window_size, window_size, -1)
112
+ x = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(b, h, w, -1)
113
+ return x
114
+
115
+
116
+ class WindowAttention(nn.Module):
117
+ def __init__(self, dim, window_size, num_heads, qkv_bias=True, qk_scale=None, attn_drop=0., proj_drop=0.):
118
+ super().__init__()
119
+ self.dim = dim
120
+ self.window_size = window_size
121
+ self.num_heads = num_heads
122
+ head_dim = dim // num_heads
123
+ self.scale = qk_scale or head_dim**-0.5
124
+
125
+ self.relative_position_bias_table = nn.Parameter(
126
+ torch.zeros((2 * window_size[0] - 1) * (2 * window_size[1] - 1), num_heads))
127
+
128
+ self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
129
+ self.attn_drop = nn.Dropout(attn_drop)
130
+ self.proj = nn.Linear(dim, dim)
131
+ self.proj_drop = nn.Dropout(proj_drop)
132
+
133
+ trunc_normal_(self.relative_position_bias_table, std=.02)
134
+ self.softmax = nn.Softmax(dim=-1)
135
+
136
+ def forward(self, x, rpi, mask=None):
137
+ b_, n, c = x.shape
138
+ qkv = self.qkv(x).reshape(b_, n, 3, self.num_heads, c // self.num_heads).permute(2, 0, 3, 1, 4)
139
+ q, k, v = qkv[0], qkv[1], qkv[2]
140
+
141
+ q = q * self.scale
142
+ attn = (q @ k.transpose(-2, -1))
143
+
144
+ relative_position_bias = self.relative_position_bias_table[rpi.view(-1)].view(
145
+ self.window_size[0] * self.window_size[1], self.window_size[0] * self.window_size[1], -1)
146
+ relative_position_bias = relative_position_bias.permute(2, 0, 1).contiguous()
147
+ attn = attn + relative_position_bias.unsqueeze(0)
148
+
149
+ if mask is not None:
150
+ nw = mask.shape[0]
151
+ attn = attn.view(b_ // nw, nw, self.num_heads, n, n) + mask.unsqueeze(1).unsqueeze(0)
152
+ attn = attn.view(-1, self.num_heads, n, n)
153
+ attn = self.softmax(attn)
154
+ else:
155
+ attn = self.softmax(attn)
156
+
157
+ attn = self.attn_drop(attn)
158
+
159
+ x = (attn @ v).transpose(1, 2).reshape(b_, n, c)
160
+ x = self.proj(x)
161
+ x = self.proj_drop(x)
162
+ return x
163
+
164
+
165
+ class HAB(nn.Module):
166
+ def __init__(self, dim, input_resolution, num_heads, window_size=7, shift_size=0,
167
+ compress_ratio=3, squeeze_factor=30, conv_scale=0.01, mlp_ratio=4.,
168
+ qkv_bias=True, qk_scale=None, drop=0., attn_drop=0., drop_path=0.,
169
+ act_layer=nn.GELU, norm_layer=nn.LayerNorm):
170
+ super().__init__()
171
+ self.dim = dim
172
+ self.input_resolution = input_resolution
173
+ self.num_heads = num_heads
174
+ self.window_size = window_size
175
+ self.shift_size = shift_size
176
+ self.mlp_ratio = mlp_ratio
177
+ if min(self.input_resolution) <= self.window_size:
178
+ self.shift_size = 0
179
+ self.window_size = min(self.input_resolution)
180
+ assert 0 <= self.shift_size < self.window_size, 'shift_size must in 0-window_size'
181
+
182
+ self.norm1 = norm_layer(dim)
183
+ self.attn = WindowAttention(
184
+ dim, window_size=to_2tuple(self.window_size), num_heads=num_heads,
185
+ qkv_bias=qkv_bias, qk_scale=qk_scale, attn_drop=attn_drop, proj_drop=drop)
186
+
187
+ self.conv_scale = conv_scale
188
+ self.conv_block = CAB(num_feat=dim, compress_ratio=compress_ratio, squeeze_factor=squeeze_factor)
189
+
190
+ self.drop_path = DropPath(drop_path) if drop_path > 0. else nn.Identity()
191
+ self.norm2 = norm_layer(dim)
192
+ mlp_hidden_dim = int(dim * mlp_ratio)
193
+ self.mlp = Mlp(in_features=dim, hidden_features=mlp_hidden_dim, act_layer=act_layer, drop=drop)
194
+
195
+ def forward(self, x, x_size, rpi_sa, attn_mask):
196
+ h, w = x_size
197
+ b, _, c = x.shape
198
+
199
+ shortcut = x
200
+ x = self.norm1(x)
201
+ x = x.view(b, h, w, c)
202
+
203
+ # Conv_X
204
+ conv_x = self.conv_block(x.permute(0, 3, 1, 2))
205
+ conv_x = conv_x.permute(0, 2, 3, 1).contiguous().view(b, h * w, c)
206
+
207
+ # cyclic shift
208
+ if self.shift_size > 0:
209
+ shifted_x = torch.roll(x, shifts=(-self.shift_size, -self.shift_size), dims=(1, 2))
210
+ attn_mask = attn_mask
211
+ else:
212
+ shifted_x = x
213
+ attn_mask = None
214
+
215
+ # partition windows
216
+ x_windows = window_partition(shifted_x, self.window_size)
217
+ x_windows = x_windows.view(-1, self.window_size * self.window_size, c)
218
+
219
+ # W-MSA/SW-MSA
220
+ attn_windows = self.attn(x_windows, rpi=rpi_sa, mask=attn_mask)
221
+
222
+ # merge windows
223
+ attn_windows = attn_windows.view(-1, self.window_size, self.window_size, c)
224
+ shifted_x = window_reverse(attn_windows, self.window_size, h, w)
225
+
226
+ # reverse cyclic shift
227
+ if self.shift_size > 0:
228
+ attn_x = torch.roll(shifted_x, shifts=(self.shift_size, self.shift_size), dims=(1, 2))
229
+ else:
230
+ attn_x = shifted_x
231
+ attn_x = attn_x.view(b, h * w, c)
232
+
233
+ # FFN
234
+ x = shortcut + self.drop_path(attn_x) + conv_x * self.conv_scale
235
+ x = x + self.drop_path(self.mlp(self.norm2(x)))
236
+
237
+ return x
238
+
239
+
240
+ class OCAB(nn.Module):
241
+ def __init__(self, dim, input_resolution, window_size, overlap_ratio, num_heads,
242
+ qkv_bias=True, qk_scale=None, mlp_ratio=2, norm_layer=nn.LayerNorm):
243
+ super().__init__()
244
+ self.dim = dim
245
+ self.input_resolution = input_resolution
246
+ self.window_size = window_size
247
+ self.num_heads = num_heads
248
+ head_dim = dim // num_heads
249
+ self.scale = qk_scale or head_dim**-0.5
250
+ self.overlap_win_size = int(window_size * overlap_ratio) + window_size
251
+
252
+ self.norm1 = norm_layer(dim)
253
+ self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
254
+ self.unfold = nn.Unfold(kernel_size=(self.overlap_win_size, self.overlap_win_size),
255
+ stride=window_size, padding=(self.overlap_win_size-window_size)//2)
256
+
257
+ self.relative_position_bias_table = nn.Parameter(
258
+ torch.zeros((window_size + self.overlap_win_size - 1) * (window_size + self.overlap_win_size - 1), num_heads))
259
+
260
+ trunc_normal_(self.relative_position_bias_table, std=.02)
261
+ self.softmax = nn.Softmax(dim=-1)
262
+
263
+ self.proj = nn.Linear(dim,dim)
264
+
265
+ self.norm2 = norm_layer(dim)
266
+ mlp_hidden_dim = int(dim * mlp_ratio)
267
+ self.mlp = Mlp(in_features=dim, hidden_features=mlp_hidden_dim, act_layer=nn.GELU)
268
+
269
+ def forward(self, x, x_size, rpi):
270
+ h, w = x_size
271
+ b, _, c = x.shape
272
+
273
+ shortcut = x
274
+ x = self.norm1(x)
275
+ x = x.view(b, h, w, c)
276
+
277
+ qkv = self.qkv(x).reshape(b, h, w, 3, c).permute(3, 0, 4, 1, 2)
278
+ q = qkv[0].permute(0, 2, 3, 1)
279
+ kv = torch.cat((qkv[1], qkv[2]), dim=1)
280
+
281
+ # partition windows
282
+ q_windows = window_partition(q, self.window_size)
283
+ q_windows = q_windows.view(-1, self.window_size * self.window_size, c)
284
+
285
+ kv_windows = self.unfold(kv)
286
+ kv_windows = rearrange(kv_windows, 'b (nc ch owh oww) nw -> nc (b nw) (owh oww) ch',
287
+ nc=2, ch=c, owh=self.overlap_win_size, oww=self.overlap_win_size).contiguous()
288
+ k_windows, v_windows = kv_windows[0], kv_windows[1]
289
+
290
+ b_, nq, _ = q_windows.shape
291
+ _, n, _ = k_windows.shape
292
+ d = self.dim // self.num_heads
293
+ q = q_windows.reshape(b_, nq, self.num_heads, d).permute(0, 2, 1, 3)
294
+ k = k_windows.reshape(b_, n, self.num_heads, d).permute(0, 2, 1, 3)
295
+ v = v_windows.reshape(b_, n, self.num_heads, d).permute(0, 2, 1, 3)
296
+
297
+ q = q * self.scale
298
+ attn = (q @ k.transpose(-2, -1))
299
+
300
+ relative_position_bias = self.relative_position_bias_table[rpi.view(-1)].view(
301
+ self.window_size * self.window_size, self.overlap_win_size * self.overlap_win_size, -1)
302
+ relative_position_bias = relative_position_bias.permute(2, 0, 1).contiguous()
303
+ attn = attn + relative_position_bias.unsqueeze(0)
304
+
305
+ attn = self.softmax(attn)
306
+ attn_windows = (attn @ v).transpose(1, 2).reshape(b_, nq, self.dim)
307
+
308
+ # merge windows
309
+ attn_windows = attn_windows.view(-1, self.window_size, self.window_size, self.dim)
310
+ x = window_reverse(attn_windows, self.window_size, h, w)
311
+ x = x.view(b, h * w, self.dim)
312
+
313
+ x = self.proj(x) + shortcut
314
+ x = x + self.mlp(self.norm2(x))
315
+ return x
316
+
317
+
318
+ class AttenBlocks(nn.Module):
319
+ def __init__(self, dim, input_resolution, depth, num_heads, window_size, compress_ratio,
320
+ squeeze_factor, conv_scale, overlap_ratio, mlp_ratio=4., qkv_bias=True, qk_scale=None,
321
+ drop=0., attn_drop=0., drop_path=0., norm_layer=nn.LayerNorm, downsample=None,
322
+ use_checkpoint=False):
323
+ super().__init__()
324
+ self.dim = dim
325
+ self.input_resolution = input_resolution
326
+ self.depth = depth
327
+ self.use_checkpoint = use_checkpoint
328
+
329
+ # build blocks
330
+ self.blocks = nn.ModuleList([
331
+ HAB(dim=dim, input_resolution=input_resolution, num_heads=num_heads, window_size=window_size,
332
+ shift_size=0 if (i % 2 == 0) else window_size // 2, compress_ratio=compress_ratio,
333
+ squeeze_factor=squeeze_factor, conv_scale=conv_scale, mlp_ratio=mlp_ratio,
334
+ qkv_bias=qkv_bias, qk_scale=qk_scale, drop=drop, attn_drop=attn_drop,
335
+ drop_path=drop_path[i] if isinstance(drop_path, list) else drop_path,
336
+ norm_layer=norm_layer) for i in range(depth)
337
+ ])
338
+
339
+ # OCAB
340
+ self.overlap_attn = OCAB(dim=dim, input_resolution=input_resolution, window_size=window_size,
341
+ overlap_ratio=overlap_ratio, num_heads=num_heads, qkv_bias=qkv_bias,
342
+ qk_scale=qk_scale, mlp_ratio=mlp_ratio, norm_layer=norm_layer)
343
+
344
+ # patch merging layer
345
+ if downsample is not None:
346
+ self.downsample = downsample(input_resolution, dim=dim, norm_layer=norm_layer)
347
+ else:
348
+ self.downsample = None
349
+
350
+ def forward(self, x, x_size, params):
351
+ for blk in self.blocks:
352
+ x = blk(x, x_size, params['rpi_sa'], params['attn_mask'])
353
+
354
+ x = self.overlap_attn(x, x_size, params['rpi_oca'])
355
+
356
+ if self.downsample is not None:
357
+ x = self.downsample(x)
358
+ return x
359
+
360
+
361
+ class RHAG(nn.Module):
362
+ def __init__(self, dim, input_resolution, depth, num_heads, window_size, compress_ratio,
363
+ squeeze_factor, conv_scale, overlap_ratio, mlp_ratio=4., qkv_bias=True, qk_scale=None,
364
+ drop=0., attn_drop=0., drop_path=0., norm_layer=nn.LayerNorm, downsample=None,
365
+ use_checkpoint=False, img_size=224, patch_size=4, resi_connection='1conv'):
366
+ super(RHAG, self).__init__()
367
+
368
+ self.dim = dim
369
+ self.input_resolution = input_resolution
370
+
371
+ self.residual_group = AttenBlocks(
372
+ dim=dim, input_resolution=input_resolution, depth=depth, num_heads=num_heads,
373
+ window_size=window_size, compress_ratio=compress_ratio, squeeze_factor=squeeze_factor,
374
+ conv_scale=conv_scale, overlap_ratio=overlap_ratio, mlp_ratio=mlp_ratio,
375
+ qkv_bias=qkv_bias, qk_scale=qk_scale, drop=drop, attn_drop=attn_drop,
376
+ drop_path=drop_path, norm_layer=norm_layer, downsample=downsample,
377
+ use_checkpoint=use_checkpoint)
378
+
379
+ if resi_connection == '1conv':
380
+ self.conv = nn.Conv2d(dim, dim, 3, 1, 1)
381
+ elif resi_connection == 'identity':
382
+ self.conv = nn.Identity()
383
+
384
+ self.patch_embed = PatchEmbed(
385
+ img_size=img_size, patch_size=patch_size, in_chans=0, embed_dim=dim, norm_layer=None)
386
+
387
+ self.patch_unembed = PatchUnEmbed(
388
+ img_size=img_size, patch_size=patch_size, in_chans=0, embed_dim=dim, norm_layer=None)
389
+
390
+ def forward(self, x, x_size, params):
391
+ return self.patch_embed(self.conv(self.patch_unembed(self.residual_group(x, x_size, params), x_size))) + x
392
+
393
+
394
+ class PatchEmbed(nn.Module):
395
+ def __init__(self, img_size=224, patch_size=4, in_chans=3, embed_dim=96, norm_layer=None):
396
+ super().__init__()
397
+ img_size = to_2tuple(img_size)
398
+ patch_size = to_2tuple(patch_size)
399
+ patches_resolution = [img_size[0] // patch_size[0], img_size[1] // patch_size[1]]
400
+ self.img_size = img_size
401
+ self.patch_size = patch_size
402
+ self.patches_resolution = patches_resolution
403
+ self.num_patches = patches_resolution[0] * patches_resolution[1]
404
+
405
+ self.in_chans = in_chans
406
+ self.embed_dim = embed_dim
407
+
408
+ if norm_layer is not None:
409
+ self.norm = norm_layer(embed_dim)
410
+ else:
411
+ self.norm = None
412
+
413
+ def forward(self, x):
414
+ x = x.flatten(2).transpose(1, 2)
415
+ if self.norm is not None:
416
+ x = self.norm(x)
417
+ return x
418
+
419
+
420
+ class PatchUnEmbed(nn.Module):
421
+ def __init__(self, img_size=224, patch_size=4, in_chans=3, embed_dim=96, norm_layer=None):
422
+ super().__init__()
423
+ img_size = to_2tuple(img_size)
424
+ patch_size = to_2tuple(patch_size)
425
+ patches_resolution = [img_size[0] // patch_size[0], img_size[1] // patch_size[1]]
426
+ self.img_size = img_size
427
+ self.patch_size = patch_size
428
+ self.patches_resolution = patches_resolution
429
+ self.num_patches = patches_resolution[0] * patches_resolution[1]
430
+
431
+ self.in_chans = in_chans
432
+ self.embed_dim = embed_dim
433
+
434
+ def forward(self, x, x_size):
435
+ x = x.transpose(1, 2).contiguous().view(x.shape[0], self.embed_dim, x_size[0], x_size[1])
436
+ return x
437
+
438
+
439
+ class Upsample(nn.Sequential):
440
+ def __init__(self, scale, num_feat):
441
+ m = []
442
+ if (scale & (scale - 1)) == 0:
443
+ for _ in range(int(math.log(scale, 2))):
444
+ m.append(nn.Conv2d(num_feat, 4 * num_feat, 3, 1, 1))
445
+ m.append(nn.PixelShuffle(2))
446
+ elif scale == 3:
447
+ m.append(nn.Conv2d(num_feat, 9 * num_feat, 3, 1, 1))
448
+ m.append(nn.PixelShuffle(3))
449
+ else:
450
+ raise ValueError(f'scale {scale} is not supported. Supported scales: 2^n and 3.')
451
+ super(Upsample, self).__init__(*m)
models/hatsat/hat_model.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HAT (Hybrid Attention Transformer) main model implementation.
3
+ """
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+ import math
8
+
9
+ from .components import (
10
+ RHAG, PatchEmbed, PatchUnEmbed, Upsample,
11
+ trunc_normal_, window_partition, to_2tuple
12
+ )
13
+
14
+
15
+ class HAT(nn.Module):
16
+ def __init__(self, img_size=64, patch_size=1, in_chans=3, embed_dim=96, depths=(6, 6, 6, 6),
17
+ num_heads=(6, 6, 6, 6), window_size=7, compress_ratio=3, squeeze_factor=30,
18
+ conv_scale=0.01, overlap_ratio=0.5, mlp_ratio=4., qkv_bias=True, qk_scale=None,
19
+ drop_rate=0., attn_drop_rate=0., drop_path_rate=0.1, norm_layer=nn.LayerNorm,
20
+ ape=False, patch_norm=True, use_checkpoint=False, upscale=2, img_range=1.,
21
+ upsampler='', resi_connection='1conv', **kwargs):
22
+ super(HAT, self).__init__()
23
+
24
+ self.window_size = window_size
25
+ self.shift_size = window_size // 2
26
+ self.overlap_ratio = overlap_ratio
27
+
28
+ num_in_ch = in_chans
29
+ num_out_ch = in_chans
30
+ num_feat = 64
31
+ self.img_range = img_range
32
+ if in_chans == 3:
33
+ rgb_mean = (0.4488, 0.4371, 0.4040)
34
+ self.mean = torch.Tensor(rgb_mean).view(1, 3, 1, 1)
35
+ else:
36
+ self.mean = torch.zeros(1, 1, 1, 1)
37
+ self.upscale = upscale
38
+ self.upsampler = upsampler
39
+
40
+ # relative position index
41
+ relative_position_index_SA = self.calculate_rpi_sa()
42
+ relative_position_index_OCA = self.calculate_rpi_oca()
43
+ self.register_buffer('relative_position_index_SA', relative_position_index_SA)
44
+ self.register_buffer('relative_position_index_OCA', relative_position_index_OCA)
45
+
46
+ # shallow feature extraction
47
+ self.conv_first = nn.Conv2d(num_in_ch, embed_dim, 3, 1, 1)
48
+
49
+ # deep feature extraction
50
+ self.num_layers = len(depths)
51
+ self.embed_dim = embed_dim
52
+ self.ape = ape
53
+ self.patch_norm = patch_norm
54
+ self.num_features = embed_dim
55
+ self.mlp_ratio = mlp_ratio
56
+
57
+ # split image into non-overlapping patches
58
+ self.patch_embed = PatchEmbed(
59
+ img_size=img_size, patch_size=patch_size, in_chans=embed_dim, embed_dim=embed_dim,
60
+ norm_layer=norm_layer if self.patch_norm else None)
61
+ num_patches = self.patch_embed.num_patches
62
+ patches_resolution = self.patch_embed.patches_resolution
63
+ self.patches_resolution = patches_resolution
64
+
65
+ # merge non-overlapping patches into image
66
+ self.patch_unembed = PatchUnEmbed(
67
+ img_size=img_size, patch_size=patch_size, in_chans=embed_dim, embed_dim=embed_dim,
68
+ norm_layer=norm_layer if self.patch_norm else None)
69
+
70
+ # absolute position embedding
71
+ if self.ape:
72
+ self.absolute_pos_embed = nn.Parameter(torch.zeros(1, num_patches, embed_dim))
73
+ trunc_normal_(self.absolute_pos_embed, std=.02)
74
+
75
+ self.pos_drop = nn.Dropout(p=drop_rate)
76
+
77
+ # stochastic depth
78
+ dpr = [x.item() for x in torch.linspace(0, drop_path_rate, sum(depths))]
79
+
80
+ # build Residual Hybrid Attention Groups (RHAG)
81
+ self.layers = nn.ModuleList()
82
+ for i_layer in range(self.num_layers):
83
+ layer = RHAG(
84
+ dim=embed_dim,
85
+ input_resolution=(patches_resolution[0], patches_resolution[1]),
86
+ depth=depths[i_layer],
87
+ num_heads=num_heads[i_layer],
88
+ window_size=window_size,
89
+ compress_ratio=compress_ratio,
90
+ squeeze_factor=squeeze_factor,
91
+ conv_scale=conv_scale,
92
+ overlap_ratio=overlap_ratio,
93
+ mlp_ratio=self.mlp_ratio,
94
+ qkv_bias=qkv_bias,
95
+ qk_scale=qk_scale,
96
+ drop=drop_rate,
97
+ attn_drop=attn_drop_rate,
98
+ drop_path=dpr[sum(depths[:i_layer]):sum(depths[:i_layer + 1])],
99
+ norm_layer=norm_layer,
100
+ downsample=None,
101
+ use_checkpoint=use_checkpoint,
102
+ img_size=img_size,
103
+ patch_size=patch_size,
104
+ resi_connection=resi_connection)
105
+ self.layers.append(layer)
106
+ self.norm = norm_layer(self.num_features)
107
+
108
+ # build the last conv layer in deep feature extraction
109
+ if resi_connection == '1conv':
110
+ self.conv_after_body = nn.Conv2d(embed_dim, embed_dim, 3, 1, 1)
111
+ elif resi_connection == 'identity':
112
+ self.conv_after_body = nn.Identity()
113
+
114
+ # high quality image reconstruction
115
+ if self.upsampler == 'pixelshuffle':
116
+ self.conv_before_upsample = nn.Sequential(
117
+ nn.Conv2d(embed_dim, num_feat, 3, 1, 1), nn.LeakyReLU(inplace=True))
118
+ self.upsample = Upsample(upscale, num_feat)
119
+ self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1)
120
+
121
+ self.apply(self._init_weights)
122
+
123
+ def _init_weights(self, m):
124
+ if isinstance(m, nn.Linear):
125
+ trunc_normal_(m.weight, std=.02)
126
+ if isinstance(m, nn.Linear) and m.bias is not None:
127
+ nn.init.constant_(m.bias, 0)
128
+ elif isinstance(m, nn.LayerNorm):
129
+ nn.init.constant_(m.bias, 0)
130
+ nn.init.constant_(m.weight, 1.0)
131
+
132
+ def calculate_rpi_sa(self):
133
+ coords_h = torch.arange(self.window_size)
134
+ coords_w = torch.arange(self.window_size)
135
+ coords = torch.stack(torch.meshgrid([coords_h, coords_w]))
136
+ coords_flatten = torch.flatten(coords, 1)
137
+ relative_coords = coords_flatten[:, :, None] - coords_flatten[:, None, :]
138
+ relative_coords = relative_coords.permute(1, 2, 0).contiguous()
139
+ relative_coords[:, :, 0] += self.window_size - 1
140
+ relative_coords[:, :, 1] += self.window_size - 1
141
+ relative_coords[:, :, 0] *= 2 * self.window_size - 1
142
+ relative_position_index = relative_coords.sum(-1)
143
+ return relative_position_index
144
+
145
+ def calculate_rpi_oca(self):
146
+ window_size_ori = self.window_size
147
+ window_size_ext = self.window_size + int(self.overlap_ratio * self.window_size)
148
+
149
+ coords_h = torch.arange(window_size_ori)
150
+ coords_w = torch.arange(window_size_ori)
151
+ coords_ori = torch.stack(torch.meshgrid([coords_h, coords_w]))
152
+ coords_ori_flatten = torch.flatten(coords_ori, 1)
153
+
154
+ coords_h = torch.arange(window_size_ext)
155
+ coords_w = torch.arange(window_size_ext)
156
+ coords_ext = torch.stack(torch.meshgrid([coords_h, coords_w]))
157
+ coords_ext_flatten = torch.flatten(coords_ext, 1)
158
+
159
+ relative_coords = coords_ext_flatten[:, None, :] - coords_ori_flatten[:, :, None]
160
+ relative_coords = relative_coords.permute(1, 2, 0).contiguous()
161
+ relative_coords[:, :, 0] += window_size_ori - window_size_ext + 1
162
+ relative_coords[:, :, 1] += window_size_ori - window_size_ext + 1
163
+ relative_coords[:, :, 0] *= window_size_ori + window_size_ext - 1
164
+ relative_position_index = relative_coords.sum(-1)
165
+ return relative_position_index
166
+
167
+ def calculate_mask(self, x_size):
168
+ h, w = x_size
169
+ img_mask = torch.zeros((1, h, w, 1))
170
+ h_slices = (slice(0, -self.window_size), slice(-self.window_size, -self.shift_size), slice(-self.shift_size, None))
171
+ w_slices = (slice(0, -self.window_size), slice(-self.window_size, -self.shift_size), slice(-self.shift_size, None))
172
+ cnt = 0
173
+ for h in h_slices:
174
+ for w in w_slices:
175
+ img_mask[:, h, w, :] = cnt
176
+ cnt += 1
177
+
178
+ mask_windows = window_partition(img_mask, self.window_size)
179
+ mask_windows = mask_windows.view(-1, self.window_size * self.window_size)
180
+ attn_mask = mask_windows.unsqueeze(1) - mask_windows.unsqueeze(2)
181
+ attn_mask = attn_mask.masked_fill(attn_mask != 0, float(-100.0)).masked_fill(attn_mask == 0, float(0.0))
182
+ return attn_mask
183
+
184
+ @torch.jit.ignore
185
+ def no_weight_decay(self):
186
+ return {'absolute_pos_embed'}
187
+
188
+ @torch.jit.ignore
189
+ def no_weight_decay_keywords(self):
190
+ return {'relative_position_bias_table'}
191
+
192
+ def forward_features(self, x):
193
+ x_size = (x.shape[2], x.shape[3])
194
+
195
+ attn_mask = self.calculate_mask(x_size).to(x.device)
196
+ params = {'attn_mask': attn_mask, 'rpi_sa': self.relative_position_index_SA, 'rpi_oca': self.relative_position_index_OCA}
197
+
198
+ x = self.patch_embed(x)
199
+ if self.ape:
200
+ x = x + self.absolute_pos_embed
201
+ x = self.pos_drop(x)
202
+
203
+ for layer in self.layers:
204
+ x = layer(x, x_size, params)
205
+
206
+ x = self.norm(x)
207
+ x = self.patch_unembed(x, x_size)
208
+ return x
209
+
210
+ def forward(self, x):
211
+ self.mean = self.mean.type_as(x)
212
+ x = (x - self.mean) * self.img_range
213
+
214
+ if self.upsampler == 'pixelshuffle':
215
+ x = self.conv_first(x)
216
+ x = self.conv_after_body(self.forward_features(x)) + x
217
+ x = self.conv_before_upsample(x)
218
+ x = self.conv_last(self.upsample(x))
219
+
220
+ x = x / self.img_range + self.mean
221
+ return x
models/hatsat/hatsat_inference.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import torch
7
+ from PIL import Image
8
+
9
+ # ------------------------------------------------------
10
+ # Paths
11
+ # ------------------------------------------------------
12
+
13
+ HERE = Path(__file__).resolve().parent
14
+
15
+ WEIGHTS = (
16
+ HERE.parent.parent
17
+ / "weights"
18
+ / "hatsat"
19
+ / "net_g_150000.pth"
20
+ )
21
+
22
+ # ------------------------------------------------------
23
+ # Import HAT
24
+ # ------------------------------------------------------
25
+
26
+ from .hat_model import HAT
27
+
28
+
29
+ class HATSATInference:
30
+
31
+ def __init__(self, checkpoint_path=None, device=None):
32
+
33
+ self.device = device or (
34
+ "cuda" if torch.cuda.is_available() else "cpu"
35
+ )
36
+
37
+ self.device = torch.device(self.device)
38
+
39
+ if checkpoint_path is None:
40
+ checkpoint_path = WEIGHTS
41
+
42
+ checkpoint_path = Path(checkpoint_path)
43
+
44
+ if not checkpoint_path.exists():
45
+ raise FileNotFoundError(
46
+ f"HATSAT checkpoint not found: {checkpoint_path}"
47
+ )
48
+
49
+ # --------------------------------------------------
50
+ # HATSAT configuration
51
+ # --------------------------------------------------
52
+
53
+ self.model = HAT(
54
+ upscale=4,
55
+ in_chans=3,
56
+ img_size=128,
57
+ window_size=16,
58
+ compress_ratio=3,
59
+ squeeze_factor=30,
60
+ conv_scale=0.01,
61
+ overlap_ratio=0.5,
62
+ img_range=1.0,
63
+ depths=[6, 6, 6, 6, 6, 6],
64
+ embed_dim=180,
65
+ num_heads=[6, 6, 6, 6, 6, 6],
66
+ mlp_ratio=2,
67
+ upsampler="pixelshuffle",
68
+ resi_connection="1conv"
69
+ )
70
+
71
+ checkpoint = torch.load(
72
+ checkpoint_path,
73
+ map_location="cpu"
74
+ )
75
+
76
+ if "params_ema" in checkpoint:
77
+ state_dict = checkpoint["params_ema"]
78
+ elif "params" in checkpoint:
79
+ state_dict = checkpoint["params"]
80
+ else:
81
+ state_dict = checkpoint
82
+
83
+ self.model.load_state_dict(
84
+ state_dict,
85
+ strict=True
86
+ )
87
+
88
+ self.model.eval()
89
+ self.model.to(self.device)
90
+
91
+ print("HATSAT checkpoint loaded successfully.")
92
+
93
+ # ------------------------------------------------------
94
+ # Inference
95
+ # ------------------------------------------------------
96
+
97
+ @torch.no_grad()
98
+ def predict(self, image):
99
+
100
+ if not isinstance(image, Image.Image):
101
+ image = Image.fromarray(image)
102
+
103
+ image = image.convert("RGB")
104
+
105
+ original_w, original_h = image.size
106
+
107
+ img = np.array(image).astype(
108
+ np.float32
109
+ ) / 255.0
110
+
111
+ device = next(self.model.parameters()).device
112
+ tensor = torch.from_numpy(
113
+ img
114
+ ).permute(
115
+ 2, 0, 1
116
+ ).unsqueeze(0).to(device)
117
+
118
+ # --------------------------------------------------
119
+ # HAT requires dimensions compatible with window=16
120
+ # --------------------------------------------------
121
+
122
+ _, _, h, w = tensor.shape
123
+
124
+ window = 16
125
+
126
+ pad_h = (
127
+ window - h % window
128
+ ) % window
129
+
130
+ pad_w = (
131
+ window - w % window
132
+ ) % window
133
+
134
+ if pad_h > 0 or pad_w > 0:
135
+
136
+ tensor = torch.nn.functional.pad(
137
+ tensor,
138
+ (0, pad_w, 0, pad_h),
139
+ mode="reflect"
140
+ )
141
+
142
+ # --------------------------------------------------
143
+ # Model
144
+ # --------------------------------------------------
145
+
146
+ output = self.model(tensor)
147
+
148
+ # --------------------------------------------------
149
+ # Crop padding from output
150
+ # --------------------------------------------------
151
+
152
+ output_h = original_h * 4
153
+ output_w = original_w * 4
154
+
155
+ output = output[
156
+ :,
157
+ :,
158
+ :output_h,
159
+ :output_w
160
+ ]
161
+
162
+ output = (
163
+ output
164
+ .clamp(0, 1)
165
+ .squeeze(0)
166
+ .permute(1, 2, 0)
167
+ .cpu()
168
+ .numpy()
169
+ )
170
+
171
+ output = (
172
+ output * 255.0
173
+ ).round().astype(np.uint8)
174
+
175
+ return Image.fromarray(
176
+ output,
177
+ mode="RGB"
178
+ )
179
+
180
+
181
+ # ------------------------------------------------------
182
+ # Convenience function
183
+ # ------------------------------------------------------
184
+
185
+ def run_hatsat(
186
+ image,
187
+ checkpoint_path=None,
188
+ device=None
189
+ ):
190
+
191
+ model = HATSATInference(
192
+ checkpoint_path=checkpoint_path,
193
+ device=device
194
+ )
195
+
196
+ return model.predict(image)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ torch>=2.0.0, !=2.0.1
2
+ numpy<2.0.0
3
+ Pillow>=10.0.0
4
+ gradio>=4.0.0
5
+ einops==0.8.1
6
+ rasterio==1.4.3
7
+ spaces
weights/RRDB_ESRGAN_x4.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:65fece06e1ccb48853242aa972bdf00ad07a7dd8938d2dcbdf4221b59f6372ce
3
+ size 66929193
weights/esrgan/RRDB_ESRGAN_x4.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:65fece06e1ccb48853242aa972bdf00ad07a7dd8938d2dcbdf4221b59f6372ce
3
+ size 66929193
weights/hatsat/net_g_150000.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:54b2eda431411bfecc50c542ec863267ed683128b7aa4163c109776c627bb721
3
+ size 170285859
weights/net_g_150000.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:54b2eda431411bfecc50c542ec863267ed683128b7aa4163c109776c627bb721
3
+ size 170285859