This commit is contained in:
JIAN SO 2026-02-02 19:07:53 +09:00
commit 4f1707a925
33 changed files with 12183 additions and 0 deletions

7
.claude/settings.json Normal file
View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dir:*)"
]
}
}

View 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
View 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/

1
README.md Normal file
View File

@ -0,0 +1 @@
# 파일 업데이트

View 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',
]

View 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}")

View 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()

View 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)

View 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',
]

View 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

View 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

View 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}")

View 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

View 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)

View 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()

View 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()

View 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
View 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
View 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
View 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
View 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
View 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'}
}
})

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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
View 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
View 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>&copy; 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
View 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

6
run.py Normal file
View File

@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)