wing-ops/backend/src/prediction/imageAnalyzeService.ts
jeonghyo.k 2640d882da feat(incidents): 이미지 분석 연동 강화 및 사고 팝업 리뉴얼
- 사고별 이미지 분석 API 및 항공 미디어 조회 연동
- 사고 마커 팝업 디자인 개선, 필터링된 사고만 지도 표시
- 이미지 분석 시 사고명 파라미터 지원, 기본 예측시간 6시간으로 변경
- 유출량 정밀도 NUMERIC(14,10) 확대 (migration 031)
- OpenDrift 유종 매핑 수정 (원유, 등유)
2026-04-13 16:41:56 +09:00

175 lines
5.6 KiB
TypeScript

import crypto from 'crypto';
import { wingPool } from '../db/wingDb.js';
import { createMedia } from '../aerial/aerialService.js';
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
// 유류 클래스 → UI 유종명 매핑
const CLASS_ID_TO_OIL_TYPE: Record<string, string> = {
'검정': '벙커C유',
'갈색': '벙커C유',
'무지개': '경유',
'은색': '등유',
};
// 유종명 → DB 코드 매핑
const OIL_DB_CODE_MAP: Record<string, string> = {
'벙커C유': 'BUNKER_C',
'원유': 'CRUDE_OIL',
'경유': 'DIESEL',
'등유': 'GASOLINE',
};
interface OilPolygon {
classId: string;
area: number;
volume: number;
note: string;
thickness: number;
wkt: string;
}
interface ImageServerResponse {
meta: string;
data: OilPolygon[];
}
export interface ImageAnalyzeResult {
acdntSn: number;
lat: number;
lon: number;
oilType: string;
area: number;
volume: number;
fileId: string;
occurredAt: string;
}
/**
* mx15hdi CSV 컬럼 순서:
* Filename, Tlat_d, Tlat_m, Tlat_s, Tlon_d, Tlon_m, Tlon_s,
* Alat_d, Alat_m, Alat_s, Alon_d, Alon_m, Alon_s,
* Az, El, Alt, Date1, Date2, Date3, Time1, Time2, Time3
*/
function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: string } {
const parts = metaStr.split(',');
const tlat_d = parseFloat(parts[1]);
const tlat_m = parseFloat(parts[2]);
const tlat_s = parseFloat(parts[3]);
const tlon_d = parseFloat(parts[4]);
const tlon_m = parseFloat(parts[5]);
const tlon_s = parseFloat(parts[6]);
const lat = tlat_d + tlat_m / 60 + tlat_s / 3600;
const lon = tlon_d + tlon_m / 60 + tlon_s / 3600;
// Date: Date1(DD), Date2(MM), Date3(YYYY) / Time: Time1(HH), Time2(mm), Time3(ss)
const dd = (parts[16] ?? '01').padStart(2, '0');
const mm = (parts[17] ?? '01').padStart(2, '0');
const yyyy = parts[18] ?? new Date().getFullYear().toString();
const time1 = (parts[19] ?? '00').padStart(2, '0');
const time2 = (parts[20] ?? '00').padStart(2, '0');
const occurredAt = `${yyyy}-${mm}-${dd}T${time1}:${time2}:00+09:00`;
return { lat, lon, occurredAt };
}
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise<ImageAnalyzeResult> {
const fileId = crypto.randomUUID();
// camTy는 현재 "mx15hdi"로 하드코딩한다.
// TODO: 추후 이미지 EXIF에서 카메라 모델명을 읽어 camTy를 자동 판별하는 로직을
// 이미지 분석 서버(api.py)에 추가할 예정이다. (check_camera_info 함수 활용)
const camTy = 'mx15hdi';
// 이미지 분석 서버 호출
const formData = new FormData();
const blob = new Blob([imageBuffer]);
formData.append('camTy', camTy);
formData.append('fileId', fileId);
formData.append('image', blob, originalName);
let serverResponse: ImageServerResponse;
try {
const res = await fetch(`${IMAGE_API_URL}/run-script/`, {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(300_000),
});
if (!res.ok) {
const text = await res.text();
if (res.status === 400 && text.includes('GPS')) {
throw Object.assign(new Error('GPS_NOT_FOUND'), { code: 'GPS_NOT_FOUND' });
}
throw new Error(`이미지 분석 서버 오류: ${res.status} - ${text}`);
}
serverResponse = await res.json() as ImageServerResponse;
} catch (err: unknown) {
if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'GPS_NOT_FOUND') throw err;
if (err instanceof Error && err.name === 'TimeoutError') {
throw Object.assign(new Error('TIMEOUT'), { code: 'TIMEOUT' });
}
throw err;
}
// 응답 파싱
const { lat, lon, occurredAt } = parseMeta(serverResponse.meta);
const firstOil = serverResponse.data[0];
const oilType = firstOil ? (CLASS_ID_TO_OIL_TYPE[firstOil.classId] ?? '벙커C유') : '벙커C유';
const area = firstOil?.area ?? 0;
const volume = firstOil?.volume ?? 0;
// ACDNT INSERT
const acdntNm = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
const acdntRes = await wingPool.query(
`INSERT INTO wing.ACDNT
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
VALUES (
'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-' ||
LPAD(
(SELECT COALESCE(MAX(CAST(SPLIT_PART(ACDNT_CD, '-', 3) AS INTEGER)), 0) + 1
FROM wing.ACDNT
WHERE ACDNT_CD LIKE 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-%')::TEXT,
4, '0'
),
$1, '유류유출', $2, $3, $4, 'ACTIVE', 'Y', NOW()
)
RETURNING ACDNT_SN`,
[acdntNm, occurredAt, lat, lon]
);
const acdntSn: number = acdntRes.rows[0].acdnt_sn;
// SPIL_DATA INSERT (img_rslt_data에 분석 원본 저장)
await wingPool.query(
`INSERT INTO wing.SPIL_DATA
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`,
[
acdntSn,
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',
volume,
JSON.stringify(serverResponse),
]
);
// AERIAL_MEDIA INSERT (영상사진관리 목록에서 조회 가능하도록 저장)
const fileSizeMb = (imageBuffer.length / (1024 * 1024)).toFixed(1) + ' MB';
await createMedia({
fileNm: `${fileId}_${originalName}`,
orgnlNm: originalName,
acdntSn,
lon,
lat,
locDc: `${lon.toFixed(4)} + ${lat.toFixed(4)}`,
equipTpCd: 'drone',
equipNm: camTy,
mediaTpCd: '사진',
takngDtm: occurredAt,
fileSz: fileSizeMb,
});
return { acdntSn, lat, lon, oilType, area, volume, fileId, occurredAt };
}