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 = { '검정': '벙커C유', '갈색': '벙커C유', '무지개': '경유', '은색': '등유', }; // 유종명 → DB 코드 매핑 const OIL_DB_CODE_MAP: Record = { '벙커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 { 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 }; }