Skip to content

Commit a9a5235

Browse files
committed
Add VWorld building footprint quality audit
1 parent ca38696 commit a9a5235

6 files changed

Lines changed: 394 additions & 2 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "vitest";
2+
import { auditBuildingFootprints, isUsableBuildingFootprint, usableBuildingFootprintFeatures } from "./buildingFootprintQuality";
3+
import type { VworldWfsFeature } from "./vworld";
4+
5+
const polygon = (id: string, managementNo?: string): VworldWfsFeature => ({ id, geometry: { type: "Polygon", coordinates: [[[126.9, 35.1], [126.901, 35.1], [126.901, 35.101], [126.9, 35.1]]] }, properties: managementNo ? { BD_MGT_SN: managementNo, USE: "주거" } : { USE: "미확인" } });
6+
7+
describe("building footprint quality", () => {
8+
it("accepts valid polygon and multipolygon footprints only", () => {
9+
expect(isUsableBuildingFootprint(polygon("b-1").geometry)).toBe(true);
10+
expect(isUsableBuildingFootprint({ type: "MultiPolygon", coordinates: [[[[126.9, 35.1], [126.901, 35.1], [126.901, 35.101], [126.9, 35.1]]]] })).toBe(true);
11+
expect(isUsableBuildingFootprint({ type: "Point", coordinates: [126.9, 35.1] })).toBe(false);
12+
expect(isUsableBuildingFootprint({ type: "Polygon", coordinates: [[[126.9, 35.1], [126.9, 35.1]]] })).toBe(false);
13+
});
14+
15+
it("counts invalid geometry and missing identities without deleting source features", () => {
16+
const features = [polygon("b-1", "A-1"), polygon("b-2"), { id: "point-1", geometry: { type: "Point", coordinates: [126.9, 35.1] }, properties: {} } as VworldWfsFeature];
17+
const quality = auditBuildingFootprints(features);
18+
expect(quality).toMatchObject({ totalFeatures: 3, polygonFeatures: 2, usablePolygonFeatures: 2, invalidGeometryCount: 1, missingIdentityCount: 2 });
19+
expect(usableBuildingFootprintFeatures(features)).toHaveLength(2);
20+
});
21+
22+
it("reports duplicate identity and geometry groups separately", () => {
23+
const features = [polygon("b-1", "A-1"), { ...polygon("b-2", "A-1"), geometry: { type: "Polygon", coordinates: [[[126.91, 35.1], [126.911, 35.1], [126.911, 35.101], [126.91, 35.1]]] } }, polygon("b-3", "B-1"), { ...polygon("b-4", "B-1") }];
24+
const quality = auditBuildingFootprints(features);
25+
expect(quality.duplicateIdentityGroups).toEqual(expect.arrayContaining([expect.objectContaining({ key: "buildingManagementNo:A1", count: 2 }), expect.objectContaining({ key: "buildingManagementNo:B1", count: 2 })]));
26+
expect(quality.duplicateIdentityFeatureCount).toBe(4);
27+
expect(quality.duplicateGeometryCount).toBe(3);
28+
});
29+
30+
it("returns field names and geometry types for source diagnostics", () => {
31+
const quality = auditBuildingFootprints([polygon("b-1", "A-1"), { id: "point-1", geometry: { type: "Point", coordinates: [126.9, 35.1] }, properties: { HEIGHT: 12 } }]);
32+
expect(quality.propertyFieldNames).toEqual(["BD_MGT_SN", "HEIGHT", "USE"]);
33+
expect(quality.geometryTypes).toEqual(["Point", "Polygon"]);
34+
});
35+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { buildingIdentityFromProperties, normalizeBuildingIdentity, type BuildingIdentityField } from "./buildingIdentity";
2+
import type { SpatialGeometry } from "./model";
3+
import type { VworldWfsFeature } from "./vworld";
4+
5+
export type BuildingFootprintDuplicateGroup = {
6+
key: string;
7+
featureIds: string[];
8+
count: number;
9+
};
10+
11+
export type BuildingFootprintQuality = {
12+
totalFeatures: number;
13+
polygonFeatures: number;
14+
usablePolygonFeatures: number;
15+
invalidGeometryCount: number;
16+
missingIdentityCount: number;
17+
duplicateIdentityGroups: BuildingFootprintDuplicateGroup[];
18+
duplicateIdentityFeatureCount: number;
19+
duplicateGeometryCount: number;
20+
propertyFieldNames: string[];
21+
geometryTypes: string[];
22+
};
23+
24+
const identityFields: BuildingIdentityField[] = ["buildingManagementNo", "bldrgstPk", "ufid", "pnu", "gid", "featureId"];
25+
26+
function coordinatePair(value: unknown): value is [number, number] {
27+
return Array.isArray(value) && value.length >= 2 && Number.isFinite(Number(value[0])) && Number.isFinite(Number(value[1]));
28+
}
29+
30+
function validRing(value: unknown) {
31+
if (!Array.isArray(value) || value.length < 3) return false;
32+
const points = value.filter(coordinatePair);
33+
return points.length >= 3 && new Set(points.map(point => `${Number(point[0]).toFixed(7)},${Number(point[1]).toFixed(7)}`)).size >= 3;
34+
}
35+
36+
export function isUsableBuildingFootprint(geometry?: SpatialGeometry) {
37+
if (!geometry || !Array.isArray(geometry.coordinates)) return false;
38+
if (geometry.type === "Polygon") return validRing(geometry.coordinates[0]);
39+
if (geometry.type === "MultiPolygon") return geometry.coordinates.some(polygon => Array.isArray(polygon) && validRing(polygon[0]));
40+
return false;
41+
}
42+
43+
function identityKey(feature: VworldWfsFeature) {
44+
const identity = buildingIdentityFromProperties(feature.properties, feature.id);
45+
for (const field of identityFields) {
46+
const value = normalizeBuildingIdentity(identity[field]);
47+
if (value) return `${field}:${value}`;
48+
}
49+
return "";
50+
}
51+
52+
function featureId(feature: VworldWfsFeature, index: number) {
53+
return feature.id?.trim() || `feature-${index + 1}`;
54+
}
55+
56+
function geometryKey(geometry?: SpatialGeometry) {
57+
if (!geometry || !Array.isArray(geometry.coordinates)) return "";
58+
const round = (value: unknown): unknown => Array.isArray(value) ? value.map(round) : typeof value === "number" ? Number(value.toFixed(7)) : value;
59+
return `${geometry.type}:${JSON.stringify(round(geometry.coordinates))}`;
60+
}
61+
62+
export function auditBuildingFootprints(features: VworldWfsFeature[]): BuildingFootprintQuality {
63+
const duplicateIdentityMap = new Map<string, string[]>();
64+
const duplicateGeometryMap = new Map<string, string[]>();
65+
const fields = new Set<string>();
66+
const geometryTypes = new Set<string>();
67+
let polygonFeatures = 0;
68+
let usablePolygonFeatures = 0;
69+
let invalidGeometryCount = 0;
70+
let missingIdentityCount = 0;
71+
features.forEach((feature, index) => {
72+
Object.keys(feature.properties).forEach(field => fields.add(field));
73+
if (feature.geometry?.type) geometryTypes.add(feature.geometry.type);
74+
const isPolygon = feature.geometry?.type === "Polygon" || feature.geometry?.type === "MultiPolygon";
75+
if (isPolygon) polygonFeatures += 1;
76+
if (isUsableBuildingFootprint(feature.geometry)) usablePolygonFeatures += 1;
77+
else invalidGeometryCount += 1;
78+
const idKey = identityKey(feature);
79+
if (!idKey) missingIdentityCount += 1;
80+
else duplicateIdentityMap.set(idKey, [...(duplicateIdentityMap.get(idKey) ?? []), featureId(feature, index)]);
81+
const shapeKey = geometryKey(feature.geometry);
82+
if (shapeKey) duplicateGeometryMap.set(shapeKey, [...(duplicateGeometryMap.get(shapeKey) ?? []), featureId(feature, index)]);
83+
});
84+
const duplicateIdentityGroups = Array.from(duplicateIdentityMap.entries()).filter(([, ids]) => ids.length > 1).map(([key, ids]) => ({ key, featureIds: ids, count: ids.length }));
85+
const duplicateGeometryGroups = Array.from(duplicateGeometryMap.values()).filter(ids => ids.length > 1);
86+
return { totalFeatures: features.length, polygonFeatures, usablePolygonFeatures, invalidGeometryCount, missingIdentityCount, duplicateIdentityGroups, duplicateIdentityFeatureCount: duplicateIdentityGroups.reduce((sum, group) => sum + group.count, 0), duplicateGeometryCount: duplicateGeometryGroups.reduce((sum, ids) => sum + ids.length, 0), propertyFieldNames: Array.from(fields).sort(), geometryTypes: Array.from(geometryTypes).sort() };
87+
}
88+
89+
export function usableBuildingFootprintFeatures(features: VworldWfsFeature[]) {
90+
return features.filter(feature => isUsableBuildingFootprint(feature.geometry));
91+
}

client/src/static/research.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { PublicServiceSettings, ResearchNote, SiteRecord } from "./model";
22
import { enrichVworldParcelCandidates, fetchVworldBrowserParcel, fetchVworldBuildingUseWfs, fetchVworldDataFeatures, fetchVworldWfs, mergeBuildingUseFeatures } from "./vworld";
3+
import { auditBuildingFootprints } from "./buildingFootprintQuality";
34
import { fetchOsmHydrology, osmHydrologyQuery } from "./osm";
45

56
export type SourceId = "terrain" | "air" | "vworldParcel" | "cityParks" | "vworldBuildings" | "vworldRoads" | "vworldZoning" | "landRegulation" | "vworldWelfare" | "vworldTransit" | "vworldBusiness" | "vworldCulture" | "sgisPopulation" | "sgisBusiness" | "osmHydrology";
@@ -111,6 +112,7 @@ export async function collectSource(source: SourceDefinition, site: SiteRecord,
111112
if (source.id === "vworldBuildings" || source.id === "vworldRoads") {
112113
const typename = source.id === "vworldBuildings" ? "lt_c_spbd" : "lt_l_moctlink";
113114
const result = await fetchVworldWfs({ key: settings.vworldKey, domain: settings.vworldDomain, typename, latitude: site.latitude, longitude: site.longitude, radiusMeters: siteRadius(radiusMeters) });
115+
const footprintQuality = source.id === "vworldBuildings" ? auditBuildingFootprints(result.features) : undefined;
114116
let features = result.features;
115117
let useFeatures = [] as typeof result.features;
116118
let useStatus = "";
@@ -120,7 +122,8 @@ export async function collectSource(source: SourceDefinition, site: SiteRecord,
120122
}
121123
const propertyNames = Array.from(new Set(features.flatMap(feature => Object.keys(feature.properties)))).slice(0, 16).join(", ");
122124
const spatialFeatures = features.filter(feature => feature.geometry).slice(0, 300).map((feature, index) => ({ id: feature.id ?? `${source.id}-${index + 1}`, geometry: feature.geometry!, properties: feature.properties }));
123-
const record = note(source.source, source.title, `조사 반경 ${siteRadius(radiusMeters)}m 내 WFS 객체 ${features.length.toLocaleString("ko-KR")}개. 지도에는 공간자료 ${spatialFeatures.length.toLocaleString("ko-KR")}개를 표시합니다. 속성 표본: ${propertyNames || "응답 속성 없음"}. ${useStatus ? `${useStatus}. ` : ""}${source.limitation}`, source.id === "vworldBuildings" ? "https://www.data.go.kr/data/15123458/openapi.do" : "https://www.its.go.kr/nodelink/", { latitude: site.latitude, longitude: site.longitude }, { catalogId: source.catalogId, detail: `footprint WFS 원본 feature의 속성·geometry 상세입니다.\n${JSON.stringify(features, null, 2)}${useFeatures.length ? `\n\n용도별건물정보 WFS 원본 feature입니다.\n${JSON.stringify(useFeatures, null, 2)}` : ""}`, ...serializeDetail({ footprint: features, buildingUse: useFeatures, useStatus }) });
125+
const qualitySummary = footprintQuality ? ` 유효 footprint ${footprintQuality.usablePolygonFeatures.toLocaleString("ko-KR")}개, geometry 오류 ${footprintQuality.invalidGeometryCount.toLocaleString("ko-KR")}개, 식별자 누락 ${footprintQuality.missingIdentityCount.toLocaleString("ko-KR")}개, 식별자 중복 그룹 ${footprintQuality.duplicateIdentityGroups.length.toLocaleString("ko-KR")}개입니다.` : "";
126+
const record = note(source.source, source.title, `조사 반경 ${siteRadius(radiusMeters)}m 내 WFS 객체 ${features.length.toLocaleString("ko-KR")}개. 지도에는 공간자료 ${spatialFeatures.length.toLocaleString("ko-KR")}개를 표시합니다.${qualitySummary} 속성 표본: ${propertyNames || "응답 속성 없음"}. ${useStatus ? `${useStatus}. ` : ""}${source.limitation}`, source.id === "vworldBuildings" ? "https://www.data.go.kr/data/15123458/openapi.do" : "https://www.its.go.kr/nodelink/", { latitude: site.latitude, longitude: site.longitude }, { catalogId: source.catalogId, detail: `footprint WFS 원본 feature의 속성·geometry 상세입니다.\n${JSON.stringify(features, null, 2)}${useFeatures.length ? `\n\n용도별건물정보 WFS 원본 feature입니다.\n${JSON.stringify(useFeatures, null, 2)}` : ""}`, ...serializeDetail({ footprint: features, footprintQuality, buildingUse: useFeatures, useStatus }) });
124127
record.spatialLayer = { id: source.id, title: source.title, source: source.source, fetchedAt: record.createdAt, features: spatialFeatures, totalFeatureCount: result.features.length, truncated: result.features.length > spatialFeatures.length };
125128
return record;
126129
}

development/TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
## C. 주변 건축물 데이터 연결·정규화
2525

26-
- [ ] C-01. VWorld footprint 원자료 수집·보존·품질검사
26+
- [x] C-01. VWorld footprint 원자료 수집·보존·품질검사
2727
- [ ] C-02. 용도별건물정보·건축HUB 속성자료 연결
2828
- [ ] C-03. 건축인허가·사용승인·폐쇄말소·건축연도 자료 연결
2929
- [ ] C-04. 높이·층수·면적·구조·용도 필드 정규화
@@ -77,3 +77,4 @@
7777
| 2026-08-29 | B-02 거시·중간·대지·미시 조사 범위와 자료량 정책 | 완료 | 범위 정책 함수·필드 정책·테스트 통과 | B-03 데이터 구조에서 적용 |
7878
| 2026-08-29 | B-03 원본·정규화·현장관찰·분석결과 데이터 구조 | 완료 | 데이터 타입·프로젝트 연결·역호환 테스트 통과 | B-04 식별자 결합에서 적용 |
7979
| 2026-08-29 | B-04 건축물 식별자와 자료 간 결합 정책 | 완료 | 정규화·인덱스·일치·후보·충돌 테스트 통과 | C 단계 실제 원자료에서 적용 |
80+
| 2026-08-29 | C-01 VWorld footprint 원자료 수집·보존·품질검사 | 완료 | 품질 요약·유효 geometry·중복·누락 테스트 통과 | C-02 속성자료 연결에서 적용 |
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# VWorld 건축물 footprint 품질검사 정책
2+
3+
작성일: 2026-08-29
4+
상위 작업: `development/tasks/C-01-building-footprint-quality.md`
5+
6+
## 1. 목적
7+
8+
VWorld `lt_c_spbd` 등 건축물 footprint 응답을 분석하기 전에 유효 geometry·식별자·중복·필드 목록을 진단한다.
9+
10+
```text
11+
원자료 전체
12+
→ 품질 요약
13+
→ 유효 footprint 분석 후보
14+
→ 지도 표시·공간분석
15+
```
16+
17+
품질검사에서 제외된 feature도 원자료에서는 삭제하지 않는다.
18+
19+
## 2. 유효 footprint
20+
21+
분석 가능한 footprint는 다음 조건을 만족해야 한다.
22+
23+
```text
24+
Polygon 또는 MultiPolygon
25+
외곽 ring에 유효 좌표 3개 이상
26+
서로 다른 좌표 3개 이상
27+
```
28+
29+
다음 feature는 footprint 분석 후보에서 제외한다.
30+
31+
```text
32+
geometry 없음
33+
Point
34+
LineString
35+
좌표 부족
36+
좌표가 모두 동일
37+
```
38+
39+
단, 제외 feature의 원본 속성과 geometry는 보존한다.
40+
41+
## 3. 품질 요약
42+
43+
```text
44+
totalFeatures
45+
polygonFeatures
46+
usablePolygonFeatures
47+
invalidGeometryCount
48+
missingIdentityCount
49+
duplicateIdentityGroups
50+
duplicateIdentityFeatureCount
51+
duplicateGeometryCount
52+
propertyFieldNames
53+
geometryTypes
54+
```
55+
56+
이 값은 실제 건축물 부재를 의미하지 않는다. API 오류·CORS 실패·빈 응답은 품질 요약에 넣지 않고 별도 수집 상태로 기록해야 한다.
57+
58+
## 4. 식별자 진단
59+
60+
B-04 결합 정책의 별칭을 사용한다.
61+
62+
```text
63+
buildingManagementNo
64+
bd_mgt_sn
65+
bld_mng_no
66+
bldg_mng_no
67+
bldrgst_pk
68+
UFID
69+
PNU
70+
GID
71+
feature id
72+
```
73+
74+
feature ID만 있는 경우 내부 feature 식별자로 보관하며, 건축물관리번호로 해석하지 않는다.
75+
76+
## 5. 중복 진단
77+
78+
### 식별자 중복
79+
80+
동일 정규화 식별자가 여러 feature에 나타나는 경우 중복 그룹으로 기록한다.
81+
82+
```text
83+
원본 feature 모두 보존
84+
중복 그룹 ID 기록
85+
속성·geometry·기준일 비교 대상으로 전달
86+
자동 삭제하지 않음
87+
```
88+
89+
### geometry 중복
90+
91+
좌표를 소수점 7자리로 정규화했을 때 동일한 geometry가 여러 feature에 나타나는 경우 geometry 중복 후보로 기록한다.
92+
93+
geometry가 같아도 다음이 다를 수 있으므로 자동 삭제하지 않는다.
94+
95+
```text
96+
출처
97+
기준일
98+
식별자
99+
속성
100+
자료 갱신 상태
101+
```
102+
103+
## 6. 지도 표시와 분석 보존 구분
104+
105+
```text
106+
전체 원자료
107+
: 품질검사 결과와 함께 프로젝트에 보존
108+
109+
유효 분석 후보
110+
: Polygon·MultiPolygon 조건을 통과한 feature
111+
112+
지도 표시
113+
: 성능을 위해 최대 300개까지 제한 가능
114+
115+
통계·공간분석
116+
: 지도 표시 300개가 아니라 전체 유효 분석 후보 기준
117+
```
118+
119+
따라서 지도에 300개만 표시되었다고 전체 건축물이 300개라는 의미가 아니다.
120+
121+
## 7. 구현 파일
122+
123+
```text
124+
client/src/static/buildingFootprintQuality.ts
125+
client/src/static/buildingFootprintQuality.test.ts
126+
```
127+
128+
제공 함수:
129+
130+
```text
131+
isUsableBuildingFootprint()
132+
auditBuildingFootprints()
133+
usableBuildingFootprintFeatures()
134+
```
135+
136+
## 8. 다음 작업에서 사용할 것
137+
138+
```text
139+
C-02 용도별건물정보·건축HUB 속성자료 연결
140+
C-05 건축물별 통합 마스터와 결합 신뢰도 생성
141+
D-01~D-05 범위별 건축물 분석
142+
```
143+
144+
품질 요약의 중복·누락·geometry 상태는 이후 결합 신뢰도와 AI 입력의 데이터 한계에 전달한다.

0 commit comments

Comments
 (0)