175 lines
5.6 KiB
TypeScript
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): 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 = `이미지분석_${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', 48, $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 };
|
|
}
|