'update'
This commit is contained in:
commit
4f1707a925
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dir:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:apihub.kma.go.kr)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(\"C:\\\\Anaconda\\\\envs\\\\pyt310\\\\python.exe\" \"C:\\\\Users\\\\소지안\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-----Desktop-soja-goheung\\\\287537f0-26ac-4b65-80c0-3975c27c50b4\\\\scratchpad\\\\test_train.py\")",
|
||||||
|
"Bash(\"C:\\\\Anaconda\\\\envs\\\\pyt310\\\\python.exe\" -c \"import rasterio; src=rasterio.open\\(r''c:\\\\Users\\\\소지안\\\\Desktop\\\\soja\\\\goheung\\\\app\\\\static\\\\water_segmentation\\\\image\\\\tile_0000.tif''\\); print\\(''Size:'', src.width, ''x'', src.height\\); print\\(''Bands:'', src.count\\); print\\(''Dtype:'', src.dtypes\\); src.close\\(\\)\")",
|
||||||
|
"Bash(\"C:\\\\Anaconda\\\\envs\\\\pyt310\\\\python.exe\" -c \"import rasterio; src=rasterio.open\\(r''c:\\\\Users\\\\소지안\\\\Desktop\\\\soja\\\\goheung\\\\app\\\\static\\\\water_segmentation\\\\image\\\\0_aug.tif''\\); print\\(''Size:'', src.width, ''x'', src.height\\); print\\(''Bands:'', src.count\\); print\\(''Dtype:'', src.dtypes\\); src.close\\(\\)\")",
|
||||||
|
"Bash(git ls-tree:*)",
|
||||||
|
"Bash(sort:*)",
|
||||||
|
"Read(//c/Users/소지안/Desktop/soja/goheung/**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
64
.gitignore
vendored
Normal file
64
.gitignore
vendored
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.so
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# 데이터 파일
|
||||||
|
*.tif
|
||||||
|
*.csv
|
||||||
|
*.xlsx
|
||||||
|
*.xls
|
||||||
|
*.h5
|
||||||
|
*.xml
|
||||||
|
|
||||||
|
# GIS / Shapefile
|
||||||
|
*.shp
|
||||||
|
*.shx
|
||||||
|
*.dbf
|
||||||
|
*.cpg
|
||||||
|
*.qmd
|
||||||
|
*.prj
|
||||||
|
|
||||||
|
# 이미지
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.png
|
||||||
|
|
||||||
|
# 모델 파일
|
||||||
|
*.h5
|
||||||
|
*.keras
|
||||||
|
*.pb
|
||||||
|
*.onnx
|
||||||
|
*.pth
|
||||||
|
*.pt
|
||||||
|
*.pkl
|
||||||
|
models/
|
||||||
|
|
||||||
|
# 학습 데이터셋 (TIF)
|
||||||
|
app/static/water_segmentation/image/
|
||||||
|
app/static/water_segmentation/mask/
|
||||||
|
|
||||||
|
# 테스트 결과
|
||||||
|
app/AI_modules/water_body_segmentation/water_body_segmentation_result/
|
||||||
|
|
||||||
|
# 임시 파일
|
||||||
|
app/static/drought/tmp/
|
||||||
|
|
||||||
|
# KMA 예보 캐시
|
||||||
|
app/data/
|
||||||
|
|
||||||
|
# 환경 변수
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
nul
|
||||||
|
|
||||||
|
app/time_series/
|
||||||
41
app/AI_modules/gpu_utils/__init__.py
Normal file
41
app/AI_modules/gpu_utils/__init__.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# GPU Utilities Package
|
||||||
|
"""
|
||||||
|
외부 GPU 연결 및 관리 유틸리티
|
||||||
|
|
||||||
|
사용 예시:
|
||||||
|
# GPU 상태 확인
|
||||||
|
from app.AI_modules.gpu_utils import check_gpu_status
|
||||||
|
check_gpu_status()
|
||||||
|
|
||||||
|
# 원격 GPU 서버 연결
|
||||||
|
from app.AI_modules.gpu_utils import RemoteGPUClient
|
||||||
|
client = RemoteGPUClient(host='192.168.1.100', port=22)
|
||||||
|
client.connect()
|
||||||
|
|
||||||
|
# TensorFlow GPU 설정
|
||||||
|
from app.AI_modules.gpu_utils import setup_tensorflow_gpu
|
||||||
|
setup_tensorflow_gpu(memory_limit=4096)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .gpu_config import (
|
||||||
|
check_gpu_status,
|
||||||
|
get_gpu_info,
|
||||||
|
setup_tensorflow_gpu,
|
||||||
|
setup_pytorch_gpu,
|
||||||
|
limit_gpu_memory,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .remote_gpu import (
|
||||||
|
RemoteGPUClient,
|
||||||
|
SSHGPURunner,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'check_gpu_status',
|
||||||
|
'get_gpu_info',
|
||||||
|
'setup_tensorflow_gpu',
|
||||||
|
'setup_pytorch_gpu',
|
||||||
|
'limit_gpu_memory',
|
||||||
|
'RemoteGPUClient',
|
||||||
|
'SSHGPURunner',
|
||||||
|
]
|
||||||
205
app/AI_modules/gpu_utils/gpu_config.py
Normal file
205
app/AI_modules/gpu_utils/gpu_config.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
"""
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
File: gpu_config.py
|
||||||
|
Description: 로컬 GPU 감지 및 설정 유틸리티
|
||||||
|
Author: 소지안 프로
|
||||||
|
Created: 2026-02-02
|
||||||
|
Last Modified: 2026-02-02
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def check_gpu_status():
|
||||||
|
"""GPU 상태 확인 및 출력"""
|
||||||
|
print("=" * 50)
|
||||||
|
print("GPU Status Check")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# NVIDIA GPU 확인 (nvidia-smi)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['nvidia-smi'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("\n[NVIDIA GPU Detected]")
|
||||||
|
print(result.stdout)
|
||||||
|
else:
|
||||||
|
print("\n[NVIDIA GPU] Not found or driver not installed")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("\n[NVIDIA GPU] nvidia-smi not found")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[NVIDIA GPU] Error: {e}")
|
||||||
|
|
||||||
|
# TensorFlow GPU 확인
|
||||||
|
try:
|
||||||
|
import tensorflow as tf
|
||||||
|
gpus = tf.config.list_physical_devices('GPU')
|
||||||
|
print(f"\n[TensorFlow] GPU devices: {len(gpus)}")
|
||||||
|
for gpu in gpus:
|
||||||
|
print(f" - {gpu}")
|
||||||
|
except ImportError:
|
||||||
|
print("\n[TensorFlow] Not installed")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[TensorFlow] Error: {e}")
|
||||||
|
|
||||||
|
# PyTorch GPU 확인
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
print(f"\n[PyTorch] CUDA available: {torch.cuda.is_available()}")
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
print(f" - Device count: {torch.cuda.device_count()}")
|
||||||
|
print(f" - Current device: {torch.cuda.current_device()}")
|
||||||
|
print(f" - Device name: {torch.cuda.get_device_name(0)}")
|
||||||
|
except ImportError:
|
||||||
|
print("\n[PyTorch] Not installed")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[PyTorch] Error: {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
def get_gpu_info():
|
||||||
|
"""GPU 정보 딕셔너리로 반환"""
|
||||||
|
info = {
|
||||||
|
'nvidia_available': False,
|
||||||
|
'tensorflow_gpus': [],
|
||||||
|
'pytorch_cuda': False,
|
||||||
|
'gpu_name': None,
|
||||||
|
'gpu_memory': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# NVIDIA 정보
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
info['nvidia_available'] = True
|
||||||
|
parts = result.stdout.strip().split(',')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
info['gpu_name'] = parts[0].strip()
|
||||||
|
info['gpu_memory'] = parts[1].strip()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# TensorFlow
|
||||||
|
try:
|
||||||
|
import tensorflow as tf
|
||||||
|
gpus = tf.config.list_physical_devices('GPU')
|
||||||
|
info['tensorflow_gpus'] = [str(gpu) for gpu in gpus]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# PyTorch
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
info['pytorch_cuda'] = torch.cuda.is_available()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def setup_tensorflow_gpu(memory_limit=None, allow_growth=True):
|
||||||
|
"""
|
||||||
|
TensorFlow GPU 설정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
memory_limit: GPU 메모리 제한 (MB). None이면 제한 없음
|
||||||
|
allow_growth: 메모리 동적 할당 허용
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
설정된 GPU 리스트
|
||||||
|
"""
|
||||||
|
import tensorflow as tf
|
||||||
|
|
||||||
|
gpus = tf.config.list_physical_devices('GPU')
|
||||||
|
|
||||||
|
if not gpus:
|
||||||
|
print("[Warning] No GPU found for TensorFlow")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for gpu in gpus:
|
||||||
|
if memory_limit:
|
||||||
|
# 메모리 제한 설정
|
||||||
|
tf.config.set_logical_device_configuration(
|
||||||
|
gpu,
|
||||||
|
[tf.config.LogicalDeviceConfiguration(memory_limit=memory_limit)]
|
||||||
|
)
|
||||||
|
print(f"[TensorFlow] GPU memory limited to {memory_limit}MB")
|
||||||
|
elif allow_growth:
|
||||||
|
# 동적 메모리 할당
|
||||||
|
tf.config.experimental.set_memory_growth(gpu, True)
|
||||||
|
print("[TensorFlow] GPU memory growth enabled")
|
||||||
|
|
||||||
|
print(f"[TensorFlow] Configured {len(gpus)} GPU(s)")
|
||||||
|
return gpus
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"[TensorFlow] GPU configuration error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def setup_pytorch_gpu(device_id=0):
|
||||||
|
"""
|
||||||
|
PyTorch GPU 설정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: 사용할 GPU 디바이스 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
torch.device 객체
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
device = torch.device(f'cuda:{device_id}')
|
||||||
|
torch.cuda.set_device(device_id)
|
||||||
|
print(f"[PyTorch] Using GPU: {torch.cuda.get_device_name(device_id)}")
|
||||||
|
else:
|
||||||
|
device = torch.device('cpu')
|
||||||
|
print("[PyTorch] CUDA not available, using CPU")
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def limit_gpu_memory(fraction=0.5):
|
||||||
|
"""
|
||||||
|
GPU 메모리 사용량 제한 (TensorFlow)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fraction: 전체 메모리 중 사용할 비율 (0.0 ~ 1.0)
|
||||||
|
"""
|
||||||
|
import tensorflow as tf
|
||||||
|
|
||||||
|
gpus = tf.config.list_physical_devices('GPU')
|
||||||
|
if gpus:
|
||||||
|
try:
|
||||||
|
for gpu in gpus:
|
||||||
|
tf.config.experimental.set_memory_growth(gpu, True)
|
||||||
|
|
||||||
|
# 환경변수로 메모리 제한
|
||||||
|
os.environ['TF_FORCE_GPU_ALLOW_GROWTH'] = 'true'
|
||||||
|
print(f"[TensorFlow] GPU memory fraction set to {fraction * 100}%")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Error] {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def set_visible_gpus(gpu_ids):
|
||||||
|
"""
|
||||||
|
사용할 GPU 지정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gpu_ids: GPU ID 리스트 (예: [0, 1])
|
||||||
|
"""
|
||||||
|
os.environ['CUDA_VISIBLE_DEVICES'] = ','.join(map(str, gpu_ids))
|
||||||
|
print(f"[GPU] Visible devices set to: {gpu_ids}")
|
||||||
143
app/AI_modules/gpu_utils/gpu_config_example.py
Normal file
143
app/AI_modules/gpu_utils/gpu_config_example.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
File: gpu_config.py
|
||||||
|
Description:
|
||||||
|
외부 GPU 연결 예제
|
||||||
|
사용법 : python -m app.AI_modules.gpu_utils.example
|
||||||
|
Author: 소지안 프로
|
||||||
|
Created: 2026-02-02
|
||||||
|
Last Modified: 2026-02-02
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
|
||||||
|
def example_local_gpu():
|
||||||
|
"""로컬 GPU 사용 예제"""
|
||||||
|
from .gpu_config import check_gpu_status, setup_tensorflow_gpu
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Example 1: Local GPU Setup")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# GPU 상태 확인
|
||||||
|
check_gpu_status()
|
||||||
|
|
||||||
|
# TensorFlow GPU 설정
|
||||||
|
gpus = setup_tensorflow_gpu(memory_limit=4096) # 4GB 제한
|
||||||
|
|
||||||
|
if gpus:
|
||||||
|
print("\n[Ready] GPU is ready for training")
|
||||||
|
else:
|
||||||
|
print("\n[Warning] No GPU available, will use CPU")
|
||||||
|
|
||||||
|
|
||||||
|
def example_remote_gpu():
|
||||||
|
"""원격 GPU 서버 연결 예제"""
|
||||||
|
from .remote_gpu import RemoteGPUClient
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Example 2: Remote GPU Connection")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# 원격 서버 설정 (실제 값으로 변경 필요)
|
||||||
|
client = RemoteGPUClient(
|
||||||
|
host='YOUR_GPU_SERVER_IP', # 예: '192.168.1.100'
|
||||||
|
username='YOUR_USERNAME', # 예: 'ubuntu'
|
||||||
|
port=22,
|
||||||
|
key_path='~/.ssh/id_rsa' # SSH 키 경로
|
||||||
|
)
|
||||||
|
|
||||||
|
# 연결 테스트
|
||||||
|
if client.connect():
|
||||||
|
# GPU 상태 확인
|
||||||
|
client.check_gpu()
|
||||||
|
|
||||||
|
# 원격 명령어 실행
|
||||||
|
client.run_command('python --version')
|
||||||
|
else:
|
||||||
|
print("[Failed] Could not connect to remote server")
|
||||||
|
|
||||||
|
|
||||||
|
def example_remote_training():
|
||||||
|
"""원격 GPU 학습 예제"""
|
||||||
|
from .remote_gpu import SSHGPURunner
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Example 3: Remote Training")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# 설정 (실제 값으로 변경 필요)
|
||||||
|
runner = SSHGPURunner(
|
||||||
|
host='YOUR_GPU_SERVER_IP',
|
||||||
|
username='YOUR_USERNAME',
|
||||||
|
remote_project_dir='/home/user/projects/goheung',
|
||||||
|
key_path='~/.ssh/id_rsa'
|
||||||
|
)
|
||||||
|
|
||||||
|
if runner.connect():
|
||||||
|
# 1. 프로젝트 동기화
|
||||||
|
# runner.sync_project('./app')
|
||||||
|
|
||||||
|
# 2. 학습 실행
|
||||||
|
# runner.run('python -m app.AI_modules.DeepLabV3.train --epochs 50')
|
||||||
|
|
||||||
|
# 3. 백그라운드 학습 (연결 끊어도 계속 실행)
|
||||||
|
# runner.run_background('python -m app.AI_modules.DeepLabV3.train --epochs 50')
|
||||||
|
|
||||||
|
# 4. 로그 확인
|
||||||
|
# runner.check_training_log()
|
||||||
|
|
||||||
|
# 5. 결과 다운로드
|
||||||
|
# runner.download_model('DeepLabV3-Plus.h5', './models/')
|
||||||
|
|
||||||
|
print("[Ready] Remote training setup complete")
|
||||||
|
else:
|
||||||
|
print("[Failed] Could not connect to remote server")
|
||||||
|
|
||||||
|
|
||||||
|
def example_colab_config():
|
||||||
|
"""Google Colab GPU 설정 예제 (Colab에서 실행)"""
|
||||||
|
config_code = '''
|
||||||
|
# Google Colab에서 실행할 코드
|
||||||
|
|
||||||
|
# 1. GPU 런타임 확인
|
||||||
|
!nvidia-smi
|
||||||
|
|
||||||
|
# 2. Google Drive 마운트
|
||||||
|
from google.colab import drive
|
||||||
|
drive.mount('/content/drive')
|
||||||
|
|
||||||
|
# 3. 프로젝트 클론 또는 업로드
|
||||||
|
# !git clone https://github.com/your/repo.git
|
||||||
|
# 또는 드라이브에서 복사
|
||||||
|
# !cp -r /content/drive/MyDrive/goheung /content/
|
||||||
|
|
||||||
|
# 4. 의존성 설치
|
||||||
|
# !pip install tensorflow keras numpy matplotlib
|
||||||
|
|
||||||
|
# 5. 학습 실행
|
||||||
|
# %cd /content/goheung
|
||||||
|
# !python -m app.AI_modules.DeepLabV3.train --image_path /content/data/Images/
|
||||||
|
|
||||||
|
# 6. 결과 저장
|
||||||
|
# !cp DeepLabV3-Plus.h5 /content/drive/MyDrive/models/
|
||||||
|
'''
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Example 4: Google Colab Configuration")
|
||||||
|
print("=" * 50)
|
||||||
|
print(config_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("GPU Connection Examples")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# 로컬 GPU 확인
|
||||||
|
try:
|
||||||
|
example_local_gpu()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Error] {e}")
|
||||||
|
|
||||||
|
# 다른 예제는 설정 후 주석 해제하여 사용
|
||||||
|
# example_remote_gpu()
|
||||||
|
# example_remote_training()
|
||||||
|
# example_colab_config()
|
||||||
230
app/AI_modules/gpu_utils/remote_gpu.py
Normal file
230
app/AI_modules/gpu_utils/remote_gpu.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
# Remote GPU Connection
|
||||||
|
"""
|
||||||
|
원격 GPU 서버 연결 유틸리티
|
||||||
|
|
||||||
|
지원:
|
||||||
|
- SSH를 통한 원격 GPU 서버 연결
|
||||||
|
- 파일 전송 (SCP)
|
||||||
|
- 원격 학습 실행
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteGPUClient:
|
||||||
|
"""
|
||||||
|
원격 GPU 서버 클라이언트
|
||||||
|
|
||||||
|
사용 예시:
|
||||||
|
client = RemoteGPUClient(
|
||||||
|
host='192.168.1.100',
|
||||||
|
username='user',
|
||||||
|
key_path='~/.ssh/id_rsa'
|
||||||
|
)
|
||||||
|
client.connect()
|
||||||
|
client.check_gpu()
|
||||||
|
client.upload_file('local_data.zip', '/remote/path/')
|
||||||
|
client.run_training('python train.py')
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, host, username='root', port=22, key_path=None, password=None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
host: 원격 서버 IP 또는 호스트명
|
||||||
|
username: SSH 사용자명
|
||||||
|
port: SSH 포트
|
||||||
|
key_path: SSH 키 파일 경로
|
||||||
|
password: 비밀번호 (키 없을 경우)
|
||||||
|
"""
|
||||||
|
self.host = host
|
||||||
|
self.username = username
|
||||||
|
self.port = port
|
||||||
|
self.key_path = key_path
|
||||||
|
self.password = password
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
def _get_ssh_cmd(self):
|
||||||
|
"""SSH 명령어 기본 구성"""
|
||||||
|
cmd = ['ssh', '-p', str(self.port)]
|
||||||
|
if self.key_path:
|
||||||
|
cmd.extend(['-i', os.path.expanduser(self.key_path)])
|
||||||
|
cmd.append(f'{self.username}@{self.host}')
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""연결 테스트"""
|
||||||
|
try:
|
||||||
|
cmd = self._get_ssh_cmd() + ['echo', 'Connected']
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
self.connected = True
|
||||||
|
print(f"[Connected] {self.username}@{self.host}:{self.port}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[Connection Failed] {result.stderr}")
|
||||||
|
return False
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("[Connection Failed] Timeout")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Connection Failed] {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_gpu(self):
|
||||||
|
"""원격 서버 GPU 상태 확인"""
|
||||||
|
cmd = self._get_ssh_cmd() + ['nvidia-smi']
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("[Remote GPU Status]")
|
||||||
|
print(result.stdout)
|
||||||
|
return result.stdout
|
||||||
|
else:
|
||||||
|
print(f"[GPU Check Failed] {result.stderr}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run_command(self, command):
|
||||||
|
"""원격 명령어 실행"""
|
||||||
|
cmd = self._get_ssh_cmd() + [command]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
print(f"[Remote Command] {command}")
|
||||||
|
if result.stdout:
|
||||||
|
print(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
print(f"[Error] {result.stderr}")
|
||||||
|
|
||||||
|
return result.returncode == 0, result.stdout, result.stderr
|
||||||
|
|
||||||
|
def upload_file(self, local_path, remote_path):
|
||||||
|
"""파일 업로드 (SCP)"""
|
||||||
|
cmd = ['scp', '-P', str(self.port)]
|
||||||
|
if self.key_path:
|
||||||
|
cmd.extend(['-i', os.path.expanduser(self.key_path)])
|
||||||
|
cmd.extend([local_path, f'{self.username}@{self.host}:{remote_path}'])
|
||||||
|
|
||||||
|
print(f"[Uploading] {local_path} -> {remote_path}")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("[Upload Complete]")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[Upload Failed] {result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def download_file(self, remote_path, local_path):
|
||||||
|
"""파일 다운로드 (SCP)"""
|
||||||
|
cmd = ['scp', '-P', str(self.port)]
|
||||||
|
if self.key_path:
|
||||||
|
cmd.extend(['-i', os.path.expanduser(self.key_path)])
|
||||||
|
cmd.extend([f'{self.username}@{self.host}:{remote_path}', local_path])
|
||||||
|
|
||||||
|
print(f"[Downloading] {remote_path} -> {local_path}")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("[Download Complete]")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[Download Failed] {result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def run_training(self, command, working_dir=None, background=False):
|
||||||
|
"""
|
||||||
|
원격 학습 실행
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: 실행할 명령어 (예: 'python train.py')
|
||||||
|
working_dir: 작업 디렉토리
|
||||||
|
background: 백그라운드 실행 여부
|
||||||
|
"""
|
||||||
|
if working_dir:
|
||||||
|
command = f'cd {working_dir} && {command}'
|
||||||
|
|
||||||
|
if background:
|
||||||
|
command = f'nohup {command} > training.log 2>&1 &'
|
||||||
|
|
||||||
|
return self.run_command(command)
|
||||||
|
|
||||||
|
|
||||||
|
class SSHGPURunner:
|
||||||
|
"""
|
||||||
|
SSH 터널을 통한 GPU 학습 실행기
|
||||||
|
|
||||||
|
사용 예시:
|
||||||
|
runner = SSHGPURunner(
|
||||||
|
host='gpu-server.example.com',
|
||||||
|
username='user',
|
||||||
|
remote_project_dir='/home/user/projects/goheung'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 프로젝트 동기화 및 학습 실행
|
||||||
|
runner.sync_project('./app')
|
||||||
|
runner.run('python -m app.AI_modules.DeepLabV3.train')
|
||||||
|
runner.download_results('./models/')
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, host, username, remote_project_dir, port=22, key_path=None):
|
||||||
|
self.client = RemoteGPUClient(host, username, port, key_path)
|
||||||
|
self.remote_project_dir = remote_project_dir
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""연결"""
|
||||||
|
return self.client.connect()
|
||||||
|
|
||||||
|
def sync_project(self, local_dir):
|
||||||
|
"""
|
||||||
|
프로젝트 폴더 동기화 (rsync)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
local_dir: 로컬 프로젝트 디렉토리
|
||||||
|
"""
|
||||||
|
cmd = [
|
||||||
|
'rsync', '-avz', '--progress',
|
||||||
|
'-e', f'ssh -p {self.client.port}',
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.client.key_path:
|
||||||
|
cmd[4] = f'ssh -p {self.client.port} -i {self.client.key_path}'
|
||||||
|
|
||||||
|
cmd.extend([
|
||||||
|
f'{local_dir}/',
|
||||||
|
f'{self.client.username}@{self.client.host}:{self.remote_project_dir}/'
|
||||||
|
])
|
||||||
|
|
||||||
|
print(f"[Syncing] {local_dir} -> {self.remote_project_dir}")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("[Sync Complete]")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"[Sync Failed] {result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def run(self, command):
|
||||||
|
"""원격 학습 실행"""
|
||||||
|
return self.client.run_training(command, working_dir=self.remote_project_dir)
|
||||||
|
|
||||||
|
def run_background(self, command, log_file='training.log'):
|
||||||
|
"""백그라운드로 학습 실행"""
|
||||||
|
full_command = f'nohup {command} > {log_file} 2>&1 &'
|
||||||
|
return self.client.run_training(full_command, working_dir=self.remote_project_dir)
|
||||||
|
|
||||||
|
def check_training_log(self, log_file='training.log', lines=50):
|
||||||
|
"""학습 로그 확인"""
|
||||||
|
command = f'tail -n {lines} {self.remote_project_dir}/{log_file}'
|
||||||
|
return self.client.run_command(command)
|
||||||
|
|
||||||
|
def download_results(self, local_dir, remote_pattern='*.h5'):
|
||||||
|
"""학습 결과 다운로드"""
|
||||||
|
remote_path = f'{self.remote_project_dir}/{remote_pattern}'
|
||||||
|
return self.client.download_file(remote_path, local_dir)
|
||||||
|
|
||||||
|
def download_model(self, model_name, local_path):
|
||||||
|
"""특정 모델 파일 다운로드"""
|
||||||
|
remote_path = f'{self.remote_project_dir}/models/{model_name}'
|
||||||
|
return self.client.download_file(remote_path, local_path)
|
||||||
92
app/AI_modules/water_body_segmentation/__init__.py
Normal file
92
app/AI_modules/water_body_segmentation/__init__.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
# DeepLabV3+ Package
|
||||||
|
"""
|
||||||
|
DeepLabV3+ Semantic Segmentation Model for Water Body Detection
|
||||||
|
|
||||||
|
사용 예시:
|
||||||
|
# 모델 생성
|
||||||
|
from app.AI_modules.DeepLabV3 import create_model
|
||||||
|
model = create_model(image_size=128)
|
||||||
|
|
||||||
|
# 데이터 로드
|
||||||
|
from app.AI_modules.DeepLabV3 import load_data
|
||||||
|
train_ds = load_data('/path/to/images/', trim=[100, 600])
|
||||||
|
|
||||||
|
# 학습
|
||||||
|
from app.AI_modules.DeepLabV3 import train
|
||||||
|
model, history = train(image_path='/path/to/images/')
|
||||||
|
|
||||||
|
# 저장된 모델 로드
|
||||||
|
from app.AI_modules.DeepLabV3 import load_model
|
||||||
|
model = load_model('DeepLabV3-Plus.h5')
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Config
|
||||||
|
from .config import (
|
||||||
|
IMAGE_SIZE,
|
||||||
|
BATCH_SIZE,
|
||||||
|
LEARNING_RATE,
|
||||||
|
EPOCHS,
|
||||||
|
IMAGE_PATH,
|
||||||
|
MODEL_SAVE_PATH,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Data Loading
|
||||||
|
from .data_loader_original import (
|
||||||
|
load_image,
|
||||||
|
load_images,
|
||||||
|
load_data,
|
||||||
|
create_dataset_from_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Layers
|
||||||
|
from .layers import (
|
||||||
|
ConvBlock,
|
||||||
|
AtrousSpatialPyramidPooling,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Model
|
||||||
|
from .model import (
|
||||||
|
create_model,
|
||||||
|
load_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Utils
|
||||||
|
from .utils import (
|
||||||
|
show_images,
|
||||||
|
show_single_prediction,
|
||||||
|
prediction_to_base64,
|
||||||
|
mask_to_binary,
|
||||||
|
calculate_iou,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Training
|
||||||
|
from .train import train
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Config
|
||||||
|
'IMAGE_SIZE',
|
||||||
|
'BATCH_SIZE',
|
||||||
|
'LEARNING_RATE',
|
||||||
|
'EPOCHS',
|
||||||
|
'IMAGE_PATH',
|
||||||
|
'MODEL_SAVE_PATH',
|
||||||
|
# Data Loading
|
||||||
|
'load_image',
|
||||||
|
'load_images',
|
||||||
|
'load_data',
|
||||||
|
'create_dataset_from_paths',
|
||||||
|
# Layers
|
||||||
|
'ConvBlock',
|
||||||
|
'AtrousSpatialPyramidPooling',
|
||||||
|
# Model
|
||||||
|
'create_model',
|
||||||
|
'load_model',
|
||||||
|
# Utils
|
||||||
|
'show_images',
|
||||||
|
'show_single_prediction',
|
||||||
|
'prediction_to_base64',
|
||||||
|
'mask_to_binary',
|
||||||
|
'calculate_iou',
|
||||||
|
# Training
|
||||||
|
'train',
|
||||||
|
]
|
||||||
38
app/AI_modules/water_body_segmentation/config.py
Normal file
38
app/AI_modules/water_body_segmentation/config.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# DeepLabV3+ Configuration
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 기본 경로 설정
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
DATA_DIR = os.path.join(BASE_DIR, 'data')
|
||||||
|
|
||||||
|
# 이미지 경로 (환경에 맞게 수정)
|
||||||
|
IMAGE_PATH = os.environ.get('DEEPLABV3_IMAGE_PATH', '/water_body_data/image/')
|
||||||
|
MASK_PATH = os.environ.get('DEEPLABV3_MASK_PATH', '/water_body_data/mask/')
|
||||||
|
|
||||||
|
# TIF 데이터셋 경로
|
||||||
|
TIF_DATASET_DIR = os.path.join(BASE_DIR, 'app', 'static', 'water_segmentation')
|
||||||
|
|
||||||
|
# 입력 채널 수 (R, G, B, MNDWI)
|
||||||
|
IN_CHANNELS = 4
|
||||||
|
|
||||||
|
# 모델 하이퍼파라미터
|
||||||
|
IMAGE_SIZE = 128
|
||||||
|
BATCH_SIZE = 8
|
||||||
|
LEARNING_RATE = 1e-3
|
||||||
|
|
||||||
|
# 학습 설정
|
||||||
|
EPOCHS = 50
|
||||||
|
SHUFFLE_BUFFER = 1000
|
||||||
|
|
||||||
|
# 모델 저장 경로
|
||||||
|
MODEL_SAVE_PATH = os.path.join(BASE_DIR, 'models', 'DeepLabV3-Plus.h5')
|
||||||
|
|
||||||
|
# ResNet50 설정
|
||||||
|
RESNET_WEIGHTS = 'imagenet'
|
||||||
|
|
||||||
|
# ASPP 설정
|
||||||
|
ASPP_FILTERS = 256
|
||||||
|
ASPP_DILATION_RATES = [1, 6, 12, 18]
|
||||||
|
|
||||||
|
# LLF 설정
|
||||||
|
LLF_FILTERS = 48
|
||||||
110
app/AI_modules/water_body_segmentation/data_loader_original.py
Normal file
110
app/AI_modules/water_body_segmentation/data_loader_original.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# DeepLabV3+ Data Loader
|
||||||
|
import numpy as np
|
||||||
|
from glob import glob
|
||||||
|
from tqdm import tqdm
|
||||||
|
import tensorflow as tf
|
||||||
|
import tensorflow.data as tfd
|
||||||
|
import tensorflow.image as tfi
|
||||||
|
from tensorflow.keras.utils import load_img, img_to_array
|
||||||
|
|
||||||
|
from .config import IMAGE_SIZE, BATCH_SIZE, SHUFFLE_BUFFER
|
||||||
|
|
||||||
|
|
||||||
|
def load_image(path, image_size=IMAGE_SIZE):
|
||||||
|
"""단일 이미지 로드 및 전처리"""
|
||||||
|
# Load Image
|
||||||
|
image = load_img(path)
|
||||||
|
|
||||||
|
# Convert to Array
|
||||||
|
image = img_to_array(image)
|
||||||
|
|
||||||
|
# Resize
|
||||||
|
image = tfi.resize(image, (image_size, image_size))
|
||||||
|
|
||||||
|
# Convert to float32
|
||||||
|
image = tf.cast(image, tf.float32)
|
||||||
|
|
||||||
|
# Standardization
|
||||||
|
image = image / 255.0
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def load_images(paths, image_size=IMAGE_SIZE):
|
||||||
|
"""여러 이미지 로드"""
|
||||||
|
# Create Space
|
||||||
|
images = np.zeros(shape=(len(paths), image_size, image_size, 3))
|
||||||
|
|
||||||
|
# Iterate Paths
|
||||||
|
for i, path in tqdm(enumerate(paths), desc="Loading", total=len(paths)):
|
||||||
|
# Load Image
|
||||||
|
images[i] = load_image(path, image_size=image_size)
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def load_data(root_path, trim=None, image_size=IMAGE_SIZE, batch_size=BATCH_SIZE, return_arrays=False):
|
||||||
|
"""
|
||||||
|
데이터셋 로드 및 tf.data.Dataset 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root_path: 이미지 폴더 경로 (Images 폴더)
|
||||||
|
trim: [start, end] 인덱스로 데이터 범위 지정
|
||||||
|
image_size: 이미지 크기
|
||||||
|
batch_size: 배치 크기
|
||||||
|
return_arrays: True면 (dataset, images, masks) 반환
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tf.data.Dataset 또는 (dataset, images, masks) 튜플
|
||||||
|
"""
|
||||||
|
# Collect Image Paths
|
||||||
|
image_paths = glob(root_path + "*.jpg")
|
||||||
|
|
||||||
|
if not image_paths:
|
||||||
|
raise ValueError(f"No images found in {root_path}")
|
||||||
|
|
||||||
|
# Trim if needed
|
||||||
|
if trim is not None:
|
||||||
|
image_paths = image_paths[trim[0]:trim[1]]
|
||||||
|
|
||||||
|
# Update mask paths
|
||||||
|
mask_paths = []
|
||||||
|
for path in image_paths:
|
||||||
|
mask_path = path.replace("Images", "Masks")
|
||||||
|
mask_paths.append(mask_path)
|
||||||
|
|
||||||
|
# Load Data
|
||||||
|
images = load_images(image_paths, image_size=image_size)
|
||||||
|
masks = load_images(mask_paths, image_size=image_size)
|
||||||
|
|
||||||
|
# Convert To tf.data.Dataset
|
||||||
|
data = tfd.Dataset.from_tensor_slices((images, masks))
|
||||||
|
data = data.batch(batch_size, drop_remainder=True)
|
||||||
|
data = data.shuffle(SHUFFLE_BUFFER).prefetch(tfd.AUTOTUNE)
|
||||||
|
|
||||||
|
if return_arrays:
|
||||||
|
return data, images, masks
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def create_dataset_from_paths(image_paths, mask_paths, image_size=IMAGE_SIZE, batch_size=BATCH_SIZE):
|
||||||
|
"""
|
||||||
|
경로 리스트에서 직접 데이터셋 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_paths: 이미지 경로 리스트
|
||||||
|
mask_paths: 마스크 경로 리스트
|
||||||
|
image_size: 이미지 크기
|
||||||
|
batch_size: 배치 크기
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tf.data.Dataset
|
||||||
|
"""
|
||||||
|
images = load_images(image_paths, image_size=image_size)
|
||||||
|
masks = load_images(mask_paths, image_size=image_size)
|
||||||
|
|
||||||
|
data = tfd.Dataset.from_tensor_slices((images, masks))
|
||||||
|
data = data.batch(batch_size, drop_remainder=True)
|
||||||
|
data = data.shuffle(SHUFFLE_BUFFER).prefetch(tfd.AUTOTUNE)
|
||||||
|
|
||||||
|
return data
|
||||||
165
app/AI_modules/water_body_segmentation/data_loader_tif.py
Normal file
165
app/AI_modules/water_body_segmentation/data_loader_tif.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
File: data_loader_tif.py
|
||||||
|
Description:
|
||||||
|
GeoTIFF 데이터 로더 (DeepLabV3+ 학습용)
|
||||||
|
4-band GeoTIFF (R, G, B, MNDWI) + 단일밴드 마스크를 로드하여
|
||||||
|
정규화, 리사이즈, augmentation, tf.data.Dataset 파이프라인 생성
|
||||||
|
Author: 원캉린, 소지안 프로
|
||||||
|
Created: 2026-02-02
|
||||||
|
Last Modified: 2026-02-02
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import numpy as np
|
||||||
|
import rasterio
|
||||||
|
import tensorflow as tf
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
from .config import IMAGE_SIZE, BATCH_SIZE, SHUFFLE_BUFFER
|
||||||
|
|
||||||
|
|
||||||
|
def _load_image(path: str, image_size: int = IMAGE_SIZE) -> np.ndarray:
|
||||||
|
"""4밴드 GeoTIFF 로드 → (H, W, 4) float32, 정규화 및 리사이즈 적용"""
|
||||||
|
with rasterio.open(path) as src:
|
||||||
|
image = src.read().transpose(1, 2, 0).astype(np.float32)
|
||||||
|
|
||||||
|
# 밴드별 min-max 정규화 (0~1)
|
||||||
|
for c in range(image.shape[2]):
|
||||||
|
band = image[:, :, c]
|
||||||
|
b_min, b_max = band.min(), band.max()
|
||||||
|
if b_max > b_min:
|
||||||
|
image[:, :, c] = (band - b_min) / (b_max - b_min)
|
||||||
|
else:
|
||||||
|
image[:, :, c] = 0.0
|
||||||
|
|
||||||
|
# 리사이즈
|
||||||
|
image = tf.image.resize(image, (image_size, image_size)).numpy()
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def _load_mask(path: str, image_size: int = IMAGE_SIZE) -> np.ndarray:
|
||||||
|
"""단일밴드 마스크 GeoTIFF 로드 → (H, W, 1) float32, 이진화 및 리사이즈 적용"""
|
||||||
|
with rasterio.open(path) as src:
|
||||||
|
mask = src.read(1).astype(np.float32)
|
||||||
|
|
||||||
|
mask = mask[..., np.newaxis] # (H, W, 1)
|
||||||
|
|
||||||
|
# 리사이즈
|
||||||
|
mask = tf.image.resize(mask, (image_size, image_size), method='nearest').numpy()
|
||||||
|
|
||||||
|
# 이진화 (0 또는 1)
|
||||||
|
mask = (mask > 0.5).astype(np.float32)
|
||||||
|
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def load_dataset(dataset_dir: str, image_size: int = IMAGE_SIZE, max_samples: int = None):
|
||||||
|
"""
|
||||||
|
데이터셋 디렉토리에서 이미지/마스크 로드
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dataset_dir: 'dataset/{scene_id}/' (image/, mask/ 폴더 포함)
|
||||||
|
image_size: 리사이즈 크기
|
||||||
|
max_samples: 최대 로드 개수 (None이면 전체)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
images: (N, H, W, 4) float32, 정규화됨
|
||||||
|
masks: (N, H, W, 1) float32, 이진 마스크
|
||||||
|
"""
|
||||||
|
image_paths = sorted(glob.glob(os.path.join(dataset_dir, "image", "*.tif")))
|
||||||
|
mask_paths = sorted(glob.glob(os.path.join(dataset_dir, "mask", "*.tif")))
|
||||||
|
|
||||||
|
assert len(image_paths) == len(mask_paths), \
|
||||||
|
f"이미지({len(image_paths)})와 마스크({len(mask_paths)}) 개수 불일치"
|
||||||
|
assert len(image_paths) > 0, f"이미지를 찾을 수 없음: {dataset_dir}/image/"
|
||||||
|
|
||||||
|
if max_samples is not None:
|
||||||
|
image_paths = image_paths[:max_samples]
|
||||||
|
mask_paths = mask_paths[:max_samples]
|
||||||
|
|
||||||
|
n = len(image_paths)
|
||||||
|
images = np.zeros((n, image_size, image_size, 4), dtype=np.float32)
|
||||||
|
masks = np.zeros((n, image_size, image_size, 1), dtype=np.float32)
|
||||||
|
|
||||||
|
for i, (img_path, msk_path) in tqdm(
|
||||||
|
enumerate(zip(image_paths, mask_paths)), total=n, desc="Loading TIF"
|
||||||
|
):
|
||||||
|
images[i] = _load_image(img_path, image_size)
|
||||||
|
masks[i] = _load_mask(msk_path, image_size)
|
||||||
|
|
||||||
|
return images, masks
|
||||||
|
|
||||||
|
|
||||||
|
def augment(image, mask):
|
||||||
|
"""Data augmentation (소규모 데이터셋용)"""
|
||||||
|
# 좌우 반전
|
||||||
|
if tf.random.uniform(()) > 0.5:
|
||||||
|
image = tf.image.flip_left_right(image)
|
||||||
|
mask = tf.image.flip_left_right(mask)
|
||||||
|
|
||||||
|
# 상하 반전
|
||||||
|
if tf.random.uniform(()) > 0.5:
|
||||||
|
image = tf.image.flip_up_down(image)
|
||||||
|
mask = tf.image.flip_up_down(mask)
|
||||||
|
|
||||||
|
# 90도 회전 (0~3회)
|
||||||
|
k = tf.random.uniform((), minval=0, maxval=4, dtype=tf.int32)
|
||||||
|
image = tf.image.rot90(image, k)
|
||||||
|
mask = tf.image.rot90(mask, k)
|
||||||
|
|
||||||
|
# 밝기 조절 (이미지만)
|
||||||
|
image = tf.image.random_brightness(image, max_delta=0.1)
|
||||||
|
image = tf.clip_by_value(image, 0.0, 1.0)
|
||||||
|
|
||||||
|
return image, mask
|
||||||
|
|
||||||
|
|
||||||
|
def create_dataset(dataset_dir: str, image_size: int = IMAGE_SIZE,
|
||||||
|
batch_size: int = BATCH_SIZE, use_augmentation: bool = True,
|
||||||
|
max_samples: int = None, return_arrays: bool = False):
|
||||||
|
"""
|
||||||
|
TIF 데이터셋을 로드하여 tf.data.Dataset 파이프라인 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dataset_dir: 데이터셋 경로
|
||||||
|
image_size: 이미지 크기
|
||||||
|
batch_size: 배치 크기
|
||||||
|
use_augmentation: augmentation 적용 여부
|
||||||
|
return_arrays: True면 (dataset, images, masks) 반환
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tf.data.Dataset 또는 (dataset, images, masks) 튜플
|
||||||
|
"""
|
||||||
|
images, masks = load_dataset(dataset_dir, image_size, max_samples=max_samples)
|
||||||
|
|
||||||
|
dataset = tf.data.Dataset.from_tensor_slices((images, masks))
|
||||||
|
|
||||||
|
if use_augmentation:
|
||||||
|
dataset = dataset.map(augment, num_parallel_calls=tf.data.AUTOTUNE)
|
||||||
|
|
||||||
|
dataset = dataset.shuffle(SHUFFLE_BUFFER)
|
||||||
|
dataset = dataset.batch(batch_size, drop_remainder=True)
|
||||||
|
dataset = dataset.prefetch(tf.data.AUTOTUNE)
|
||||||
|
|
||||||
|
if return_arrays:
|
||||||
|
return dataset, images, masks
|
||||||
|
return dataset
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
dataset_dir = "dataset_256x256/S2B_MSIL2A_20231001T020659_R103_T52SCD_20241020T075116_tif"
|
||||||
|
|
||||||
|
images, masks = load_dataset(dataset_dir)
|
||||||
|
print(f"Images: {images.shape}, dtype={images.dtype}")
|
||||||
|
print(f"Masks: {masks.shape}, dtype={masks.dtype}")
|
||||||
|
print(f"Image value range: [{images.min():.3f}, {images.max():.3f}]")
|
||||||
|
print(f"Mask unique values: {np.unique(masks)}")
|
||||||
|
|
||||||
|
dataset = create_dataset(dataset_dir)
|
||||||
|
for batch_img, batch_msk in dataset.take(1):
|
||||||
|
print(f"Batch images: {batch_img.shape}")
|
||||||
|
print(f"Batch masks: {batch_msk.shape}")
|
||||||
91
app/AI_modules/water_body_segmentation/layers.py
Normal file
91
app/AI_modules/water_body_segmentation/layers.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# DeepLabV3+ Custom Layers
|
||||||
|
from keras.layers import ReLU
|
||||||
|
from keras.layers import Layer
|
||||||
|
from keras.layers import Conv2D
|
||||||
|
from keras.layers import Concatenate
|
||||||
|
from keras.layers import UpSampling2D
|
||||||
|
from keras.layers import AveragePooling2D
|
||||||
|
from keras.models import Sequential
|
||||||
|
from keras.layers import BatchNormalization
|
||||||
|
|
||||||
|
from .config import ASPP_FILTERS
|
||||||
|
|
||||||
|
|
||||||
|
class ConvBlock(Layer):
|
||||||
|
"""
|
||||||
|
Convolution Block: Conv2D -> BatchNormalization -> ReLU
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filters: 출력 필터 수
|
||||||
|
kernel_size: 커널 크기
|
||||||
|
dilation_rate: Dilation rate (Atrous convolution용)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, filters=ASPP_FILTERS, kernel_size=3, dilation_rate=1, **kwargs):
|
||||||
|
super(ConvBlock, self).__init__(**kwargs)
|
||||||
|
|
||||||
|
self.filters = filters
|
||||||
|
self.kernel_size = kernel_size
|
||||||
|
self.dilation_rate = dilation_rate
|
||||||
|
|
||||||
|
self.net = Sequential([
|
||||||
|
Conv2D(
|
||||||
|
filters,
|
||||||
|
kernel_size=kernel_size,
|
||||||
|
padding='same',
|
||||||
|
dilation_rate=dilation_rate,
|
||||||
|
use_bias=False,
|
||||||
|
kernel_initializer='he_normal'
|
||||||
|
),
|
||||||
|
BatchNormalization(),
|
||||||
|
ReLU()
|
||||||
|
])
|
||||||
|
|
||||||
|
def call(self, X):
|
||||||
|
return self.net(X)
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
base_config = super().get_config()
|
||||||
|
return {
|
||||||
|
**base_config,
|
||||||
|
"filters": self.filters,
|
||||||
|
"kernel_size": self.kernel_size,
|
||||||
|
"dilation_rate": self.dilation_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def AtrousSpatialPyramidPooling(X, filters=ASPP_FILTERS):
|
||||||
|
"""
|
||||||
|
Atrous Spatial Pyramid Pooling (ASPP) 모듈
|
||||||
|
|
||||||
|
다양한 dilation rate의 atrous convolution을 병렬로 적용하여
|
||||||
|
다중 스케일 컨텍스트를 캡처합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
X: 입력 텐서
|
||||||
|
filters: 각 브랜치의 출력 필터 수
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ASPP 처리된 텐서
|
||||||
|
"""
|
||||||
|
B, H, W, C = X.shape
|
||||||
|
|
||||||
|
# Image Pooling - 전역 컨텍스트 캡처
|
||||||
|
image_pool = AveragePooling2D(pool_size=(H, W), name="ASPP-AvgPool")(X)
|
||||||
|
image_pool = ConvBlock(filters=filters, kernel_size=1, name="ASPP-ImagePool-CB")(image_pool)
|
||||||
|
image_pool = UpSampling2D(
|
||||||
|
size=(H // image_pool.shape[1], W // image_pool.shape[2]),
|
||||||
|
name="ASPP-ImagePool-UpSample"
|
||||||
|
)(image_pool)
|
||||||
|
|
||||||
|
# Atrous Convolutions with different dilation rates
|
||||||
|
conv_1 = ConvBlock(filters=filters, kernel_size=1, dilation_rate=1, name="ASPP-CB-1")(X)
|
||||||
|
conv_6 = ConvBlock(filters=filters, kernel_size=3, dilation_rate=6, name="ASPP-CB-6")(X)
|
||||||
|
conv_12 = ConvBlock(filters=filters, kernel_size=3, dilation_rate=12, name="ASPP-CB-12")(X)
|
||||||
|
conv_18 = ConvBlock(filters=filters, kernel_size=3, dilation_rate=18, name="ASPP-CB-18")(X)
|
||||||
|
|
||||||
|
# Combine All branches
|
||||||
|
combined = Concatenate(name="ASPP-Combine")([image_pool, conv_1, conv_6, conv_12, conv_18])
|
||||||
|
processed = ConvBlock(filters=filters, kernel_size=1, name="ASPP-Net")(combined)
|
||||||
|
|
||||||
|
return processed
|
||||||
118
app/AI_modules/water_body_segmentation/model.py
Normal file
118
app/AI_modules/water_body_segmentation/model.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# DeepLabV3+ Model
|
||||||
|
import tensorflow as tf
|
||||||
|
from keras.layers import Input
|
||||||
|
from keras.layers import Conv2D
|
||||||
|
from keras.layers import Concatenate
|
||||||
|
from keras.layers import UpSampling2D
|
||||||
|
from keras.models import Model
|
||||||
|
from tensorflow.keras.applications import ResNet50
|
||||||
|
|
||||||
|
from .config import IMAGE_SIZE, LEARNING_RATE, LLF_FILTERS, RESNET_WEIGHTS
|
||||||
|
from .layers import ConvBlock, AtrousSpatialPyramidPooling
|
||||||
|
|
||||||
|
|
||||||
|
def create_model(image_size=IMAGE_SIZE, learning_rate=LEARNING_RATE,
|
||||||
|
compile_model=True, in_channels=4):
|
||||||
|
"""
|
||||||
|
DeepLabV3+ 모델 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_size: 입력 이미지 크기
|
||||||
|
learning_rate: 학습률
|
||||||
|
compile_model: True면 모델 컴파일 포함
|
||||||
|
in_channels: 입력 채널 수 (4 = R,G,B,MNDWI)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeepLabV3+ Keras Model
|
||||||
|
"""
|
||||||
|
# Input Layer
|
||||||
|
InputL = Input(shape=(image_size, image_size, in_channels), name="InputLayer")
|
||||||
|
|
||||||
|
# 4채널 → 3채널 변환 (ResNet50은 3채널만 지원)
|
||||||
|
if in_channels != 3:
|
||||||
|
x = Conv2D(3, kernel_size=1, padding='same', name="ChannelReduce")(InputL)
|
||||||
|
else:
|
||||||
|
x = InputL
|
||||||
|
|
||||||
|
# Backbone: ResNet50 (pretrained on ImageNet)
|
||||||
|
# 별도 input_shape로 생성 후 중간 레이어 출력을 추출
|
||||||
|
resnet50_base = ResNet50(
|
||||||
|
include_top=False,
|
||||||
|
weights=RESNET_WEIGHTS,
|
||||||
|
input_shape=(image_size, image_size, 3)
|
||||||
|
)
|
||||||
|
# ResNet50의 중간 출력을 가져오기 위한 멀티출력 모델 생성
|
||||||
|
resnet50 = Model(
|
||||||
|
inputs=resnet50_base.input,
|
||||||
|
outputs={
|
||||||
|
'deep': resnet50_base.get_layer('conv4_block6_2_relu').output,
|
||||||
|
'low': resnet50_base.get_layer('conv2_block3_2_relu').output,
|
||||||
|
},
|
||||||
|
name="ResNet50-Backbone"
|
||||||
|
)
|
||||||
|
features_dict = resnet50(x)
|
||||||
|
|
||||||
|
# ASPP Phase - Deep CNN features
|
||||||
|
DCNN = features_dict['deep']
|
||||||
|
ASPP = AtrousSpatialPyramidPooling(DCNN)
|
||||||
|
ASPP = UpSampling2D(
|
||||||
|
size=(image_size // 4 // ASPP.shape[1], image_size // 4 // ASPP.shape[2]),
|
||||||
|
name="AtrousSpatial"
|
||||||
|
)(ASPP)
|
||||||
|
|
||||||
|
# Low-Level Features (LLF) Phase
|
||||||
|
LLF = features_dict['low']
|
||||||
|
LLF = ConvBlock(filters=LLF_FILTERS, kernel_size=1, name="LLF-ConvBlock")(LLF)
|
||||||
|
|
||||||
|
# Decoder: Combine ASPP and LLF
|
||||||
|
combined = Concatenate(axis=-1, name="Combine-LLF-ASPP")([ASPP, LLF])
|
||||||
|
features = ConvBlock(name="Top-ConvBlock-1")(combined)
|
||||||
|
features = ConvBlock(name="Top-ConvBlock-2")(features)
|
||||||
|
|
||||||
|
# Upsample to original size
|
||||||
|
upsample = UpSampling2D(
|
||||||
|
size=(image_size // features.shape[1], image_size // features.shape[1]),
|
||||||
|
interpolation='bilinear',
|
||||||
|
name="Top-UpSample"
|
||||||
|
)(features)
|
||||||
|
|
||||||
|
# Output Mask (1 channel for binary segmentation)
|
||||||
|
PredMask = Conv2D(
|
||||||
|
1,
|
||||||
|
kernel_size=3,
|
||||||
|
strides=1,
|
||||||
|
padding='same',
|
||||||
|
activation='sigmoid',
|
||||||
|
use_bias=False,
|
||||||
|
name="OutputMask"
|
||||||
|
)(upsample)
|
||||||
|
|
||||||
|
# Build Model
|
||||||
|
model = Model(InputL, PredMask, name="DeepLabV3-Plus")
|
||||||
|
|
||||||
|
# Compile if requested
|
||||||
|
if compile_model:
|
||||||
|
optimizer = tf.keras.optimizers.Adam(learning_rate=learning_rate)
|
||||||
|
model.compile(loss='binary_crossentropy', optimizer=optimizer)
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def load_model(model_path):
|
||||||
|
"""
|
||||||
|
저장된 모델 로드
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_path: 모델 파일 경로 (.h5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
로드된 Keras Model
|
||||||
|
"""
|
||||||
|
from keras.models import load_model as keras_load_model
|
||||||
|
from .layers import ConvBlock
|
||||||
|
|
||||||
|
custom_objects = {
|
||||||
|
'ConvBlock': ConvBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
return keras_load_model(model_path, custom_objects=custom_objects)
|
||||||
111
app/AI_modules/water_body_segmentation/pyplot_tif.py
Normal file
111
app/AI_modules/water_body_segmentation/pyplot_tif.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
GeoTIFF 4밴드 수체 세그멘테이션 시각적 테스트
|
||||||
|
원본 RGB | 정답 마스크 | 예측 마스크 | 오버레이 비교
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
|
||||||
|
def _to_rgb(image):
|
||||||
|
"""4밴드 (R,G,B,MNDWI) 이미지에서 RGB 3채널 추출"""
|
||||||
|
return image[:, :, :3]
|
||||||
|
|
||||||
|
|
||||||
|
def show_predictions(images, masks, model, n_images=6, save_path=None):
|
||||||
|
"""
|
||||||
|
모델 예측 결과를 시각적으로 비교
|
||||||
|
|
||||||
|
Args:
|
||||||
|
images: (N, H, W, 4) 이미지 배열
|
||||||
|
masks: (N, H, W, 1) 정답 마스크 배열
|
||||||
|
model: 학습된 모델
|
||||||
|
n_images: 표시할 이미지 수
|
||||||
|
save_path: 저장 경로 (None이면 화면 표시)
|
||||||
|
"""
|
||||||
|
n = min(n_images, len(images))
|
||||||
|
indices = np.random.choice(len(images), n, replace=False)
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(n, 4, figsize=(20, 5 * n))
|
||||||
|
if n == 1:
|
||||||
|
axes = axes[np.newaxis, :]
|
||||||
|
|
||||||
|
for row, idx in enumerate(indices):
|
||||||
|
image = images[idx]
|
||||||
|
mask = masks[idx]
|
||||||
|
pred = model.predict(image[np.newaxis, ...], verbose=0)[0]
|
||||||
|
|
||||||
|
rgb = _to_rgb(image)
|
||||||
|
mask_2d = mask[:, :, 0]
|
||||||
|
pred_2d = (pred[:, :, 0] > 0.5).astype(np.float32)
|
||||||
|
|
||||||
|
# 원본 RGB
|
||||||
|
axes[row, 0].imshow(np.clip(rgb, 0, 1))
|
||||||
|
axes[row, 0].set_title('Original RGB')
|
||||||
|
axes[row, 0].axis('off')
|
||||||
|
|
||||||
|
# 정답 마스크
|
||||||
|
axes[row, 1].imshow(mask_2d, cmap='Blues', vmin=0, vmax=1)
|
||||||
|
axes[row, 1].set_title('Ground Truth')
|
||||||
|
axes[row, 1].axis('off')
|
||||||
|
|
||||||
|
# 예측 마스크
|
||||||
|
axes[row, 2].imshow(pred_2d, cmap='Blues', vmin=0, vmax=1)
|
||||||
|
axes[row, 2].set_title('Prediction')
|
||||||
|
axes[row, 2].axis('off')
|
||||||
|
|
||||||
|
# 오버레이
|
||||||
|
axes[row, 3].imshow(np.clip(rgb, 0, 1))
|
||||||
|
axes[row, 3].imshow(pred_2d, cmap='Blues', alpha=0.4, vmin=0, vmax=1)
|
||||||
|
axes[row, 3].set_title('Overlay')
|
||||||
|
axes[row, 3].axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
if save_path:
|
||||||
|
plt.savefig(save_path, dpi=150, bbox_inches='tight')
|
||||||
|
print(f"결과 저장: {save_path}")
|
||||||
|
else:
|
||||||
|
plt.show()
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
|
||||||
|
def show_dataset(images, masks, n_images=6, save_path=None):
|
||||||
|
"""
|
||||||
|
데이터셋 미리보기 (모델 없이)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
images: (N, H, W, 4) 이미지 배열
|
||||||
|
masks: (N, H, W, 1) 마스크 배열
|
||||||
|
n_images: 표시할 이미지 수
|
||||||
|
save_path: 저장 경로 (None이면 화면 표시)
|
||||||
|
"""
|
||||||
|
n = min(n_images, len(images))
|
||||||
|
indices = np.random.choice(len(images), n, replace=False)
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(n, 3, figsize=(15, 5 * n))
|
||||||
|
if n == 1:
|
||||||
|
axes = axes[np.newaxis, :]
|
||||||
|
|
||||||
|
for row, idx in enumerate(indices):
|
||||||
|
rgb = _to_rgb(images[idx])
|
||||||
|
mask_2d = masks[idx][:, :, 0]
|
||||||
|
|
||||||
|
axes[row, 0].imshow(np.clip(rgb, 0, 1))
|
||||||
|
axes[row, 0].set_title('Original RGB')
|
||||||
|
axes[row, 0].axis('off')
|
||||||
|
|
||||||
|
axes[row, 1].imshow(mask_2d, cmap='Blues', vmin=0, vmax=1)
|
||||||
|
axes[row, 1].set_title('Mask')
|
||||||
|
axes[row, 1].axis('off')
|
||||||
|
|
||||||
|
axes[row, 2].imshow(np.clip(rgb, 0, 1))
|
||||||
|
axes[row, 2].imshow(mask_2d, cmap='Blues', alpha=0.4, vmin=0, vmax=1)
|
||||||
|
axes[row, 2].set_title('Overlay')
|
||||||
|
axes[row, 2].axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
if save_path:
|
||||||
|
plt.savefig(save_path, dpi=150, bbox_inches='tight')
|
||||||
|
print(f"결과 저장: {save_path}")
|
||||||
|
else:
|
||||||
|
plt.show()
|
||||||
|
plt.close()
|
||||||
210
app/AI_modules/water_body_segmentation/train.py
Normal file
210
app/AI_modules/water_body_segmentation/train.py
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
"""
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
File: train.py
|
||||||
|
Description: DeepLabV3+ 모델 학습 스크립트 (4밴드 GeoTIFF)
|
||||||
|
사용법:
|
||||||
|
python -m app.AI_modules.water_body_segmentation.train --dataset_dir /path/to/dataset/ --epochs 50
|
||||||
|
Author: 소지안 프로
|
||||||
|
Created: 2026-02-02
|
||||||
|
Last Modified: 2026-02-02
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
import tensorflow as tf
|
||||||
|
from keras.callbacks import Callback, ModelCheckpoint
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
TIF_DATASET_DIR, IMAGE_SIZE, BATCH_SIZE,
|
||||||
|
LEARNING_RATE, EPOCHS, MODEL_SAVE_PATH, IN_CHANNELS
|
||||||
|
)
|
||||||
|
from .data_loader_tif import load_dataset, create_dataset
|
||||||
|
from .model import create_model
|
||||||
|
from .pyplot_tif import show_predictions
|
||||||
|
|
||||||
|
|
||||||
|
class ShowProgress(Callback):
|
||||||
|
"""학습 중 진행 상황을 시각화하는 콜백"""
|
||||||
|
|
||||||
|
def __init__(self, val_images, val_masks, save_dir=None, interval=5):
|
||||||
|
super().__init__()
|
||||||
|
self.val_images = val_images
|
||||||
|
self.val_masks = val_masks
|
||||||
|
self.save_dir = save_dir
|
||||||
|
self.interval = interval
|
||||||
|
|
||||||
|
def on_epoch_end(self, epoch, logs=None):
|
||||||
|
if (epoch + 1) % self.interval == 0:
|
||||||
|
print(f"\n[Epoch {epoch + 1}] Saving validation predictions...")
|
||||||
|
save_path = None
|
||||||
|
if self.save_dir:
|
||||||
|
save_path = os.path.join(self.save_dir, f"epoch_{epoch + 1}.png")
|
||||||
|
show_predictions(
|
||||||
|
self.val_images,
|
||||||
|
self.val_masks,
|
||||||
|
model=self.model,
|
||||||
|
n_images=5,
|
||||||
|
save_path=save_path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def train(
|
||||||
|
dataset_dir=TIF_DATASET_DIR,
|
||||||
|
image_size=IMAGE_SIZE,
|
||||||
|
batch_size=BATCH_SIZE,
|
||||||
|
learning_rate=LEARNING_RATE,
|
||||||
|
epochs=EPOCHS,
|
||||||
|
model_save_path=MODEL_SAVE_PATH,
|
||||||
|
in_channels=IN_CHANNELS,
|
||||||
|
max_train_samples=None,
|
||||||
|
max_val_samples=None,
|
||||||
|
val_ratio=0.1,
|
||||||
|
show_progress_interval=5
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
DeepLabV3+ 모델 학습 (TIF 데이터셋)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dataset_dir: TIF 데이터셋 경로 (image/, mask/ 폴더 포함)
|
||||||
|
image_size: 이미지 크기
|
||||||
|
batch_size: 배치 크기
|
||||||
|
learning_rate: 학습률
|
||||||
|
epochs: 에폭 수
|
||||||
|
model_save_path: 모델 저장 경로
|
||||||
|
in_channels: 입력 채널 수 (4 = R,G,B,MNDWI)
|
||||||
|
max_train_samples: 학습 데이터 최대 개수 (None이면 전체)
|
||||||
|
max_val_samples: 검증 데이터 최대 개수
|
||||||
|
val_ratio: 검증 데이터 비율 (max_val_samples 미지정 시)
|
||||||
|
show_progress_interval: 진행 상황 표시 간격 (에폭)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
학습된 모델과 학습 히스토리
|
||||||
|
"""
|
||||||
|
print("=" * 50)
|
||||||
|
print("DeepLabV3+ Training (TIF)")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"Dataset Dir: {dataset_dir}")
|
||||||
|
print(f"Image Size: {image_size}")
|
||||||
|
print(f"Input Channels: {in_channels}")
|
||||||
|
print(f"Batch Size: {batch_size}")
|
||||||
|
print(f"Learning Rate: {learning_rate}")
|
||||||
|
print(f"Epochs: {epochs}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# 전체 데이터 로드
|
||||||
|
print("\n[1/4] Loading dataset...")
|
||||||
|
images, masks = load_dataset(
|
||||||
|
dataset_dir,
|
||||||
|
image_size=image_size,
|
||||||
|
max_samples=max_train_samples
|
||||||
|
)
|
||||||
|
total = len(images)
|
||||||
|
|
||||||
|
# train/val 분리
|
||||||
|
if max_val_samples:
|
||||||
|
n_val = max_val_samples
|
||||||
|
else:
|
||||||
|
n_val = max(1, int(total * val_ratio))
|
||||||
|
n_train = total - n_val
|
||||||
|
|
||||||
|
train_images, train_masks = images[:n_train], masks[:n_train]
|
||||||
|
val_images, val_masks = images[n_train:], masks[n_train:]
|
||||||
|
print(f" Train: {train_images.shape[0]}개, Val: {val_images.shape[0]}개")
|
||||||
|
|
||||||
|
# tf.data.Dataset 생성
|
||||||
|
train_ds = tf.data.Dataset.from_tensor_slices((train_images, train_masks))
|
||||||
|
train_ds = train_ds.shuffle(n_train).batch(batch_size, drop_remainder=True)
|
||||||
|
train_ds = train_ds.prefetch(tf.data.AUTOTUNE)
|
||||||
|
|
||||||
|
val_ds = tf.data.Dataset.from_tensor_slices((val_images, val_masks))
|
||||||
|
val_ds = val_ds.batch(batch_size).prefetch(tf.data.AUTOTUNE)
|
||||||
|
|
||||||
|
# 모델 생성
|
||||||
|
print("\n[2/4] Creating model...")
|
||||||
|
model = create_model(
|
||||||
|
image_size=image_size,
|
||||||
|
learning_rate=learning_rate,
|
||||||
|
compile_model=True,
|
||||||
|
in_channels=in_channels
|
||||||
|
)
|
||||||
|
print(f" Input: {model.input_shape}, Output: {model.output_shape}")
|
||||||
|
|
||||||
|
# 저장 경로 디렉토리 생성
|
||||||
|
os.makedirs(os.path.dirname(model_save_path), exist_ok=True)
|
||||||
|
|
||||||
|
# 결과 저장 디렉토리
|
||||||
|
progress_dir = os.path.join(os.path.dirname(model_save_path), 'training_progress')
|
||||||
|
os.makedirs(progress_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 콜백 설정
|
||||||
|
callbacks = [
|
||||||
|
ModelCheckpoint(
|
||||||
|
model_save_path,
|
||||||
|
save_best_only=True,
|
||||||
|
monitor='val_loss',
|
||||||
|
verbose=1
|
||||||
|
),
|
||||||
|
ShowProgress(val_images, val_masks,
|
||||||
|
save_dir=progress_dir,
|
||||||
|
interval=show_progress_interval)
|
||||||
|
]
|
||||||
|
|
||||||
|
# 학습
|
||||||
|
print("\n[3/4] Training...")
|
||||||
|
history = model.fit(
|
||||||
|
train_ds,
|
||||||
|
validation_data=val_ds,
|
||||||
|
epochs=epochs,
|
||||||
|
callbacks=callbacks
|
||||||
|
)
|
||||||
|
|
||||||
|
# 최종 예측 결과 저장
|
||||||
|
print("\n[4/4] Saving final predictions...")
|
||||||
|
show_predictions(
|
||||||
|
val_images, val_masks, model,
|
||||||
|
n_images=min(5, len(val_images)),
|
||||||
|
save_path=os.path.join(progress_dir, "final_predictions.png")
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Training completed!")
|
||||||
|
print(f"Model saved to: {model_save_path}")
|
||||||
|
print(f"Progress images: {progress_dir}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
return model, history
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""CLI 엔트리포인트"""
|
||||||
|
parser = argparse.ArgumentParser(description='Train DeepLabV3+ model (TIF)')
|
||||||
|
parser.add_argument('--dataset_dir', type=str, default=TIF_DATASET_DIR,
|
||||||
|
help='Path to TIF dataset directory')
|
||||||
|
parser.add_argument('--image_size', type=int, default=IMAGE_SIZE,
|
||||||
|
help='Image size')
|
||||||
|
parser.add_argument('--batch_size', type=int, default=BATCH_SIZE,
|
||||||
|
help='Batch size')
|
||||||
|
parser.add_argument('--learning_rate', type=float, default=LEARNING_RATE,
|
||||||
|
help='Learning rate')
|
||||||
|
parser.add_argument('--epochs', type=int, default=EPOCHS,
|
||||||
|
help='Number of epochs')
|
||||||
|
parser.add_argument('--model_save_path', type=str, default=MODEL_SAVE_PATH,
|
||||||
|
help='Path to save model')
|
||||||
|
parser.add_argument('--max_samples', type=int, default=None,
|
||||||
|
help='Max training samples to load')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
train(
|
||||||
|
dataset_dir=args.dataset_dir,
|
||||||
|
image_size=args.image_size,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
learning_rate=args.learning_rate,
|
||||||
|
epochs=args.epochs,
|
||||||
|
model_save_path=args.model_save_path,
|
||||||
|
max_train_samples=args.max_samples
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
172
app/AI_modules/water_body_segmentation/utils.py
Normal file
172
app/AI_modules/water_body_segmentation/utils.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# DeepLabV3+ Utilities
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
|
||||||
|
def show_images(data, model=None, explain=False, n_images=5, SIZE=(15, 5)):
|
||||||
|
"""
|
||||||
|
이미지와 마스크 시각화
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: tf.data.Dataset (images, masks) 형태
|
||||||
|
model: 예측에 사용할 모델 (선택)
|
||||||
|
explain: True면 예측 결과도 표시
|
||||||
|
n_images: 표시할 이미지 수
|
||||||
|
SIZE: 그림 크기 (width, height)
|
||||||
|
"""
|
||||||
|
# Get batch from dataset
|
||||||
|
for images, masks in data.take(1):
|
||||||
|
images = images.numpy()
|
||||||
|
masks = masks.numpy()
|
||||||
|
|
||||||
|
# Limit number of images
|
||||||
|
n_images = min(n_images, len(images))
|
||||||
|
|
||||||
|
if explain and model is not None:
|
||||||
|
# Show Image, Mask, and Prediction
|
||||||
|
fig, axes = plt.subplots(n_images, 3, figsize=SIZE)
|
||||||
|
predictions = model.predict(images[:n_images])
|
||||||
|
|
||||||
|
for i in range(n_images):
|
||||||
|
# Original Image
|
||||||
|
axes[i, 0].imshow(images[i])
|
||||||
|
axes[i, 0].set_title("Image")
|
||||||
|
axes[i, 0].axis('off')
|
||||||
|
|
||||||
|
# Ground Truth Mask
|
||||||
|
axes[i, 1].imshow(masks[i])
|
||||||
|
axes[i, 1].set_title("Mask (GT)")
|
||||||
|
axes[i, 1].axis('off')
|
||||||
|
|
||||||
|
# Predicted Mask
|
||||||
|
axes[i, 2].imshow(predictions[i])
|
||||||
|
axes[i, 2].set_title("Prediction")
|
||||||
|
axes[i, 2].axis('off')
|
||||||
|
else:
|
||||||
|
# Show only Image and Mask
|
||||||
|
fig, axes = plt.subplots(n_images, 2, figsize=SIZE)
|
||||||
|
|
||||||
|
for i in range(n_images):
|
||||||
|
# Original Image
|
||||||
|
axes[i, 0].imshow(images[i])
|
||||||
|
axes[i, 0].set_title("Image")
|
||||||
|
axes[i, 0].axis('off')
|
||||||
|
|
||||||
|
# Ground Truth Mask
|
||||||
|
axes[i, 1].imshow(masks[i])
|
||||||
|
axes[i, 1].set_title("Mask")
|
||||||
|
axes[i, 1].axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
def show_single_prediction(model, image):
|
||||||
|
"""
|
||||||
|
단일 이미지 예측 시각화
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: 학습된 DeepLabV3+ 모델
|
||||||
|
image: 입력 이미지 (numpy array)
|
||||||
|
"""
|
||||||
|
# Ensure correct shape
|
||||||
|
if len(image.shape) == 3:
|
||||||
|
image = np.expand_dims(image, axis=0)
|
||||||
|
|
||||||
|
# Predict
|
||||||
|
prediction = model.predict(image)[0]
|
||||||
|
|
||||||
|
# Visualize
|
||||||
|
fig, axes = plt.subplots(1, 2, figsize=(10, 5))
|
||||||
|
|
||||||
|
axes[0].imshow(image[0])
|
||||||
|
axes[0].set_title("Input Image")
|
||||||
|
axes[0].axis('off')
|
||||||
|
|
||||||
|
axes[1].imshow(prediction)
|
||||||
|
axes[1].set_title("Prediction")
|
||||||
|
axes[1].axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
def prediction_to_base64(model, image):
|
||||||
|
"""
|
||||||
|
예측 결과를 base64 이미지로 변환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: 학습된 DeepLabV3+ 모델
|
||||||
|
image: 입력 이미지 (numpy array)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
base64 인코딩된 이미지 문자열
|
||||||
|
"""
|
||||||
|
# Ensure correct shape
|
||||||
|
if len(image.shape) == 3:
|
||||||
|
image = np.expand_dims(image, axis=0)
|
||||||
|
|
||||||
|
# Predict
|
||||||
|
prediction = model.predict(image)[0]
|
||||||
|
|
||||||
|
# Create figure
|
||||||
|
fig, axes = plt.subplots(1, 2, figsize=(10, 5))
|
||||||
|
|
||||||
|
axes[0].imshow(image[0])
|
||||||
|
axes[0].set_title("Input Image")
|
||||||
|
axes[0].axis('off')
|
||||||
|
|
||||||
|
axes[1].imshow(prediction)
|
||||||
|
axes[1].set_title("Water Body Prediction")
|
||||||
|
axes[1].axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buf = io.BytesIO()
|
||||||
|
plt.savefig(buf, format='png', bbox_inches='tight', dpi=100)
|
||||||
|
buf.seek(0)
|
||||||
|
img_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return img_base64
|
||||||
|
|
||||||
|
|
||||||
|
def mask_to_binary(mask, threshold=0.5):
|
||||||
|
"""
|
||||||
|
마스크를 이진화
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mask: 예측 마스크
|
||||||
|
threshold: 이진화 임계값
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
이진화된 마스크
|
||||||
|
"""
|
||||||
|
return (mask > threshold).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_iou(pred_mask, true_mask, threshold=0.5):
|
||||||
|
"""
|
||||||
|
IoU (Intersection over Union) 계산
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pred_mask: 예측 마스크
|
||||||
|
true_mask: 실제 마스크
|
||||||
|
threshold: 이진화 임계값
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IoU 값
|
||||||
|
"""
|
||||||
|
pred_binary = mask_to_binary(pred_mask, threshold)
|
||||||
|
true_binary = mask_to_binary(true_mask, threshold)
|
||||||
|
|
||||||
|
intersection = np.logical_and(pred_binary, true_binary).sum()
|
||||||
|
union = np.logical_or(pred_binary, true_binary).sum()
|
||||||
|
|
||||||
|
if union == 0:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
return intersection / union
|
||||||
46
app/__init__.py
Normal file
46
app/__init__.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from app.api import weather
|
||||||
|
from flask import Flask
|
||||||
|
import os
|
||||||
|
"""
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
File: __init__.py
|
||||||
|
Description:
|
||||||
|
Author: 소지안 프로
|
||||||
|
Created: 2026-02-02
|
||||||
|
Last Modified: 2026-02-02
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# 설정
|
||||||
|
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
||||||
|
|
||||||
|
# 라우트 등록
|
||||||
|
from app.routes import main
|
||||||
|
app.register_blueprint(main.bp)
|
||||||
|
|
||||||
|
# API 블루프린트 등록
|
||||||
|
from app.api import water_body_segmentation, flood, drought, vulnerability, terrain
|
||||||
|
app.register_blueprint(water_body_segmentation.bp)
|
||||||
|
app.register_blueprint(flood.bp)
|
||||||
|
app.register_blueprint(drought.bp)
|
||||||
|
app.register_blueprint(vulnerability.bp)
|
||||||
|
app.register_blueprint(terrain.bp)
|
||||||
|
app.register_blueprint(weather.bp)
|
||||||
|
|
||||||
|
# 기상청 예보: 매일 밤 12시(KST)에 API 호출하여 캐시 갱신
|
||||||
|
if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
scheduler = BackgroundScheduler(timezone='Asia/Seoul')
|
||||||
|
scheduler.add_job(
|
||||||
|
weather.fetch_and_cache_forecast,
|
||||||
|
'cron',
|
||||||
|
hour=0,
|
||||||
|
minute=0,
|
||||||
|
id='kma_midnight_fetch'
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
return app
|
||||||
567
app/api/drought.py
Normal file
567
app/api/drought.py
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import base64
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
import geopandas as gpd
|
||||||
|
from flask import Blueprint, jsonify, request, current_app
|
||||||
|
|
||||||
|
bp = Blueprint('drought', __name__, url_prefix='/api/drought')
|
||||||
|
|
||||||
|
# SHP 렌더링 임시 JPG 경로
|
||||||
|
_SHP_JPG_DIR = None
|
||||||
|
|
||||||
|
def _get_shp_jpg_dir():
|
||||||
|
global _SHP_JPG_DIR
|
||||||
|
if _SHP_JPG_DIR is None:
|
||||||
|
_SHP_JPG_DIR = os.path.join(current_app.static_folder, 'drought', 'tmp')
|
||||||
|
os.makedirs(_SHP_JPG_DIR, exist_ok=True)
|
||||||
|
return _SHP_JPG_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_old_jpg():
|
||||||
|
"""이전 렌더링된 JPG 파일 삭제"""
|
||||||
|
jpg_dir = _get_shp_jpg_dir()
|
||||||
|
for f in glob.glob(os.path.join(jpg_dir, 'drought_shp_*.jpg')):
|
||||||
|
try:
|
||||||
|
os.remove(f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _point_to_grade(point):
|
||||||
|
"""point 값(0~1)을 4등급으로 변환"""
|
||||||
|
if point <= 0.25:
|
||||||
|
return '관심'
|
||||||
|
elif point <= 0.50:
|
||||||
|
return '주의'
|
||||||
|
elif point <= 0.75:
|
||||||
|
return '경계'
|
||||||
|
else:
|
||||||
|
return '심각'
|
||||||
|
|
||||||
|
|
||||||
|
# 등급별 색상 (관심→심각: 초록→빨강)
|
||||||
|
GRADE_COLORS = {
|
||||||
|
'관심': '#2ecc71',
|
||||||
|
'주의': '#f1c40f',
|
||||||
|
'경계': '#e67e22',
|
||||||
|
'심각': '#e74c3c',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_shp_to_base64(grid_size=500):
|
||||||
|
"""SHP + XLSX 매칭하여 point 등급별 색상으로 격자 렌더링. 이전 JPG는 삭제."""
|
||||||
|
import time
|
||||||
|
import pandas as pd
|
||||||
|
from matplotlib.patches import Patch
|
||||||
|
|
||||||
|
_cleanup_old_jpg()
|
||||||
|
|
||||||
|
base_dir = os.path.join(current_app.static_folder, 'drought', str(grid_size))
|
||||||
|
shp_path = os.path.join(base_dir, f'goheung_{grid_size}m_merge.shp')
|
||||||
|
xlsx_path = os.path.join(base_dir, f'goheung_{grid_size}m_center_lon_lat.xlsx')
|
||||||
|
|
||||||
|
if not os.path.exists(shp_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
gdf = gpd.read_file(shp_path)
|
||||||
|
|
||||||
|
# XLSX에서 point 값 읽어서 GRID_CD로 매칭
|
||||||
|
if os.path.exists(xlsx_path):
|
||||||
|
df = pd.read_excel(xlsx_path)
|
||||||
|
point_map = dict(zip(df['GRID_CD'], df['point']))
|
||||||
|
gdf['point'] = gdf['GRID_CD'].map(point_map).fillna(0.0)
|
||||||
|
else:
|
||||||
|
gdf['point'] = 0.0
|
||||||
|
|
||||||
|
# point → 등급 → 색상
|
||||||
|
gdf['grade'] = gdf['point'].apply(_point_to_grade)
|
||||||
|
gdf['color'] = gdf['grade'].map(GRADE_COLORS)
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 10))
|
||||||
|
|
||||||
|
# 등급별로 그려서 범례 생성
|
||||||
|
for grade, color in GRADE_COLORS.items():
|
||||||
|
subset = gdf[gdf['grade'] == grade]
|
||||||
|
if not subset.empty:
|
||||||
|
subset.plot(ax=ax, color=color, edgecolor='#555', linewidth=0.3, alpha=0.8)
|
||||||
|
|
||||||
|
# 범례
|
||||||
|
legend_handles = [Patch(facecolor=c, edgecolor='#555', label=g) for g, c in GRADE_COLORS.items()]
|
||||||
|
ax.legend(handles=legend_handles, loc='lower right', fontsize=11, title='가뭄 등급', title_fontsize=12)
|
||||||
|
|
||||||
|
ax.set_axis_off()
|
||||||
|
plt.tight_layout(pad=0)
|
||||||
|
|
||||||
|
# JPG 파일로 저장
|
||||||
|
jpg_dir = _get_shp_jpg_dir()
|
||||||
|
jpg_name = f'drought_shp_{int(time.time() * 1000)}.jpg'
|
||||||
|
jpg_path = os.path.join(jpg_dir, jpg_name)
|
||||||
|
fig.savefig(jpg_path, format='jpg', dpi=150, bbox_inches='tight', pad_inches=0)
|
||||||
|
|
||||||
|
# base64 반환
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='jpg', dpi=150, bbox_inches='tight', pad_inches=0)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/shp-render', methods=['GET'])
|
||||||
|
def shp_render():
|
||||||
|
"""SHP 파일을 JPG로 렌더링하여 반환"""
|
||||||
|
grid_size = request.args.get('grid', 500, type=int)
|
||||||
|
try:
|
||||||
|
image = render_shp_to_base64(grid_size)
|
||||||
|
if image is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'SHP 파일을 찾을 수 없습니다.'})
|
||||||
|
return jsonify({'status': 'success', 'image': image})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
def generate_plot():
|
||||||
|
"""가뭄 예측 샘플 플롯 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 6))
|
||||||
|
|
||||||
|
# 샘플 데이터 (실제 데이터로 교체 필요)
|
||||||
|
np.random.seed(456)
|
||||||
|
months = ['1월', '2월', '3월', '4월', '5월', '6월',
|
||||||
|
'7월', '8월', '9월', '10월', '11월', '12월']
|
||||||
|
drought_index = np.random.uniform(-2, 2, 12)
|
||||||
|
|
||||||
|
colors = ['brown' if x < 0 else 'green' for x in drought_index]
|
||||||
|
ax.bar(months, drought_index, color=colors, alpha=0.7)
|
||||||
|
ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
|
||||||
|
ax.set_title('가뭄 예측 결과 (월별 가뭄 지수)', fontsize=14)
|
||||||
|
ax.set_xlabel('월')
|
||||||
|
ax.set_ylabel('가뭄 지수')
|
||||||
|
ax.grid(True, alpha=0.3, axis='y')
|
||||||
|
|
||||||
|
# 이미지를 base64로 변환
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/predict', methods=['GET'])
|
||||||
|
def predict():
|
||||||
|
"""가뭄 예측 결과 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_plot()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': '가뭄 예측 완료',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def generate_simulation_plot():
|
||||||
|
"""가뭄 시뮬레이션 플롯 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 6))
|
||||||
|
|
||||||
|
np.random.seed(333)
|
||||||
|
weeks = np.arange(1, 53)
|
||||||
|
drought_severity = np.sin(weeks / 10) * 1.5 + np.random.normal(0, 0.2, 52)
|
||||||
|
|
||||||
|
colors = ['brown' if x < 0 else 'forestgreen' for x in drought_severity]
|
||||||
|
ax.bar(weeks, drought_severity, color=colors, alpha=0.7, width=0.8)
|
||||||
|
ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
|
||||||
|
ax.axhline(y=-1, color='red', linestyle='--', label='심각 가뭄')
|
||||||
|
ax.set_title('연간 가뭄 시뮬레이션', fontsize=14)
|
||||||
|
ax.set_xlabel('주차')
|
||||||
|
ax.set_ylabel('가뭄 심각도')
|
||||||
|
ax.legend()
|
||||||
|
ax.grid(True, alpha=0.3, axis='y')
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_waterlevel_plot():
|
||||||
|
"""가뭄 수위 곡선 플롯 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 6))
|
||||||
|
|
||||||
|
np.random.seed(444)
|
||||||
|
days = np.arange(1, 91)
|
||||||
|
reservoir_level = 80 - np.cumsum(np.abs(np.random.randn(90)) * 0.3)
|
||||||
|
reservoir_level = np.maximum(reservoir_level, 20)
|
||||||
|
|
||||||
|
ax.plot(days, reservoir_level, 'b-', linewidth=2)
|
||||||
|
ax.fill_between(days, 0, reservoir_level, alpha=0.3, color='blue')
|
||||||
|
ax.axhline(y=40, color='orange', linestyle='--', label='경고 수위')
|
||||||
|
ax.axhline(y=25, color='red', linestyle='--', label='위험 수위')
|
||||||
|
ax.set_title('저수지 수위 변화', fontsize=14)
|
||||||
|
ax.set_xlabel('일')
|
||||||
|
ax.set_ylabel('저수율 (%)')
|
||||||
|
ax.set_ylim(0, 100)
|
||||||
|
ax.legend()
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/simulation', methods=['GET'])
|
||||||
|
def simulation():
|
||||||
|
"""가뭄 시뮬레이션 결과 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_simulation_plot()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': '가뭄 시뮬레이션 완료',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/waterlevel', methods=['GET'])
|
||||||
|
def waterlevel():
|
||||||
|
"""가뭄 수위 곡선 결과 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_waterlevel_plot()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': '수위 곡선 조회 완료',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def generate_heatmap_image(size=(400, 300)):
|
||||||
|
"""가뭄 히트맵 이미지 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(size[0]/100, size[1]/100))
|
||||||
|
|
||||||
|
np.random.seed(72)
|
||||||
|
|
||||||
|
# 히트맵 데이터 생성 (가뭄 위험도)
|
||||||
|
x = np.linspace(0, 10, 100)
|
||||||
|
y = np.linspace(0, 8, 80)
|
||||||
|
X, Y = np.meshgrid(x, y)
|
||||||
|
|
||||||
|
# 다중 가우시안으로 히트맵 생성
|
||||||
|
Z = np.zeros_like(X)
|
||||||
|
# 중심 핫스팟 (심각 지역)
|
||||||
|
Z += 0.9 * np.exp(-((X-5)**2 + (Y-4)**2) / 3)
|
||||||
|
# 주변 핫스팟들
|
||||||
|
Z += 0.6 * np.exp(-((X-3)**2 + (Y-2)**2) / 2)
|
||||||
|
Z += 0.7 * np.exp(-((X-7)**2 + (Y-5)**2) / 2.5)
|
||||||
|
Z += 0.4 * np.exp(-((X-2)**2 + (Y-6)**2) / 1.5)
|
||||||
|
|
||||||
|
# 노이즈 추가
|
||||||
|
Z += np.random.rand(80, 100) * 0.1
|
||||||
|
|
||||||
|
# 커스텀 컬러맵 (녹색 -> 노랑 -> 주황 -> 빨강)
|
||||||
|
from matplotlib.colors import LinearSegmentedColormap
|
||||||
|
colors = ['#27ae60', '#f1c40f', '#e67e22', '#e74c3c']
|
||||||
|
cmap = LinearSegmentedColormap.from_list('drought', colors)
|
||||||
|
|
||||||
|
ax.imshow(Z, cmap=cmap, aspect='auto', alpha=0.8)
|
||||||
|
ax.axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout(pad=0)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', pad_inches=0, transparent=True)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_mini_heatmap_image():
|
||||||
|
"""미니 히트맵 이미지 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(2.6, 1.2))
|
||||||
|
|
||||||
|
np.random.seed(72)
|
||||||
|
|
||||||
|
x = np.linspace(0, 5, 50)
|
||||||
|
y = np.linspace(0, 3, 30)
|
||||||
|
X, Y = np.meshgrid(x, y)
|
||||||
|
|
||||||
|
Z = 0.8 * np.exp(-((X-2.5)**2 + (Y-1.5)**2) / 1.5)
|
||||||
|
Z += np.random.rand(30, 50) * 0.15
|
||||||
|
|
||||||
|
from matplotlib.colors import LinearSegmentedColormap
|
||||||
|
colors = ['#27ae60', '#f1c40f', '#e67e22', '#e74c3c']
|
||||||
|
cmap = LinearSegmentedColormap.from_list('drought', colors)
|
||||||
|
|
||||||
|
ax.imshow(Z, cmap=cmap, aspect='auto', alpha=0.85)
|
||||||
|
ax.axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout(pad=0)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', pad_inches=0, transparent=True)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_monthly_drought_chart():
|
||||||
|
"""월별 가뭄 지수 차트 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(4, 1.2))
|
||||||
|
|
||||||
|
np.random.seed(2025)
|
||||||
|
months = np.arange(1, 21)
|
||||||
|
|
||||||
|
# 2025년 데이터
|
||||||
|
drought_2025 = 25 + np.sin(months / 3) * 8 + np.random.rand(20) * 5
|
||||||
|
# 2024년 데이터
|
||||||
|
drought_2024 = 30 + np.sin(months / 3 + 0.5) * 6 + np.random.rand(20) * 4
|
||||||
|
|
||||||
|
ax.plot(months, drought_2025, 'b-', linewidth=1.5, label='2025')
|
||||||
|
ax.plot(months, drought_2024, color='#e67e22', linewidth=1.5, linestyle='--', label='2024')
|
||||||
|
|
||||||
|
ax.fill_between(months, drought_2025, alpha=0.2, color='blue')
|
||||||
|
|
||||||
|
ax.set_xlim(1, 20)
|
||||||
|
ax.set_ylim(15, 45)
|
||||||
|
ax.set_xticks([1, 8, 9, 12, 15, 20])
|
||||||
|
ax.set_xticklabels(['1', '8', '9', '12', '15', '20'], fontsize=7)
|
||||||
|
ax.tick_params(axis='y', labelsize=7)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
plt.tight_layout(pad=0.5)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', transparent=True)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/monitoring-heatmap', methods=['GET'])
|
||||||
|
def monitoring_heatmap():
|
||||||
|
"""가뭄 모니터링 히트맵 이미지 반환"""
|
||||||
|
try:
|
||||||
|
heatmap_image = generate_heatmap_image()
|
||||||
|
mini_heatmap_image = generate_mini_heatmap_image()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'heatmap_image': heatmap_image,
|
||||||
|
'mini_heatmap_image': mini_heatmap_image,
|
||||||
|
'risk_score': 72,
|
||||||
|
'risk_level': '심각'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/monthly-chart', methods=['GET'])
|
||||||
|
def monthly_chart():
|
||||||
|
"""월별 가뭄 지수 차트 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_monthly_drought_chart()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def generate_spi_chart(period='month'):
|
||||||
|
"""SPI (표준강수지수) 시계열 그래프 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 5))
|
||||||
|
|
||||||
|
np.random.seed(2025)
|
||||||
|
|
||||||
|
if period == 'month':
|
||||||
|
days = np.arange(1, 31)
|
||||||
|
xlabel = '일'
|
||||||
|
title = '최근 1개월 SPI 변화'
|
||||||
|
elif period == 'quarter':
|
||||||
|
days = np.arange(1, 91)
|
||||||
|
xlabel = '일'
|
||||||
|
title = '최근 3개월 SPI 변화'
|
||||||
|
else: # year
|
||||||
|
days = np.arange(1, 366)
|
||||||
|
xlabel = '일'
|
||||||
|
title = '최근 1년 SPI 변화'
|
||||||
|
|
||||||
|
# SPI 값 생성 (-3 ~ 3 범위)
|
||||||
|
spi_values = np.sin(days / 15) * 1.2 + np.random.normal(0, 0.3, len(days))
|
||||||
|
spi_values = np.clip(spi_values, -3, 3)
|
||||||
|
|
||||||
|
# 색상별 영역 표시
|
||||||
|
ax.axhspan(-3, -2, alpha=0.2, color='darkred', label='극심한 가뭄')
|
||||||
|
ax.axhspan(-2, -1.5, alpha=0.2, color='red', label='심한 가뭄')
|
||||||
|
ax.axhspan(-1.5, -1, alpha=0.2, color='orange', label='중간 가뭄')
|
||||||
|
ax.axhspan(-1, 0, alpha=0.2, color='yellow', label='약한 가뭄')
|
||||||
|
ax.axhspan(0, 3, alpha=0.1, color='green', label='정상/습윤')
|
||||||
|
|
||||||
|
ax.plot(days, spi_values, 'b-', linewidth=1.5)
|
||||||
|
ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
|
||||||
|
|
||||||
|
ax.set_title(title, fontsize=14)
|
||||||
|
ax.set_xlabel(xlabel)
|
||||||
|
ax.set_ylabel('SPI 값')
|
||||||
|
ax.set_ylim(-3, 3)
|
||||||
|
ax.legend(loc='upper right', fontsize=8)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
# 통계 계산
|
||||||
|
current = round(spi_values[-1], 2)
|
||||||
|
avg = round(np.mean(spi_values), 2)
|
||||||
|
min_val = round(np.min(spi_values), 2)
|
||||||
|
|
||||||
|
# 가뭄 등급 결정
|
||||||
|
if current <= -2:
|
||||||
|
grade = '극심한 가뭄'
|
||||||
|
elif current <= -1.5:
|
||||||
|
grade = '심한 가뭄'
|
||||||
|
elif current <= -1:
|
||||||
|
grade = '중간 가뭄'
|
||||||
|
elif current <= 0:
|
||||||
|
grade = '약한 가뭄'
|
||||||
|
else:
|
||||||
|
grade = '정상'
|
||||||
|
|
||||||
|
return image_base64, {'current': current, 'avg': avg, 'min': min_val, 'grade': grade}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_vhi_chart(period='month'):
|
||||||
|
"""VHI (식생건강지수) 시계열 그래프 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 5))
|
||||||
|
|
||||||
|
np.random.seed(2026)
|
||||||
|
|
||||||
|
if period == 'month':
|
||||||
|
days = np.arange(1, 31)
|
||||||
|
xlabel = '일'
|
||||||
|
title = '최근 1개월 VHI 변화'
|
||||||
|
elif period == 'quarter':
|
||||||
|
days = np.arange(1, 91)
|
||||||
|
xlabel = '일'
|
||||||
|
title = '최근 3개월 VHI 변화'
|
||||||
|
else: # year
|
||||||
|
days = np.arange(1, 366)
|
||||||
|
xlabel = '일'
|
||||||
|
title = '최근 1년 VHI 변화'
|
||||||
|
|
||||||
|
# VHI 값 생성 (0 ~ 100 범위)
|
||||||
|
vhi_values = 50 + np.sin(days / 20) * 25 + np.random.normal(0, 5, len(days))
|
||||||
|
vhi_values = np.clip(vhi_values, 0, 100)
|
||||||
|
|
||||||
|
# 색상별 영역 표시
|
||||||
|
ax.axhspan(0, 10, alpha=0.2, color='darkred', label='극심한 가뭄')
|
||||||
|
ax.axhspan(10, 20, alpha=0.2, color='red', label='심한 가뭄')
|
||||||
|
ax.axhspan(20, 30, alpha=0.2, color='orange', label='중간 가뭄')
|
||||||
|
ax.axhspan(30, 40, alpha=0.2, color='yellow', label='약한 가뭄')
|
||||||
|
ax.axhspan(40, 100, alpha=0.1, color='green', label='정상')
|
||||||
|
|
||||||
|
ax.plot(days, vhi_values, 'g-', linewidth=1.5)
|
||||||
|
|
||||||
|
ax.set_title(title, fontsize=14)
|
||||||
|
ax.set_xlabel(xlabel)
|
||||||
|
ax.set_ylabel('VHI 값')
|
||||||
|
ax.set_ylim(0, 100)
|
||||||
|
ax.legend(loc='upper right', fontsize=8)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
# 통계 계산
|
||||||
|
current = round(vhi_values[-1], 1)
|
||||||
|
avg = round(np.mean(vhi_values), 1)
|
||||||
|
min_val = round(np.min(vhi_values), 1)
|
||||||
|
|
||||||
|
# 가뭄 등급 결정
|
||||||
|
if current <= 10:
|
||||||
|
grade = '극심한 가뭄'
|
||||||
|
elif current <= 20:
|
||||||
|
grade = '심한 가뭄'
|
||||||
|
elif current <= 30:
|
||||||
|
grade = '중간 가뭄'
|
||||||
|
elif current <= 40:
|
||||||
|
grade = '약한 가뭄'
|
||||||
|
else:
|
||||||
|
grade = '정상'
|
||||||
|
|
||||||
|
return image_base64, {'current': current, 'avg': avg, 'min': min_val, 'grade': grade}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/graph', methods=['GET'])
|
||||||
|
def drought_graph():
|
||||||
|
"""가뭄 지표 그래프 반환"""
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
graph_type = request.args.get('type', 'spi')
|
||||||
|
period = request.args.get('period', 'month')
|
||||||
|
|
||||||
|
try:
|
||||||
|
if graph_type == 'spi':
|
||||||
|
image, stats = generate_spi_chart(period)
|
||||||
|
else: # vhi
|
||||||
|
image, stats = generate_vhi_chart(period)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'image': image,
|
||||||
|
'stats': stats
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
443
app/api/flood.py
Normal file
443
app/api/flood.py
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
|
bp = Blueprint('flood', __name__, url_prefix='/api/flood')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_plot():
|
||||||
|
"""침수 예측 샘플 플롯 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 6))
|
||||||
|
|
||||||
|
# 샘플 데이터 (실제 데이터로 교체 필요)
|
||||||
|
np.random.seed(123)
|
||||||
|
data = np.random.rand(10, 10)
|
||||||
|
|
||||||
|
im = ax.imshow(data, cmap='Blues', interpolation='nearest')
|
||||||
|
ax.set_title('침수 예측 결과', fontsize=14)
|
||||||
|
ax.set_xlabel('X 좌표')
|
||||||
|
ax.set_ylabel('Y 좌표')
|
||||||
|
fig.colorbar(im, ax=ax, label='침수 확률')
|
||||||
|
|
||||||
|
# 이미지를 base64로 변환
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/predict', methods=['GET'])
|
||||||
|
def predict():
|
||||||
|
"""침수 예측 결과 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_plot()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': '침수 예측 완료',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def generate_simulation_plot():
|
||||||
|
"""침수 시뮬레이션 플롯 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 6))
|
||||||
|
|
||||||
|
np.random.seed(111)
|
||||||
|
time = np.arange(0, 48, 1)
|
||||||
|
flood_level = np.sin(time / 6) * 2 + np.random.normal(0, 0.3, 48) + 3
|
||||||
|
|
||||||
|
ax.plot(time, flood_level, 'b-', linewidth=2, label='침수 수위')
|
||||||
|
ax.fill_between(time, 0, flood_level, alpha=0.3, color='blue')
|
||||||
|
ax.axhline(y=4, color='red', linestyle='--', label='위험 수위')
|
||||||
|
ax.set_title('침수 시뮬레이션 결과', fontsize=14)
|
||||||
|
ax.set_xlabel('시간 (시)')
|
||||||
|
ax.set_ylabel('수위 (m)')
|
||||||
|
ax.legend()
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_waterlevel_plot():
|
||||||
|
"""수위 곡선 플롯 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 6))
|
||||||
|
|
||||||
|
np.random.seed(222)
|
||||||
|
days = np.arange(1, 31)
|
||||||
|
water_level = np.cumsum(np.random.randn(30)) + 50
|
||||||
|
|
||||||
|
ax.plot(days, water_level, 'g-', linewidth=2, marker='o', markersize=4)
|
||||||
|
ax.axhline(y=55, color='orange', linestyle='--', label='경고 수위')
|
||||||
|
ax.axhline(y=60, color='red', linestyle='--', label='위험 수위')
|
||||||
|
ax.set_title('월간 수위 곡선', fontsize=14)
|
||||||
|
ax.set_xlabel('일')
|
||||||
|
ax.set_ylabel('수위 (m)')
|
||||||
|
ax.legend()
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/simulation', methods=['GET'])
|
||||||
|
def simulation():
|
||||||
|
"""침수 시뮬레이션 결과 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_simulation_plot()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': '침수 시뮬레이션 완료',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/waterlevel', methods=['GET'])
|
||||||
|
def waterlevel():
|
||||||
|
"""수위 곡선 결과 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_waterlevel_plot()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': '수위 곡선 조회 완료',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def generate_flood_heatmap_image(size=(400, 300)):
|
||||||
|
"""침수 히트맵 이미지 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(size[0]/100, size[1]/100))
|
||||||
|
|
||||||
|
np.random.seed(82)
|
||||||
|
|
||||||
|
# 히트맵 데이터 생성 (침수 위험도)
|
||||||
|
x = np.linspace(0, 10, 100)
|
||||||
|
y = np.linspace(0, 8, 80)
|
||||||
|
X, Y = np.meshgrid(x, y)
|
||||||
|
|
||||||
|
# 다중 가우시안으로 히트맵 생성
|
||||||
|
Z = np.zeros_like(X)
|
||||||
|
# 중심 핫스팟 (심각 지역)
|
||||||
|
Z += 0.85 * np.exp(-((X-4)**2 + (Y-3)**2) / 2.5)
|
||||||
|
# 주변 핫스팟들
|
||||||
|
Z += 0.65 * np.exp(-((X-7)**2 + (Y-5)**2) / 2)
|
||||||
|
Z += 0.55 * np.exp(-((X-2)**2 + (Y-6)**2) / 1.8)
|
||||||
|
Z += 0.45 * np.exp(-((X-8)**2 + (Y-2)**2) / 1.5)
|
||||||
|
|
||||||
|
# 노이즈 추가
|
||||||
|
Z += np.random.rand(80, 100) * 0.1
|
||||||
|
|
||||||
|
# 커스텀 컬러맵 (녹색 -> 노랑 -> 주황 -> 파랑)
|
||||||
|
from matplotlib.colors import LinearSegmentedColormap
|
||||||
|
colors = ['#27ae60', '#f1c40f', '#3498db', '#2980b9']
|
||||||
|
cmap = LinearSegmentedColormap.from_list('flood', colors)
|
||||||
|
|
||||||
|
ax.imshow(Z, cmap=cmap, aspect='auto', alpha=0.8)
|
||||||
|
ax.axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout(pad=0)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', pad_inches=0, transparent=True)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_flood_mini_heatmap_image():
|
||||||
|
"""미니 침수 히트맵 이미지 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(2.6, 1.2))
|
||||||
|
|
||||||
|
np.random.seed(82)
|
||||||
|
|
||||||
|
x = np.linspace(0, 5, 50)
|
||||||
|
y = np.linspace(0, 3, 30)
|
||||||
|
X, Y = np.meshgrid(x, y)
|
||||||
|
|
||||||
|
Z = 0.75 * np.exp(-((X-2.5)**2 + (Y-1.5)**2) / 1.5)
|
||||||
|
Z += np.random.rand(30, 50) * 0.15
|
||||||
|
|
||||||
|
from matplotlib.colors import LinearSegmentedColormap
|
||||||
|
colors = ['#27ae60', '#f1c40f', '#3498db', '#2980b9']
|
||||||
|
cmap = LinearSegmentedColormap.from_list('flood', colors)
|
||||||
|
|
||||||
|
ax.imshow(Z, cmap=cmap, aspect='auto', alpha=0.85)
|
||||||
|
ax.axis('off')
|
||||||
|
|
||||||
|
plt.tight_layout(pad=0)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', pad_inches=0, transparent=True)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_monthly_rainfall_chart():
|
||||||
|
"""월별 강우량 차트 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(4, 1.2))
|
||||||
|
|
||||||
|
np.random.seed(2025)
|
||||||
|
months = np.arange(1, 21)
|
||||||
|
|
||||||
|
# 2025년 데이터
|
||||||
|
rainfall_2025 = 45 + np.sin(months / 3) * 15 + np.random.rand(20) * 10
|
||||||
|
# 2024년 데이터
|
||||||
|
rainfall_2024 = 40 + np.sin(months / 3 + 0.5) * 12 + np.random.rand(20) * 8
|
||||||
|
|
||||||
|
ax.plot(months, rainfall_2025, 'b-', linewidth=1.5, label='2025')
|
||||||
|
ax.plot(months, rainfall_2024, color='#3498db', linewidth=1.5, linestyle='--', label='2024')
|
||||||
|
|
||||||
|
ax.fill_between(months, rainfall_2025, alpha=0.2, color='blue')
|
||||||
|
|
||||||
|
ax.set_xlim(1, 20)
|
||||||
|
ax.set_ylim(20, 80)
|
||||||
|
ax.set_xticks([1, 8, 9, 12, 15, 20])
|
||||||
|
ax.set_xticklabels(['1', '8', '9', '12', '15', '20'], fontsize=7)
|
||||||
|
ax.tick_params(axis='y', labelsize=7)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
plt.tight_layout(pad=0.5)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', transparent=True)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/monitoring-heatmap', methods=['GET'])
|
||||||
|
def monitoring_heatmap():
|
||||||
|
"""침수 모니터링 히트맵 이미지 반환"""
|
||||||
|
try:
|
||||||
|
heatmap_image = generate_flood_heatmap_image()
|
||||||
|
mini_heatmap_image = generate_flood_mini_heatmap_image()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'heatmap_image': heatmap_image,
|
||||||
|
'mini_heatmap_image': mini_heatmap_image,
|
||||||
|
'risk_score': 65,
|
||||||
|
'risk_level': '경고'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/monthly-chart', methods=['GET'])
|
||||||
|
def monthly_chart():
|
||||||
|
"""월별 강우량 차트 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_monthly_rainfall_chart()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def generate_hourly_waterlevel_chart(period='day'):
|
||||||
|
"""시간별 수위 변화 차트 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(12, 5))
|
||||||
|
|
||||||
|
np.random.seed(2025)
|
||||||
|
|
||||||
|
if period == 'day':
|
||||||
|
x = np.arange(0, 24)
|
||||||
|
x_labels = [f'{i}시' for i in range(24)]
|
||||||
|
elif period == 'week':
|
||||||
|
x = np.arange(0, 7*24, 6)
|
||||||
|
x_labels = ['월', '화', '수', '목', '금', '토', '일']
|
||||||
|
else:
|
||||||
|
x = np.arange(0, 30)
|
||||||
|
x_labels = [f'{i+1}일' for i in range(30)]
|
||||||
|
|
||||||
|
base_level = 2.0 + np.sin(x / 6) * 0.5
|
||||||
|
water_level = base_level + np.random.normal(0, 0.2, len(x))
|
||||||
|
water_level = np.maximum(water_level, 0.5)
|
||||||
|
|
||||||
|
ax.plot(x, water_level, 'b-', linewidth=2, marker='o', markersize=4, label='실측 수위')
|
||||||
|
ax.fill_between(x, 0, water_level, alpha=0.3, color='blue')
|
||||||
|
ax.axhline(y=3.5, color='orange', linestyle='--', linewidth=2, label='주의 수위')
|
||||||
|
ax.axhline(y=4.0, color='red', linestyle='--', linewidth=2, label='경고 수위')
|
||||||
|
|
||||||
|
ax.set_xlabel('시간' if period == 'day' else '기간', fontsize=11)
|
||||||
|
ax.set_ylabel('수위 (m)', fontsize=11)
|
||||||
|
ax.set_title('시간별 수위 변화', fontsize=14, fontweight='bold')
|
||||||
|
ax.legend(loc='upper right')
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.set_ylim(0, 5)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64, {
|
||||||
|
'current': f'{water_level[-1]:.1f}m',
|
||||||
|
'avg': f'{np.mean(water_level):.1f}m',
|
||||||
|
'max': f'{np.max(water_level):.1f}m'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reservoir_chart(period='day'):
|
||||||
|
"""저수율 변화 차트 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(12, 5))
|
||||||
|
|
||||||
|
np.random.seed(2026)
|
||||||
|
|
||||||
|
if period == 'day':
|
||||||
|
x = np.arange(0, 24)
|
||||||
|
elif period == 'week':
|
||||||
|
x = np.arange(0, 7)
|
||||||
|
else:
|
||||||
|
x = np.arange(0, 30)
|
||||||
|
|
||||||
|
base_rate = 65 - np.cumsum(np.random.uniform(0.1, 0.5, len(x)))
|
||||||
|
reservoir_rate = np.maximum(base_rate, 30)
|
||||||
|
|
||||||
|
ax.plot(x, reservoir_rate, 'g-', linewidth=2, marker='s', markersize=5, label='저수율')
|
||||||
|
ax.fill_between(x, 0, reservoir_rate, alpha=0.3, color='green')
|
||||||
|
ax.axhline(y=50, color='orange', linestyle='--', linewidth=2, label='주의 수준')
|
||||||
|
ax.axhline(y=30, color='red', linestyle='--', linewidth=2, label='위험 수준')
|
||||||
|
|
||||||
|
ax.set_xlabel('시간' if period == 'day' else '기간', fontsize=11)
|
||||||
|
ax.set_ylabel('저수율 (%)', fontsize=11)
|
||||||
|
ax.set_title('저수율 변화 추이', fontsize=14, fontweight='bold')
|
||||||
|
ax.legend(loc='upper right')
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.set_ylim(0, 100)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64, {
|
||||||
|
'current': f'{reservoir_rate[-1]:.1f}%',
|
||||||
|
'avg': f'{np.mean(reservoir_rate):.1f}%',
|
||||||
|
'max': f'{np.max(reservoir_rate):.1f}%'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_river_level_chart(period='day'):
|
||||||
|
"""하천 수위 곡선 차트 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(12, 5))
|
||||||
|
|
||||||
|
np.random.seed(2027)
|
||||||
|
|
||||||
|
if period == 'day':
|
||||||
|
x = np.arange(0, 24)
|
||||||
|
elif period == 'week':
|
||||||
|
x = np.arange(0, 7*24, 6)
|
||||||
|
else:
|
||||||
|
x = np.arange(0, 30)
|
||||||
|
|
||||||
|
river_level = 1.5 + np.sin(x / 8) * 0.8 + np.random.normal(0, 0.15, len(x))
|
||||||
|
river_level = np.maximum(river_level, 0.3)
|
||||||
|
|
||||||
|
ax.plot(x, river_level, 'c-', linewidth=2, marker='d', markersize=4, label='하천 수위')
|
||||||
|
ax.fill_between(x, 0, river_level, alpha=0.3, color='cyan')
|
||||||
|
ax.axhline(y=2.5, color='orange', linestyle='--', linewidth=2, label='범람 주의')
|
||||||
|
ax.axhline(y=3.0, color='red', linestyle='--', linewidth=2, label='범람 경고')
|
||||||
|
|
||||||
|
ax.set_xlabel('시간' if period == 'day' else '기간', fontsize=11)
|
||||||
|
ax.set_ylabel('수위 (m)', fontsize=11)
|
||||||
|
ax.set_title('하천 수위 변화', fontsize=14, fontweight='bold')
|
||||||
|
ax.legend(loc='upper right')
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.set_ylim(0, 4)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64, {
|
||||||
|
'current': f'{river_level[-1]:.1f}m',
|
||||||
|
'avg': f'{np.mean(river_level):.1f}m',
|
||||||
|
'max': f'{np.max(river_level):.1f}m'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/graph', methods=['GET'])
|
||||||
|
def graph():
|
||||||
|
"""수위 곡선 그래프 반환"""
|
||||||
|
try:
|
||||||
|
from flask import request
|
||||||
|
graph_type = request.args.get('type', 'hourly')
|
||||||
|
period = request.args.get('period', 'day')
|
||||||
|
|
||||||
|
if graph_type == 'hourly':
|
||||||
|
image, stats = generate_hourly_waterlevel_chart(period)
|
||||||
|
elif graph_type == 'reservoir':
|
||||||
|
image, stats = generate_reservoir_chart(period)
|
||||||
|
elif graph_type == 'river':
|
||||||
|
image, stats = generate_river_level_chart(period)
|
||||||
|
else:
|
||||||
|
image, stats = generate_hourly_waterlevel_chart(period)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'image': image,
|
||||||
|
'stats': stats,
|
||||||
|
'warning_level': '4.0m'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
70
app/api/terrain.py
Normal file
70
app/api/terrain.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
|
bp = Blueprint('terrain', __name__, url_prefix='/api/terrain')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_simulation_chart():
|
||||||
|
"""시간별 수위 변화 차트 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(5, 2.5))
|
||||||
|
|
||||||
|
np.random.seed(int(np.random.random() * 1000))
|
||||||
|
days = list(range(1, 15))
|
||||||
|
water_level = 100 - np.cumsum(np.random.uniform(2, 8, 14))
|
||||||
|
water_level = np.maximum(water_level, 20)
|
||||||
|
|
||||||
|
ax.fill_between(days, water_level, alpha=0.3, color='#3498db')
|
||||||
|
ax.plot(days, water_level, 'b-', linewidth=2, marker='o', markersize=4)
|
||||||
|
|
||||||
|
ax.axhline(y=40, color='#e74c3c', linestyle='--', linewidth=1.5, label='경고 수위')
|
||||||
|
ax.set_xlim(1, 14)
|
||||||
|
ax.set_ylim(0, 100)
|
||||||
|
ax.set_xlabel('일자', fontsize=9)
|
||||||
|
ax.set_ylabel('수위 (%)', fontsize=9)
|
||||||
|
ax.legend(loc='upper right', fontsize=8)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', transparent=True)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/simulation-chart', methods=['GET'])
|
||||||
|
def simulation_chart():
|
||||||
|
"""시뮬레이션 차트 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_simulation_chart()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/simulation-data', methods=['GET'])
|
||||||
|
def simulation_data():
|
||||||
|
"""시뮬레이션 데이터 반환"""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'water_shortage': 3672,
|
||||||
|
'farm_damage': 2803,
|
||||||
|
'estimated_cost': 12.5,
|
||||||
|
'risk_level': '심각'
|
||||||
|
}
|
||||||
|
})
|
||||||
115
app/api/vulnerability.py
Normal file
115
app/api/vulnerability.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
|
bp = Blueprint('vulnerability', __name__, url_prefix='/api/vulnerability')
|
||||||
|
|
||||||
|
|
||||||
|
def generate_drought_index_chart():
|
||||||
|
"""월별 가뭄 지수 차트 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(6, 3))
|
||||||
|
|
||||||
|
np.random.seed(2025)
|
||||||
|
months = list(range(1, 21))
|
||||||
|
values_2025 = np.random.uniform(20, 35, 20)
|
||||||
|
values_2024 = np.random.uniform(15, 30, 20)
|
||||||
|
|
||||||
|
ax.plot(months, values_2025, 'b-', linewidth=2, label='2025')
|
||||||
|
ax.plot(months, values_2024, 'g-', linewidth=2, label='2024')
|
||||||
|
ax.set_xlim(1, 20)
|
||||||
|
ax.set_ylim(10, 40)
|
||||||
|
ax.set_xlabel('')
|
||||||
|
ax.set_ylabel('')
|
||||||
|
ax.legend(loc='lower right', fontsize=8)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', transparent=True)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
def generate_rainfall_chart():
|
||||||
|
"""강우량 차트 생성"""
|
||||||
|
fig, ax = plt.subplots(figsize=(6, 3))
|
||||||
|
|
||||||
|
categories = ['누적강우', '평년누적', '평년대비']
|
||||||
|
values = [75, 60, 85]
|
||||||
|
colors = ['#3498db', '#2ecc71', '#9b59b6']
|
||||||
|
|
||||||
|
bars = ax.barh(categories, values, color=colors, height=0.5)
|
||||||
|
ax.set_xlim(0, 100)
|
||||||
|
ax.set_xlabel('')
|
||||||
|
|
||||||
|
for bar, val in zip(bars, values):
|
||||||
|
ax.text(val + 2, bar.get_y() + bar.get_height()/2, f'{val}%',
|
||||||
|
va='center', fontsize=9)
|
||||||
|
|
||||||
|
ax.spines['top'].set_visible(False)
|
||||||
|
ax.spines['right'].set_visible(False)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', transparent=True)
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/drought-index', methods=['GET'])
|
||||||
|
def drought_index():
|
||||||
|
"""월별 가뭄 지수 차트 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_drought_index_chart()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/rainfall', methods=['GET'])
|
||||||
|
def rainfall():
|
||||||
|
"""강우량 차트 반환"""
|
||||||
|
try:
|
||||||
|
image = generate_rainfall_chart()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'image': image
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/summary', methods=['GET'])
|
||||||
|
def summary():
|
||||||
|
"""취약성 요약 데이터 반환"""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'risk_level': '심각',
|
||||||
|
'risk_score': 72,
|
||||||
|
'drought': {'value': 72, 'risk': 'High'},
|
||||||
|
'soil_moisture': {'value': 45, 'risk': 'Moderate'},
|
||||||
|
'reservoir': {'value': 27, 'risk': 'Low'}
|
||||||
|
}
|
||||||
|
})
|
||||||
367
app/api/water_body_segmentation.py
Normal file
367
app/api/water_body_segmentation.py
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
bp = Blueprint('water_body_segmentation', __name__, url_prefix='/api/water_body_segmentation')
|
||||||
|
|
||||||
|
# DeepLabV3 모델 (나중에 실제 모델로 교체)
|
||||||
|
# from app.AI_modules.DeepLabV3 import create_model, load_model
|
||||||
|
deeplabv3_model = None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_satellite_image(lat, lng):
|
||||||
|
"""위성 이미지 시뮬레이션 생성 (INPUT)"""
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 8))
|
||||||
|
|
||||||
|
np.random.seed(int((lat + lng) * 1000) % 10000)
|
||||||
|
|
||||||
|
# 배경 (녹지/농경지)
|
||||||
|
terrain = np.random.rand(100, 100) * 0.3 + 0.3
|
||||||
|
|
||||||
|
# 도로/경계선 추가
|
||||||
|
for i in range(5):
|
||||||
|
start = np.random.randint(0, 100)
|
||||||
|
terrain[start:start+2, :] = 0.9
|
||||||
|
terrain[:, start:start+2] = 0.9
|
||||||
|
|
||||||
|
# 물 영역 생성 (불규칙한 형태)
|
||||||
|
y, x = np.ogrid[:100, :100]
|
||||||
|
|
||||||
|
# 주요 수역 1 (호수/저수지)
|
||||||
|
center1_x, center1_y = 35 + np.random.randint(-5, 5), 50 + np.random.randint(-5, 5)
|
||||||
|
dist1 = np.sqrt((x - center1_x)**2 + (y - center1_y)**2)
|
||||||
|
water_mask1 = dist1 < (15 + np.random.rand(100, 100) * 5)
|
||||||
|
|
||||||
|
# 주요 수역 2 (강/하천)
|
||||||
|
river_path = 60 + np.sin(np.linspace(0, 4*np.pi, 100)) * 10
|
||||||
|
for i in range(100):
|
||||||
|
width = 3 + int(np.random.rand() * 3)
|
||||||
|
river_col = int(river_path[i])
|
||||||
|
if 0 <= river_col < 100:
|
||||||
|
terrain[i, max(0, river_col-width):min(100, river_col+width)] = 0.2
|
||||||
|
|
||||||
|
terrain[water_mask1] = 0.15 + np.random.rand(np.sum(water_mask1)) * 0.1
|
||||||
|
|
||||||
|
# 녹색 채널 강조 (위성 이미지처럼)
|
||||||
|
rgb_image = np.zeros((100, 100, 3))
|
||||||
|
rgb_image[:, :, 0] = terrain * 0.4 + 0.1 # R
|
||||||
|
rgb_image[:, :, 1] = terrain * 0.8 + 0.1 # G (녹지 강조)
|
||||||
|
rgb_image[:, :, 2] = terrain * 0.3 + 0.1 # B
|
||||||
|
|
||||||
|
# 물 영역은 파란색으로
|
||||||
|
water_full_mask = (terrain < 0.3)
|
||||||
|
rgb_image[water_full_mask, 0] = 0.1
|
||||||
|
rgb_image[water_full_mask, 1] = 0.3
|
||||||
|
rgb_image[water_full_mask, 2] = 0.5
|
||||||
|
|
||||||
|
ax.imshow(rgb_image, extent=[lng-0.05, lng+0.05, lat-0.05, lat+0.05])
|
||||||
|
ax.set_xlabel(f'경도 (Longitude)', fontsize=10)
|
||||||
|
ax.set_ylabel(f'위도 (Latitude)', fontsize=10)
|
||||||
|
ax.set_title(f'위성 이미지 - ({lat:.4f}, {lng:.4f})', fontsize=12)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64, water_full_mask
|
||||||
|
|
||||||
|
|
||||||
|
def generate_segmentation_image(lat, lng, water_mask):
|
||||||
|
"""Water Body 세그멘테이션 결과 생성 (OUTPUT)"""
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 8))
|
||||||
|
|
||||||
|
np.random.seed(int((lat + lng) * 1000) % 10000)
|
||||||
|
|
||||||
|
# 배경 (원본 이미지 흐리게)
|
||||||
|
terrain = np.random.rand(100, 100) * 0.3 + 0.3
|
||||||
|
|
||||||
|
# 도로/경계선
|
||||||
|
for i in range(5):
|
||||||
|
start = np.random.randint(0, 100)
|
||||||
|
terrain[start:start+2, :] = 0.9
|
||||||
|
terrain[:, start:start+2] = 0.9
|
||||||
|
|
||||||
|
# RGB 이미지 생성 (흐린 배경)
|
||||||
|
rgb_image = np.zeros((100, 100, 3))
|
||||||
|
rgb_image[:, :, 0] = terrain * 0.5 + 0.3
|
||||||
|
rgb_image[:, :, 1] = terrain * 0.6 + 0.3
|
||||||
|
rgb_image[:, :, 2] = terrain * 0.5 + 0.3
|
||||||
|
|
||||||
|
# Water Body를 밝은 파란색/보라색으로 하이라이트
|
||||||
|
rgb_image[water_mask, 0] = 0.4
|
||||||
|
rgb_image[water_mask, 1] = 0.5
|
||||||
|
rgb_image[water_mask, 2] = 0.95
|
||||||
|
|
||||||
|
# 경계선 추가
|
||||||
|
from scipy import ndimage
|
||||||
|
edges = ndimage.binary_dilation(water_mask) & ~water_mask
|
||||||
|
rgb_image[edges, 0] = 1.0
|
||||||
|
rgb_image[edges, 1] = 1.0
|
||||||
|
rgb_image[edges, 2] = 1.0
|
||||||
|
|
||||||
|
ax.imshow(rgb_image, extent=[lng-0.05, lng+0.05, lat-0.05, lat+0.05])
|
||||||
|
ax.set_xlabel(f'경도 (Longitude)', fontsize=10)
|
||||||
|
ax.set_ylabel(f'위도 (Latitude)', fontsize=10)
|
||||||
|
ax.set_title(f'Water Body 추출 결과', fontsize=12)
|
||||||
|
|
||||||
|
# 범례 추가
|
||||||
|
from matplotlib.patches import Patch
|
||||||
|
legend_elements = [Patch(facecolor='#6680f2', edgecolor='white', label='Water Body')]
|
||||||
|
ax.legend(handles=legend_elements, loc='upper right')
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
# 면적 계산 (시뮬레이션)
|
||||||
|
water_pixel_count = np.sum(water_mask)
|
||||||
|
total_pixels = water_mask.size
|
||||||
|
water_ratio = (water_pixel_count / total_pixels) * 100
|
||||||
|
|
||||||
|
return image_base64, water_pixel_count, water_ratio
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/extract', methods=['GET'])
|
||||||
|
def extract():
|
||||||
|
"""Water Body 추출 결과 반환"""
|
||||||
|
try:
|
||||||
|
lat = float(request.args.get('lat', 34.6047))
|
||||||
|
lng = float(request.args.get('lng', 127.2855))
|
||||||
|
radius = float(request.args.get('radius', 5))
|
||||||
|
|
||||||
|
# 입력 이미지 생성
|
||||||
|
input_image, water_mask = generate_satellite_image(lat, lng)
|
||||||
|
|
||||||
|
# 출력 이미지 생성
|
||||||
|
output_image, water_pixels, water_ratio = generate_segmentation_image(lat, lng, water_mask)
|
||||||
|
|
||||||
|
# 면적 계산 (시뮬레이션: 1 pixel = 약 100m²)
|
||||||
|
area_m2 = water_pixels * 100
|
||||||
|
water_body_count = np.random.randint(2, 8)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Water Body 추출 완료',
|
||||||
|
'input_image': input_image,
|
||||||
|
'output_image': output_image,
|
||||||
|
'data': {
|
||||||
|
'area': f'{area_m2:,} ㎡',
|
||||||
|
'ratio': f'{water_ratio:.1f}%',
|
||||||
|
'count': water_body_count,
|
||||||
|
'lat': lat,
|
||||||
|
'lng': lng
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'input_image': None,
|
||||||
|
'output_image': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_water_segmentation(image_array):
|
||||||
|
"""
|
||||||
|
Water Body 세그멘테이션 시뮬레이션
|
||||||
|
(실제 DeepLabV3 모델로 교체 예정)
|
||||||
|
"""
|
||||||
|
height, width = image_array.shape[:2]
|
||||||
|
|
||||||
|
# 파란색 계열 검출 (간단한 색상 기반 검출)
|
||||||
|
if len(image_array.shape) == 3:
|
||||||
|
# RGB -> HSV 변환 대신 간단한 파란색 검출
|
||||||
|
r = image_array[:, :, 0].astype(float)
|
||||||
|
g = image_array[:, :, 1].astype(float)
|
||||||
|
b = image_array[:, :, 2].astype(float)
|
||||||
|
|
||||||
|
# 파란색/청록색 영역 검출
|
||||||
|
blue_ratio = b / (r + g + b + 1e-6)
|
||||||
|
green_blue_diff = np.abs(g - b) / 255.0
|
||||||
|
|
||||||
|
# 물로 추정되는 영역 (파란색 비율이 높고, 전체적으로 어두운 영역)
|
||||||
|
brightness = (r + g + b) / 3.0
|
||||||
|
water_mask = (blue_ratio > 0.35) | ((brightness < 100) & (b > r))
|
||||||
|
|
||||||
|
# 노이즈 제거를 위한 morphological operation 시뮬레이션
|
||||||
|
from scipy import ndimage
|
||||||
|
water_mask = ndimage.binary_opening(water_mask, iterations=2)
|
||||||
|
water_mask = ndimage.binary_closing(water_mask, iterations=2)
|
||||||
|
else:
|
||||||
|
# 그레이스케일 이미지
|
||||||
|
water_mask = image_array < 100
|
||||||
|
|
||||||
|
return water_mask
|
||||||
|
|
||||||
|
|
||||||
|
def generate_prediction_overlay(original_image, water_mask):
|
||||||
|
"""
|
||||||
|
원본 이미지에 Water Body 예측 결과 오버레이
|
||||||
|
"""
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 8))
|
||||||
|
|
||||||
|
# 원본 이미지 표시
|
||||||
|
ax.imshow(original_image)
|
||||||
|
|
||||||
|
# Water Body 마스크를 반투명 파란색으로 오버레이
|
||||||
|
overlay = np.zeros((*water_mask.shape, 4))
|
||||||
|
overlay[water_mask, 0] = 0.2 # R
|
||||||
|
overlay[water_mask, 1] = 0.5 # G
|
||||||
|
overlay[water_mask, 2] = 1.0 # B
|
||||||
|
overlay[water_mask, 3] = 0.5 # Alpha
|
||||||
|
|
||||||
|
ax.imshow(overlay)
|
||||||
|
|
||||||
|
# 경계선 추가
|
||||||
|
from scipy import ndimage
|
||||||
|
edges = ndimage.binary_dilation(water_mask, iterations=2) & ~water_mask
|
||||||
|
edge_overlay = np.zeros((*water_mask.shape, 4))
|
||||||
|
edge_overlay[edges, 0] = 0.0
|
||||||
|
edge_overlay[edges, 1] = 1.0
|
||||||
|
edge_overlay[edges, 2] = 1.0
|
||||||
|
edge_overlay[edges, 3] = 1.0
|
||||||
|
ax.imshow(edge_overlay)
|
||||||
|
|
||||||
|
ax.axis('off')
|
||||||
|
ax.set_title('Water Body Detection Result', fontsize=14, fontweight='bold')
|
||||||
|
|
||||||
|
# 범례
|
||||||
|
from matplotlib.patches import Patch
|
||||||
|
legend_elements = [
|
||||||
|
Patch(facecolor='#3380ff', alpha=0.5, edgecolor='cyan', label='Water Body')
|
||||||
|
]
|
||||||
|
ax.legend(handles=legend_elements, loc='upper right', fontsize=10)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/predict', methods=['POST'])
|
||||||
|
def predict():
|
||||||
|
"""
|
||||||
|
업로드된 이미지에서 Water Body 추출
|
||||||
|
POST /api/water_body_segmentation/predict
|
||||||
|
Form Data: image (file)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 이미지 파일 확인
|
||||||
|
if 'image' not in request.files:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': '이미지 파일이 필요합니다.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
file = request.files['image']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': '파일이 선택되지 않았습니다.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 이미지 로드
|
||||||
|
image = Image.open(file.stream)
|
||||||
|
if image.mode != 'RGB':
|
||||||
|
image = image.convert('RGB')
|
||||||
|
|
||||||
|
# numpy 배열로 변환
|
||||||
|
image_array = np.array(image)
|
||||||
|
|
||||||
|
# Water Body 세그멘테이션 (현재는 시뮬레이션)
|
||||||
|
# TODO: 실제 DeepLabV3 모델로 교체
|
||||||
|
# if deeplabv3_model is not None:
|
||||||
|
# water_mask = run_deeplabv3_prediction(deeplabv3_model, image_array)
|
||||||
|
# else:
|
||||||
|
water_mask = simulate_water_segmentation(image_array)
|
||||||
|
|
||||||
|
# 결과 이미지 생성
|
||||||
|
output_image = generate_prediction_overlay(image_array, water_mask)
|
||||||
|
|
||||||
|
# 통계 계산
|
||||||
|
water_pixel_count = np.sum(water_mask)
|
||||||
|
total_pixels = water_mask.size
|
||||||
|
water_ratio = (water_pixel_count / total_pixels) * 100
|
||||||
|
|
||||||
|
# 연결된 영역 수 계산 (Water Body 개수)
|
||||||
|
from scipy import ndimage
|
||||||
|
labeled_array, num_features = ndimage.label(water_mask)
|
||||||
|
|
||||||
|
# 면적 추정 (픽셀 기반, 실제 스케일은 이미지 메타데이터 필요)
|
||||||
|
# 가정: 1 pixel = 약 1 m² (해상도에 따라 조정 필요)
|
||||||
|
pixel_area = 1.0 # m²/pixel
|
||||||
|
estimated_area = water_pixel_count * pixel_area
|
||||||
|
|
||||||
|
# 신뢰도 (시뮬레이션)
|
||||||
|
confidence = min(95.0, 70.0 + water_ratio * 0.5)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Water Body 추출 완료',
|
||||||
|
'output_image': output_image,
|
||||||
|
'data': {
|
||||||
|
'area': f'{estimated_area:,.0f} ㎡',
|
||||||
|
'ratio': f'{water_ratio:.1f}%',
|
||||||
|
'count': num_features,
|
||||||
|
'confidence': f'{confidence:.1f}%',
|
||||||
|
'pixels': int(water_pixel_count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/load-model', methods=['POST'])
|
||||||
|
def load_deeplabv3_model():
|
||||||
|
"""
|
||||||
|
DeepLabV3 모델 로드
|
||||||
|
POST /api/water_body_segmentation/load-model
|
||||||
|
JSON: { "model_path": "path/to/model.h5" }
|
||||||
|
"""
|
||||||
|
global deeplabv3_model
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
model_path = data.get('model_path')
|
||||||
|
|
||||||
|
if not model_path:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': '모델 경로가 필요합니다.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# TODO: 실제 모델 로드
|
||||||
|
# from app.AI_modules.DeepLabV3 import load_model
|
||||||
|
# deeplabv3_model = load_model(model_path)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'모델 로드 완료: {model_path}'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
126
app/api/weather.py
Normal file
126
app/api/weather.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
from flask import Blueprint, jsonify
|
||||||
|
import json
|
||||||
|
import jsonify
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
import time
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime
|
||||||
|
import requests
|
||||||
|
import app.data_config as data_config
|
||||||
|
|
||||||
|
"""
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
File: weather.py
|
||||||
|
Description: 고흥 날씨를 가져오는 기상청 API
|
||||||
|
Author: 소지안 프로
|
||||||
|
Created: 2026-02-02
|
||||||
|
Last Modified: 2026-02-02
|
||||||
|
-------------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
bp = Blueprint('kma', __name__, url_prefix='/api/kma')
|
||||||
|
|
||||||
|
# 캐시 저장 경로 (app 루트 기준 data/kma_cache.json)
|
||||||
|
CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data')
|
||||||
|
CACHE_FILE = os.path.join(CACHE_DIR, 'kma_cache.json')
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_cache_dir():
|
||||||
|
"""캐시 디렉토리 생성"""
|
||||||
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_weather_prediction_kma_api():
|
||||||
|
"""
|
||||||
|
기상청 API에서 육상예보 데이터 조회
|
||||||
|
"""
|
||||||
|
url = 'https://apihub.kma.go.kr/api/typ02/openApi/VilageFcstMsgService/getLandFcst'
|
||||||
|
params = {
|
||||||
|
'authKey': data_config.KMA_AUTH_KEY,
|
||||||
|
'dataType': 'JSON',
|
||||||
|
'regId': '11F20403',
|
||||||
|
'pageNo': '1',
|
||||||
|
'numOfRows': '30',
|
||||||
|
}
|
||||||
|
response = requests.get(url, params=params, timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
body = data.get('response', {}).get('body', {})
|
||||||
|
items = body.get('items', {}).get('item', [])
|
||||||
|
if not isinstance(items, list):
|
||||||
|
items = [items] if items else []
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for item in items:
|
||||||
|
rows.append({
|
||||||
|
'announceTime': item.get('announceTime', ''),
|
||||||
|
'numEf': item.get('numEf', ''),
|
||||||
|
'wf': item.get('wf', ''),
|
||||||
|
'rnSt': item.get('rnSt', ''),
|
||||||
|
'rnYn': item.get('rnYn', ''),
|
||||||
|
'ta': item.get('ta', ''),
|
||||||
|
'wfCd': item.get('wfCd', ''),
|
||||||
|
'wd1': item.get('wd1', ''),
|
||||||
|
'wd2': item.get('wd2', ''),
|
||||||
|
'wdTnd': item.get('wdTnd', ''),
|
||||||
|
'wsIt': item.get('wsIt', ''),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {'success': True, 'rows': rows, 'count': len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_forecast():
|
||||||
|
"""저장된 캐시에서 예보 데이터 로드"""
|
||||||
|
if not os.path.exists(CACHE_FILE):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_forecast_cache(data):
|
||||||
|
"""예보 데이터를 캐시 파일로 저장"""
|
||||||
|
_ensure_cache_dir()
|
||||||
|
payload = {
|
||||||
|
'cachedAt': datetime.now().isoformat(),
|
||||||
|
'data': data,
|
||||||
|
}
|
||||||
|
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_and_cache_forecast():
|
||||||
|
"""
|
||||||
|
기상청 API를 호출하여 예보를 가져와 서버에 캐시 저장.
|
||||||
|
매일 밤 12시 스케줄에서 호출됨.
|
||||||
|
TODO : 스케줄링 일시 변경 가능
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = _fetch_weather_prediction_kma_api()
|
||||||
|
if data.get('success'):
|
||||||
|
save_forecast_cache(data)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/forecast', methods=['GET'])
|
||||||
|
def get_forecast():
|
||||||
|
"""
|
||||||
|
기상청 육상예보 조회 - 캐시가 있으면 캐시 반환, 없으면 API 호출
|
||||||
|
"""
|
||||||
|
cached = load_cached_forecast()
|
||||||
|
if cached and cached.get('data'):
|
||||||
|
return jsonify(cached['data'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = _fetch_weather_prediction_kma_api()
|
||||||
|
if data.get('success'):
|
||||||
|
save_forecast_cache(data)
|
||||||
|
return jsonify(data)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
13
app/data_config.py
Normal file
13
app/data_config.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
#시계열 데이터 저장 경로
|
||||||
|
CSV_STORAGE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'time_series', 'csv_storage')
|
||||||
|
|
||||||
|
#기상청 API 인증키
|
||||||
|
KMA_AUTH_KEY = 'QxOIOdO9QDCTiDnTvcAwPw'
|
||||||
|
|
||||||
|
# InfluxDB 접속 정보
|
||||||
|
token = "OjJTvbZjJCC_hZjKNxxd7fycHZzYHM-YteD25FiH5-mMgv-bwa0Dd9KNIT_t2gJMHgneWz-adJBPL00I3khiqw=="
|
||||||
|
org = "goheung"
|
||||||
|
bucket = "drought"
|
||||||
|
url = "http://localhost:8086"
|
||||||
8
app/routes/main.py
Normal file
8
app/routes/main.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
2555
app/static/css/style.css
Normal file
2555
app/static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
4031
app/static/geojson/goheung_500m_grid.geojson
Normal file
4031
app/static/geojson/goheung_500m_grid.geojson
Normal file
File diff suppressed because one or more lines are too long
1036
app/static/js/main.js
Normal file
1036
app/static/js/main.js
Normal file
File diff suppressed because it is too large
Load Diff
235
app/static/js/terrain3d.js
Normal file
235
app/static/js/terrain3d.js
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
// 3D 지형 시각화 모듈
|
||||||
|
var Terrain3D = (function() {
|
||||||
|
var scene, camera, renderer, terrain, water;
|
||||||
|
var isInitialized = false;
|
||||||
|
var animationId = null;
|
||||||
|
|
||||||
|
// 지형 초기화
|
||||||
|
function init(containerId) {
|
||||||
|
var container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// 이미 초기화된 경우 리사이즈만 처리
|
||||||
|
if (isInitialized && renderer) {
|
||||||
|
onWindowResize(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scene 생성
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x1a1a2e);
|
||||||
|
|
||||||
|
// 카메라 설정
|
||||||
|
var width = container.clientWidth;
|
||||||
|
var height = container.clientHeight;
|
||||||
|
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
|
||||||
|
camera.position.set(0, 40, 60);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
// 렌더러 설정
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// 조명 추가
|
||||||
|
var ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
var directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
directionalLight.position.set(50, 100, 50);
|
||||||
|
scene.add(directionalLight);
|
||||||
|
|
||||||
|
// 지형 생성
|
||||||
|
createTerrain();
|
||||||
|
|
||||||
|
// 물 표면 생성
|
||||||
|
createWater();
|
||||||
|
|
||||||
|
// 마우스 컨트롤
|
||||||
|
setupControls(container);
|
||||||
|
|
||||||
|
// 리사이즈 이벤트
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
onWindowResize(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
isInitialized = true;
|
||||||
|
|
||||||
|
// 애니메이션 시작
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지형 생성
|
||||||
|
function createTerrain() {
|
||||||
|
var geometry = new THREE.PlaneGeometry(80, 80, 64, 64);
|
||||||
|
geometry.rotateX(-Math.PI / 2);
|
||||||
|
|
||||||
|
// 높이 데이터 생성 (Perlin noise 시뮬레이션)
|
||||||
|
var vertices = geometry.attributes.position.array;
|
||||||
|
for (var i = 0; i < vertices.length; i += 3) {
|
||||||
|
var x = vertices[i];
|
||||||
|
var z = vertices[i + 2];
|
||||||
|
|
||||||
|
// 여러 주파수의 노이즈 조합
|
||||||
|
var height = 0;
|
||||||
|
height += Math.sin(x * 0.1) * Math.cos(z * 0.1) * 8;
|
||||||
|
height += Math.sin(x * 0.2 + 1) * Math.cos(z * 0.15) * 4;
|
||||||
|
height += Math.sin(x * 0.05) * Math.cos(z * 0.08) * 6;
|
||||||
|
|
||||||
|
// 중앙에 분지(물이 고일 곳) 생성
|
||||||
|
var distFromCenter = Math.sqrt(x * x + z * z);
|
||||||
|
if (distFromCenter < 15) {
|
||||||
|
height = Math.min(height, -3 + distFromCenter * 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
vertices[i + 1] = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
|
||||||
|
// 높이에 따른 색상 적용
|
||||||
|
var colors = [];
|
||||||
|
for (var i = 0; i < vertices.length; i += 3) {
|
||||||
|
var height = vertices[i + 1];
|
||||||
|
var color = getColorForHeight(height);
|
||||||
|
colors.push(color.r, color.g, color.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
||||||
|
|
||||||
|
var material = new THREE.MeshLambertMaterial({
|
||||||
|
vertexColors: true,
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
});
|
||||||
|
|
||||||
|
terrain = new THREE.Mesh(geometry, material);
|
||||||
|
scene.add(terrain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 높이에 따른 색상 반환
|
||||||
|
function getColorForHeight(height) {
|
||||||
|
var color = new THREE.Color();
|
||||||
|
|
||||||
|
if (height < -2) {
|
||||||
|
// 낮은 지역 - 심각 (빨강)
|
||||||
|
color.setRGB(0.91, 0.30, 0.24);
|
||||||
|
} else if (height < 2) {
|
||||||
|
// 중간 낮음 - 높음 (주황)
|
||||||
|
color.setRGB(0.90, 0.49, 0.13);
|
||||||
|
} else if (height < 6) {
|
||||||
|
// 중간 - 보통 (노랑)
|
||||||
|
color.setRGB(0.95, 0.77, 0.06);
|
||||||
|
} else {
|
||||||
|
// 높은 지역 - 낮음 (초록)
|
||||||
|
color.setRGB(0.15, 0.68, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 물 표면 생성
|
||||||
|
function createWater() {
|
||||||
|
var waterGeometry = new THREE.CircleGeometry(12, 32);
|
||||||
|
waterGeometry.rotateX(-Math.PI / 2);
|
||||||
|
|
||||||
|
var waterMaterial = new THREE.MeshLambertMaterial({
|
||||||
|
color: 0x3498db,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.7
|
||||||
|
});
|
||||||
|
|
||||||
|
water = new THREE.Mesh(waterGeometry, waterMaterial);
|
||||||
|
water.position.y = -2;
|
||||||
|
scene.add(water);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마우스 컨트롤 설정
|
||||||
|
function setupControls(container) {
|
||||||
|
var isDragging = false;
|
||||||
|
var previousMousePosition = { x: 0, y: 0 };
|
||||||
|
var rotationSpeed = 0.005;
|
||||||
|
var targetRotationY = 0;
|
||||||
|
|
||||||
|
container.addEventListener('mousedown', function(e) {
|
||||||
|
isDragging = true;
|
||||||
|
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('mousemove', function(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
var deltaX = e.clientX - previousMousePosition.x;
|
||||||
|
targetRotationY += deltaX * rotationSpeed;
|
||||||
|
|
||||||
|
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('mouseup', function() {
|
||||||
|
isDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('mouseleave', function() {
|
||||||
|
isDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 마우스 휠 줌
|
||||||
|
container.addEventListener('wheel', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var zoomSpeed = 0.1;
|
||||||
|
camera.position.z += e.deltaY * zoomSpeed;
|
||||||
|
camera.position.z = Math.max(30, Math.min(100, camera.position.z));
|
||||||
|
camera.position.y = camera.position.z * 0.67;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 애니메이션에서 회전 적용
|
||||||
|
setInterval(function() {
|
||||||
|
if (terrain) {
|
||||||
|
terrain.rotation.y += (targetRotationY - terrain.rotation.y) * 0.1;
|
||||||
|
if (water) water.rotation.y = terrain.rotation.y;
|
||||||
|
}
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 윈도우 리사이즈 처리
|
||||||
|
function onWindowResize(container) {
|
||||||
|
if (!camera || !renderer) return;
|
||||||
|
|
||||||
|
var width = container.clientWidth;
|
||||||
|
var height = container.clientHeight;
|
||||||
|
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 애니메이션 루프
|
||||||
|
function animate() {
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
// 물 애니메이션
|
||||||
|
if (water) {
|
||||||
|
water.position.y = -2 + Math.sin(Date.now() * 0.001) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderer && scene && camera) {
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정리
|
||||||
|
function dispose() {
|
||||||
|
if (animationId) {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
}
|
||||||
|
if (renderer) {
|
||||||
|
renderer.dispose();
|
||||||
|
}
|
||||||
|
isInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
dispose: dispose
|
||||||
|
};
|
||||||
|
})();
|
||||||
737
app/templates/index.html
Normal file
737
app/templates/index.html
Normal file
@ -0,0 +1,737 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Flask App</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<h1>위성,드론 활용 농업재해 모니터링 시스템</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-container">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<ul class="menu-list">
|
||||||
|
<li><a href="#" class="menu-item active" data-page="intro">Intro</a></li>
|
||||||
|
<li><a href="#" class="menu-item" data-page="waterbody">Water Body 추출</a></li>
|
||||||
|
<li class="has-submenu">
|
||||||
|
<a href="#" class="menu-item" data-page="flood-monitoring">침수</a>
|
||||||
|
<ul class="submenu">
|
||||||
|
<li><a href="#" class="submenu-item" data-page="flood-monitoring">침수 모니터링</a></li>
|
||||||
|
<li><a href="#" class="submenu-item" data-page="flood-simulation">3D 시뮬레이션</a></li>
|
||||||
|
<li><a href="#" class="submenu-item" data-page="flood-waterlevel">수위 곡선</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="has-submenu">
|
||||||
|
<a href="#" class="menu-item" data-page="drought-monitoring">가뭄</a>
|
||||||
|
<ul class="submenu">
|
||||||
|
<li><a href="#" class="submenu-item" data-page="drought-monitoring">가뭄 모니터링</a></li>
|
||||||
|
<li><a href="#" class="submenu-item" data-page="drought-simulation">3D 시뮬레이션</a></li>
|
||||||
|
<li><a href="#" class="submenu-item" data-page="drought-waterlevel">시계열 변화 그래프</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><a href="#" class="menu-item" data-page="kma-test">고흥 단기예보</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<section class="content">
|
||||||
|
<!-- Intro -->
|
||||||
|
<div class="page-content active" id="intro">
|
||||||
|
<div class="intro-dashboard">
|
||||||
|
<!-- 상단: 지도 + 현재 상태 -->
|
||||||
|
<div class="intro-top">
|
||||||
|
<!-- 왼쪽: 지도 -->
|
||||||
|
<div class="intro-map-section">
|
||||||
|
<div class="intro-map-header">
|
||||||
|
<h3>고흥군 모니터링</h3>
|
||||||
|
<span class="live-badge">LIVE</span>
|
||||||
|
</div>
|
||||||
|
<div id="map-intro" class="intro-map"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 오른쪽: 현재 상태 요약 -->
|
||||||
|
<div class="intro-status-section">
|
||||||
|
<div class="current-status-card">
|
||||||
|
<div class="status-header">
|
||||||
|
<span class="status-location">고흥군</span>
|
||||||
|
<span class="status-date" id="current-datetime">2026.01.28 (화)</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-main">
|
||||||
|
<div class="status-icon safe">
|
||||||
|
<span class="icon-circle"></span>
|
||||||
|
</div>
|
||||||
|
<div class="status-text">
|
||||||
|
<span class="status-label">현재 상태</span>
|
||||||
|
<span class="status-value">정상</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">저수율</span>
|
||||||
|
<span class="detail-value">67%</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">토양수분</span>
|
||||||
|
<span class="detail-value">45%</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">누적강수</span>
|
||||||
|
<span class="detail-value">12.5mm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 날짜 선택 -->
|
||||||
|
<div class="date-selector">
|
||||||
|
<select class="date-select" id="select-year">
|
||||||
|
<option value="2026">2026년</option>
|
||||||
|
<option value="2025">2025년</option>
|
||||||
|
<option value="2024">2024년</option>
|
||||||
|
</select>
|
||||||
|
<select class="date-select" id="select-month">
|
||||||
|
<option value="1">1월</option>
|
||||||
|
<option value="2">2월</option>
|
||||||
|
<option value="3">3월</option>
|
||||||
|
<option value="4">4월</option>
|
||||||
|
<option value="5">5월</option>
|
||||||
|
<option value="6">6월</option>
|
||||||
|
<option value="7">7월</option>
|
||||||
|
<option value="8">8월</option>
|
||||||
|
<option value="9">9월</option>
|
||||||
|
<option value="10">10월</option>
|
||||||
|
<option value="11">11월</option>
|
||||||
|
<option value="12">12월</option>
|
||||||
|
</select>
|
||||||
|
<select class="date-select" id="select-day">
|
||||||
|
<option value="1">1일</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-search" id="btn-date-search">조회</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 하단: 예측 모니터링 카드 -->
|
||||||
|
<div class="intro-monitoring">
|
||||||
|
<!-- 침수 예측 카드 -->
|
||||||
|
<div class="monitoring-card flood">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon flood-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,20A6,6 0 0,1 6,14C6,10 12,3.25 12,3.25C12,3.25 18,10 18,14A6,6 0 0,1 12,20Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h4>침수 예측</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="probability-display">
|
||||||
|
<span class="prob-value" id="flood-prob">23</span>
|
||||||
|
<span class="prob-unit">%</span>
|
||||||
|
</div>
|
||||||
|
<div class="prob-bar">
|
||||||
|
<div class="prob-fill flood-fill" style="width: 23%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="prob-status safe">낮음</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="forecast-mini">
|
||||||
|
<div class="forecast-item">
|
||||||
|
<span class="fc-day">내일</span>
|
||||||
|
<span class="fc-value">25%</span>
|
||||||
|
</div>
|
||||||
|
<div class="forecast-item">
|
||||||
|
<span class="fc-day">모레</span>
|
||||||
|
<span class="fc-value">30%</span>
|
||||||
|
</div>
|
||||||
|
<div class="forecast-item">
|
||||||
|
<span class="fc-day">3일후</span>
|
||||||
|
<span class="fc-value">28%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 가뭄 예측 카드 -->
|
||||||
|
<div class="monitoring-card drought">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon drought-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,2L14.39,5.42C13.65,5.15 12.84,5 12,5C11.16,5 10.35,5.15 9.61,5.42L12,2M3.34,7L7.5,6.65C6.9,7.16 6.36,7.78 5.94,8.5C5.5,9.24 5.25,10 5.11,10.79L3.34,7M3.36,17L5.12,13.23C5.26,14 5.53,14.78 5.95,15.5C6.37,16.24 6.91,16.86 7.5,17.37L3.36,17M20.65,7L18.88,10.79C18.74,10 18.47,9.23 18.05,8.5C17.63,7.78 17.1,7.15 16.5,6.64L20.65,7M20.64,17L16.5,17.36C17.09,16.85 17.62,16.22 18.04,15.5C18.46,14.77 18.73,14 18.87,13.21L20.64,17M12,22L9.59,18.56C10.33,18.83 11.14,19 12,19C12.82,19 13.63,18.83 14.37,18.56L12,22Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h4>가뭄 예측</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="probability-display">
|
||||||
|
<span class="prob-value" id="drought-prob">58</span>
|
||||||
|
<span class="prob-unit">%</span>
|
||||||
|
</div>
|
||||||
|
<div class="prob-bar">
|
||||||
|
<div class="prob-fill drought-fill" style="width: 58%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="prob-status warning">주의</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="forecast-mini">
|
||||||
|
<div class="forecast-item">
|
||||||
|
<span class="fc-day">내일</span>
|
||||||
|
<span class="fc-value">60%</span>
|
||||||
|
</div>
|
||||||
|
<div class="forecast-item">
|
||||||
|
<span class="fc-day">모레</span>
|
||||||
|
<span class="fc-value">65%</span>
|
||||||
|
</div>
|
||||||
|
<div class="forecast-item">
|
||||||
|
<span class="fc-day">3일후</span>
|
||||||
|
<span class="fc-value">62%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 종합 위험도 카드 -->
|
||||||
|
<div class="monitoring-card overall">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon overall-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16"/></svg>
|
||||||
|
</div>
|
||||||
|
<h4>종합 위험도</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="risk-gauge">
|
||||||
|
<div class="gauge-circle">
|
||||||
|
<svg viewBox="0 0 36 36" class="gauge-svg">
|
||||||
|
<path class="gauge-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
|
||||||
|
<path class="gauge-fill warning" stroke-dasharray="45, 100" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
|
||||||
|
</svg>
|
||||||
|
<div class="gauge-text">
|
||||||
|
<span class="gauge-value">45</span>
|
||||||
|
<span class="gauge-label">보통</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="risk-legend">
|
||||||
|
<span class="legend-dot safe"></span> 안전
|
||||||
|
<span class="legend-dot warning"></span> 주의
|
||||||
|
<span class="legend-dot danger"></span> 위험
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Water Body 추출 -->
|
||||||
|
<div class="page-content" id="waterbody">
|
||||||
|
<div class="waterbody-page">
|
||||||
|
<div class="waterbody-header">
|
||||||
|
<h2>Water Body 추출</h2>
|
||||||
|
<p class="waterbody-desc">위성/드론 이미지에서 수체(Water Body)를 자동으로 검출합니다.</p>
|
||||||
|
</div>
|
||||||
|
<!-- 이미지 비교 영역 -->
|
||||||
|
<div class="waterbody-comparison">
|
||||||
|
<!-- 왼쪽: 입력 이미지 (Drag & Drop) -->
|
||||||
|
<div class="comparison-panel input-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">INPUT</span>
|
||||||
|
<span class="panel-subtitle">원본 이미지</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="dropzone" id="wb-dropzone">
|
||||||
|
<input type="file" id="wb-file-input" accept="image/*" hidden>
|
||||||
|
<div class="dropzone-content" id="wb-dropzone-content">
|
||||||
|
<div class="dropzone-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48">
|
||||||
|
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="dropzone-text">이미지를 드래그하여 놓거나</p>
|
||||||
|
<button class="btn-browse" id="btn-wb-browse">파일 선택</button>
|
||||||
|
<p class="dropzone-hint">JPG, PNG 형식 지원</p>
|
||||||
|
</div>
|
||||||
|
<div class="dropzone-preview" id="wb-preview" style="display: none;">
|
||||||
|
<img id="wb-input-image" class="preview-image" src="" alt="입력 이미지">
|
||||||
|
<button class="btn-remove-image" id="btn-wb-remove">X</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 중앙: 추출 버튼 -->
|
||||||
|
<div class="comparison-center">
|
||||||
|
<button class="btn-extract" id="btn-waterbody-extract" disabled>
|
||||||
|
<span class="btn-extract-text">추출</span>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||||
|
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="extract-loading" id="wb-extract-loading" style="display: none;">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>분석 중...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 오른쪽: 결과 이미지 -->
|
||||||
|
<div class="comparison-panel output-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">OUTPUT</span>
|
||||||
|
<span class="panel-subtitle">Water Body 검출 결과</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="result-container" id="wb-result-container">
|
||||||
|
<div class="result-placeholder" id="wb-result-placeholder">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48">
|
||||||
|
<path d="M12,20A6,6 0 0,1 6,14C6,10 12,3.25 12,3.25C12,3.25 18,10 18,14A6,6 0 0,1 12,20Z"/>
|
||||||
|
</svg>
|
||||||
|
<p>이미지를 업로드하고<br>추출 버튼을 클릭하세요</p>
|
||||||
|
</div>
|
||||||
|
<img id="wb-output-image" class="result-image" src="" alt="추출 결과" style="display: none;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 결과 정보 -->
|
||||||
|
<div class="waterbody-info" id="wb-result-info" style="display: none;">
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">Water Body 면적</span>
|
||||||
|
<span class="info-value" id="wb-area">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">전체 대비 비율</span>
|
||||||
|
<span class="info-value highlight" id="wb-ratio">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">검출 수역 수</span>
|
||||||
|
<span class="info-value" id="wb-count">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">신뢰도</span>
|
||||||
|
<span class="info-value" id="wb-confidence">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 침수 예측 - 3D 시뮬레이션 -->
|
||||||
|
<div class="page-content" id="flood-simulation">
|
||||||
|
<div class="empty-page-placeholder" style="display:flex;align-items:center;justify-content:center;height:100%;color:#aaa;font-size:1.2rem;">
|
||||||
|
<p>준비 중입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 침수 예측 - 수위 곡선 -->
|
||||||
|
<div class="page-content" id="flood-waterlevel">
|
||||||
|
<div class="waterlevel-page">
|
||||||
|
<div class="waterlevel-header">
|
||||||
|
<h2>침수 예측 - 수위 곡선</h2>
|
||||||
|
<div class="waterlevel-controls">
|
||||||
|
<div class="control-item">
|
||||||
|
<label>그래프 유형</label>
|
||||||
|
<select class="waterlevel-select" id="flood-graph-type">
|
||||||
|
<option value="hourly">시간별 수위</option>
|
||||||
|
<option value="reservoir">저수율 변화</option>
|
||||||
|
<option value="river">하천 수위</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-item">
|
||||||
|
<label>조회 기간</label>
|
||||||
|
<select class="waterlevel-select" id="flood-graph-period">
|
||||||
|
<option value="day">최근 24시간</option>
|
||||||
|
<option value="week">최근 7일</option>
|
||||||
|
<option value="month">최근 30일</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn-graph-load" id="btn-flood-graph-load">조회</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="waterlevel-content">
|
||||||
|
<div class="graph-container">
|
||||||
|
<div class="graph-loading" id="flood-graph-loading" style="display: none;">
|
||||||
|
<span>그래프 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
<img class="graph-image" id="flood-graph-image" src="" alt="수위 곡선 그래프">
|
||||||
|
<div class="graph-placeholder" id="flood-graph-placeholder">
|
||||||
|
<span>조회 버튼을 클릭하여 그래프를 확인하세요</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="graph-info">
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">현재 수위</span>
|
||||||
|
<span class="info-value" id="flood-current-level">2.4m</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">평균 수위</span>
|
||||||
|
<span class="info-value" id="flood-avg-level">1.8m</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">최고 수위</span>
|
||||||
|
<span class="info-value warning" id="flood-max-level">3.2m</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">경고 수위</span>
|
||||||
|
<span class="info-value danger" id="flood-warning-level">4.0m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 침수 예측 - 모니터링 -->
|
||||||
|
<div class="page-content" id="flood-monitoring">
|
||||||
|
<div class="flood-monitor-dashboard">
|
||||||
|
<!-- 메인 영역 (왼쪽) -->
|
||||||
|
<div class="flood-monitor-main">
|
||||||
|
<!-- 상단 필터 바 -->
|
||||||
|
<div class="flood-filter-bar">
|
||||||
|
<div class="flood-filter-group">
|
||||||
|
<label>침수지도</label>
|
||||||
|
<select class="flood-filter-select" id="flood-map-type">
|
||||||
|
<option value="all">전체</option>
|
||||||
|
<option value="river">하천</option>
|
||||||
|
<option value="urban">도심</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flood-filter-group">
|
||||||
|
<label>조회기간</label>
|
||||||
|
<select class="flood-filter-select" id="flood-period">
|
||||||
|
<option value="week">지난 일주일</option>
|
||||||
|
<option value="month">지난 한달</option>
|
||||||
|
<option value="quarter">지난 3개월</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flood-filter-group">
|
||||||
|
<label>지도유형</label>
|
||||||
|
<select class="flood-filter-select" id="flood-view-type">
|
||||||
|
<option value="normal">일반지도</option>
|
||||||
|
<option value="satellite">위성지도</option>
|
||||||
|
<option value="terrain">지형지도</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 히트맵 지도 영역 -->
|
||||||
|
<div class="flood-heatmap-container">
|
||||||
|
<div id="flood-heatmap" class="flood-heatmap"></div>
|
||||||
|
<!-- 히트맵 이미지 오버레이 -->
|
||||||
|
<img id="flood-heatmap-overlay" class="flood-heatmap-image" src="" alt="침수 히트맵">
|
||||||
|
<!-- 위험도 오버레이 -->
|
||||||
|
<div class="flood-risk-overlay">
|
||||||
|
<div class="risk-info-box">
|
||||||
|
<span class="risk-info-label">Risk Level</span>
|
||||||
|
<span class="risk-info-value safe">낮음</span>
|
||||||
|
</div>
|
||||||
|
<div class="risk-info-box">
|
||||||
|
<span class="risk-info-label">Risk Score</span>
|
||||||
|
<span class="risk-info-score flood-score" id="flood-risk-score">23</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 하단 차트 영역 -->
|
||||||
|
<div class="flood-charts-row">
|
||||||
|
<div class="flood-chart-box">
|
||||||
|
<h4>월별 강수량</h4>
|
||||||
|
<div class="flood-chart-content">
|
||||||
|
<img id="flood-monthly-chart" class="flood-chart-image" src="" alt="월별 강수량">
|
||||||
|
</div>
|
||||||
|
<div class="flood-chart-legend">
|
||||||
|
<span class="legend-line flood-line">─ 2025</span>
|
||||||
|
<span class="legend-line secondary">─ 2024</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flood-chart-box">
|
||||||
|
<h4>수위 현황</h4>
|
||||||
|
<div class="flood-waterlevel-content">
|
||||||
|
<div class="waterlevel-bars">
|
||||||
|
<div class="waterlevel-item">
|
||||||
|
<span class="waterlevel-label">현재수위</span>
|
||||||
|
<div class="waterlevel-bar-container">
|
||||||
|
<div class="waterlevel-bar blue" style="width: 45%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="waterlevel-item">
|
||||||
|
<span class="waterlevel-label">경고수위</span>
|
||||||
|
<div class="waterlevel-bar-container">
|
||||||
|
<div class="waterlevel-bar yellow" style="width: 70%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="waterlevel-item">
|
||||||
|
<span class="waterlevel-label">위험수위</span>
|
||||||
|
<div class="waterlevel-bar-container">
|
||||||
|
<div class="waterlevel-bar red" style="width: 90%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flood-risk-legend">
|
||||||
|
<span class="risk-legend-item"><span class="dot blue"></span> 침수위험 <span class="risk-text low">Low</span></span>
|
||||||
|
<span class="risk-legend-item"><span class="dot yellow"></span> 하천수위 <span class="risk-text moderate">Moderate</span></span>
|
||||||
|
<span class="risk-legend-item"><span class="dot green"></span> 배수상태 <span class="risk-text low">Low</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 사이드바 (오른쪽) -->
|
||||||
|
<div class="flood-monitor-sidebar">
|
||||||
|
<h3>침수 예측 요약 View</h3>
|
||||||
|
<!-- 미니맵 -->
|
||||||
|
<div class="flood-summary-map">
|
||||||
|
<div id="flood-mini-map" class="flood-mini-map"></div>
|
||||||
|
<img id="flood-mini-heatmap" class="flood-mini-heatmap" src="" alt="미니 히트맵">
|
||||||
|
<div class="flood-mini-label">
|
||||||
|
<span class="location-name">고흥천 유역</span>
|
||||||
|
<span class="risk-badge safe">Risk Level 낮음</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 위험 지표 -->
|
||||||
|
<div class="flood-indicators">
|
||||||
|
<div class="flood-indicator-item">
|
||||||
|
<span class="indicator-name">침수위험</span>
|
||||||
|
<div class="indicator-data">
|
||||||
|
<span class="indicator-value">23</span>
|
||||||
|
<span class="indicator-risk low">Risk Low</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flood-indicator-item">
|
||||||
|
<span class="indicator-name">하천수위</span>
|
||||||
|
<div class="indicator-data">
|
||||||
|
<span class="indicator-value">45</span>
|
||||||
|
<span class="indicator-risk moderate">Risk Moderate</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flood-indicator-item">
|
||||||
|
<span class="indicator-name">배수상태</span>
|
||||||
|
<div class="indicator-data">
|
||||||
|
<span class="indicator-value">82</span>
|
||||||
|
<span class="indicator-risk low">Risk Low</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 버튼 -->
|
||||||
|
<div class="flood-summary-buttons">
|
||||||
|
<button class="btn-detail" id="btn-flood-detail">상세보기</button>
|
||||||
|
<button class="btn-report" id="btn-flood-report">보고서 출력</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 가뭄 예측 - 모니터링 -->
|
||||||
|
<div class="page-content" id="drought-monitoring">
|
||||||
|
<div class="drought-monitor-dashboard">
|
||||||
|
<!-- 메인 영역 (왼쪽) -->
|
||||||
|
<div class="drought-monitor-main">
|
||||||
|
<!-- 상단 필터 바 -->
|
||||||
|
<div class="drought-filter-bar">
|
||||||
|
<div class="drought-filter-group">
|
||||||
|
<label>조회기간</label>
|
||||||
|
<select class="drought-filter-select" id="drought-period">
|
||||||
|
<option value="week">지난 일주일</option>
|
||||||
|
<option value="month">지난 한달</option>
|
||||||
|
<option value="quarter">지난 3개월</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="drought-filter-group">
|
||||||
|
<label>격자 단위(m)</label>
|
||||||
|
<div class="drought-grid-buttons">
|
||||||
|
<button class="grid-btn" data-grid="100">100</button>
|
||||||
|
<button class="grid-btn" data-grid="250">250</button>
|
||||||
|
<button class="grid-btn active" data-grid="500">500</button>
|
||||||
|
<button class="grid-btn" data-grid="1000">1000</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 히트맵 지도 영역 -->
|
||||||
|
<div class="drought-heatmap-container">
|
||||||
|
<div id="drought-heatmap" class="drought-heatmap"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 하단 차트 영역 -->
|
||||||
|
<div class="drought-charts-row">
|
||||||
|
<div class="drought-chart-box">
|
||||||
|
<h4>월별 가뭄 지수</h4>
|
||||||
|
<div class="drought-chart-content">
|
||||||
|
<img id="drought-monthly-chart" class="drought-chart-image" src="" alt="월별 가뭄 지수">
|
||||||
|
</div>
|
||||||
|
<div class="drought-chart-legend">
|
||||||
|
<span class="legend-line">─ 2025</span>
|
||||||
|
<span class="legend-line secondary">─ 2024</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drought-chart-box">
|
||||||
|
<h4>강우량</h4>
|
||||||
|
<div class="drought-rainfall-content">
|
||||||
|
<div class="rainfall-bars">
|
||||||
|
<div class="rainfall-item">
|
||||||
|
<span class="rainfall-label">누적강우</span>
|
||||||
|
<div class="rainfall-bar-container">
|
||||||
|
<div class="rainfall-bar blue" style="width: 65%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rainfall-item">
|
||||||
|
<span class="rainfall-label">평년누적</span>
|
||||||
|
<div class="rainfall-bar-container">
|
||||||
|
<div class="rainfall-bar green" style="width: 85%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rainfall-item">
|
||||||
|
<span class="rainfall-label">평년대비</span>
|
||||||
|
<div class="rainfall-bar-container">
|
||||||
|
<div class="rainfall-bar orange" style="width: 45%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drought-risk-legend">
|
||||||
|
<span class="risk-legend-item"><span class="dot green"></span> 가뭄 <span class="risk-text high">High</span></span>
|
||||||
|
<span class="risk-legend-item"><span class="dot yellow"></span> 토양수분 <span class="risk-text moderate">Moderate</span></span>
|
||||||
|
<span class="risk-legend-item"><span class="dot blue"></span> 저수율 <span class="risk-text low">Low</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 사이드바 (오른쪽) -->
|
||||||
|
<div class="drought-monitor-sidebar">
|
||||||
|
<h3>가뭄 예측 요약 View</h3>
|
||||||
|
<!-- 미니맵 -->
|
||||||
|
<div class="drought-summary-map">
|
||||||
|
<div id="drought-mini-map" class="drought-mini-map"></div>
|
||||||
|
<img id="drought-mini-heatmap" class="drought-mini-heatmap" src="" alt="미니 히트맵">
|
||||||
|
<div class="drought-mini-label">
|
||||||
|
<span class="location-name">AAA 공원</span>
|
||||||
|
<span class="risk-badge danger">Risk Level 심각</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 위험 지표 -->
|
||||||
|
<div class="drought-indicators">
|
||||||
|
<div class="drought-indicator-item">
|
||||||
|
<span class="indicator-name">가뭄</span>
|
||||||
|
<div class="indicator-data">
|
||||||
|
<span class="indicator-value">72</span>
|
||||||
|
<span class="indicator-risk high">Risk High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drought-indicator-item">
|
||||||
|
<span class="indicator-name">토양수분</span>
|
||||||
|
<div class="indicator-data">
|
||||||
|
<span class="indicator-value">45</span>
|
||||||
|
<span class="indicator-risk moderate">Risk Moderate</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drought-indicator-item">
|
||||||
|
<span class="indicator-name">저수율</span>
|
||||||
|
<div class="indicator-data">
|
||||||
|
<span class="indicator-value">27</span>
|
||||||
|
<span class="indicator-risk low">Risk Low</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 버튼 -->
|
||||||
|
<div class="drought-summary-buttons">
|
||||||
|
<button class="btn-detail" id="btn-drought-detail">상세보기</button>
|
||||||
|
<button class="btn-report" id="btn-drought-report">보고서 출력</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 가뭄 예측 - 3D 시뮬레이션 -->
|
||||||
|
<div class="page-content" id="drought-simulation">
|
||||||
|
<div class="empty-page-placeholder" style="display:flex;align-items:center;justify-content:center;height:100%;color:#aaa;font-size:1.2rem;">
|
||||||
|
<p>준비 중입니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 가뭄 예측 - 시계열 변화 그래프 -->
|
||||||
|
<div class="page-content" id="drought-waterlevel">
|
||||||
|
<div class="waterlevel-page drought-timeseries">
|
||||||
|
<div class="waterlevel-header">
|
||||||
|
<h2>가뭄 예측 - 시계열 변화 그래프</h2>
|
||||||
|
<div class="waterlevel-controls">
|
||||||
|
<div class="control-item">
|
||||||
|
<label>가뭄 지표</label>
|
||||||
|
<select class="waterlevel-select" id="drought-graph-type">
|
||||||
|
<option value="spi">SPI (표준강수지수)</option>
|
||||||
|
<option value="vhi">VHI (식생건강지수)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-item">
|
||||||
|
<label>조회 기간</label>
|
||||||
|
<select class="waterlevel-select" id="drought-graph-period">
|
||||||
|
<option value="month">최근 1개월</option>
|
||||||
|
<option value="quarter">최근 3개월</option>
|
||||||
|
<option value="year">최근 1년</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn-graph-load drought-btn" id="btn-drought-graph-load">조회</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="waterlevel-content">
|
||||||
|
<div class="graph-container">
|
||||||
|
<div class="graph-loading" id="drought-graph-loading" style="display: none;">
|
||||||
|
<span>그래프 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
<img class="graph-image" id="drought-graph-image" src="" alt="가뭄 지표 그래프">
|
||||||
|
<div class="graph-placeholder" id="drought-graph-placeholder">
|
||||||
|
<span>조회 버튼을 클릭하여 그래프를 확인하세요</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="graph-info drought-info">
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">현재 지표값</span>
|
||||||
|
<span class="info-value" id="drought-current-value">-0.8</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">평균 지표값</span>
|
||||||
|
<span class="info-value" id="drought-avg-value">-0.3</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">최저값</span>
|
||||||
|
<span class="info-value warning" id="drought-min-value">-1.5</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">가뭄 등급</span>
|
||||||
|
<span class="info-value drought-level" id="drought-grade">약한 가뭄</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 고흥 단기예보 -->
|
||||||
|
<div class="page-content" id="kma-test">
|
||||||
|
<div class="waterlevel-page">
|
||||||
|
<!-- 육상예보 -->
|
||||||
|
<div class="waterlevel-header">
|
||||||
|
<h2>단기예보 (전라남도 고흥)</h2>
|
||||||
|
<div class="waterlevel-controls">
|
||||||
|
<button class="btn-graph-load" id="btn-kma-fetch">새로고침</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="waterlevel-content">
|
||||||
|
<div class="kma-result-area">
|
||||||
|
<div class="kma-summary" id="kma-summary" style="display:none;">
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">발표시간</span>
|
||||||
|
<span class="info-value" id="kma-announce-time">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<span class="info-label">조회 건수</span>
|
||||||
|
<span class="info-value" id="kma-count">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="graph-placeholder" id="kma-placeholder">
|
||||||
|
<span>데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
<div class="kma-loading" id="kma-loading" style="display:none;">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>데이터 조회 중...</span>
|
||||||
|
</div>
|
||||||
|
<div class="kma-data-container" id="kma-data-container" style="display:none;">
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="kma-table" id="kma-table">
|
||||||
|
<thead id="kma-table-head"></thead>
|
||||||
|
<tbody id="kma-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2026 All Rights Reserved</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/terrain3d.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
Flask==3.0.0
|
||||||
|
APScheduler==3.10.4
|
||||||
|
requests>=2.28.0
|
||||||
|
Werkzeug==3.0.1
|
||||||
|
Jinja2==3.1.2
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
matplotlib==3.8.2
|
||||||
|
numpy==2.4.1
|
||||||
|
scipy==1.11.4
|
||||||
|
geopandas==1.1.2
|
||||||
|
pyproj==3.7.2
|
||||||
|
shapely==2.1.2
|
||||||
|
pandas==3.0.0
|
||||||
|
pyogrio==0.12.1
|
||||||
|
openpyxl==3.1.5
|
||||||
Loading…
Reference in New Issue
Block a user