트레드 그루브 오버레이
AI Engine API가 돌려주는 groove_masks·tire_rotation·vehicle_side로 트레드 사진 위에 그루브 깊이를 그리는 방법을 설명합니다.
POST /v2/tire/tread는 그루브별 잔여 깊이와 함께 그루브 영역 폴리곤(groove_masks) 과, 사진을 똑바로
세우고 좌우 방향을 맞추는 데 필요한 tire_rotation·vehicle_side 를 돌려줍니다. 이 페이지는 이 세 값을
어떤 순서로 적용해 자체 화면에 오버레이를 그리는지 설명합니다.
핵심 세 가지
- 폴리곤 좌표는 보낸 사진 그대로(회전 전) 의 정규화 좌표입니다. 사진을 돌려 표시하면 폴리곤도 같이 돌려야 합니다.
tire_rotation은 사진을 똑바로 세우기 위해 시계 방향으로 돌려야 할 각도입니다.vehicle_side는 세운 사진에서 차량 안쪽이 어느 쪽인지입니다. 배열 순서는 차량 바깥쪽 → 안쪽입니다.
요청
analysis_function에 groove_masks를 넣으면 폴리곤이, tread_roi를 넣으면 트레드 영역 bbox가 함께 옵니다.
회전·좌우 방향까지 쓰려면 tire_rotation·vehicle_side도 요청해야 합니다. 요청하지 않으면 응답에 키 자체가
없고 에러도 나지 않아, 그대로 그리면 회전 0·반전 없음으로 처리되어 사진이 누운 채, 좌우가 뒤집힌 채 표시됩니다.
curl -X POST https://api.atrace.ai/v2/tire/tread \
-H "Authorization: Bearer $ATRACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"analysis_function": ["aggregated_depth", "individual_depths", "tire_rotation", "vehicle_side", "groove_masks", "tread_roi"],
"image_url": "https://docs.atrace.ai/samples/tire/tread-b-4mm.jpg"
}'아래는 위 요청의 실제 응답입니다. 폴리곤 좌표는 지면상 첫 점만 실었고, 전체 응답은 tread-b-4mm.json에서 그대로 받을 수 있습니다.
{
"data": {
"aggregated_depth": { "aggregated_remaining_depth": 5.3, "estimated_lifetime": 17 },
"individual_depths": { "individual_remaining_depth": [5.0, 5.4, 5.6, 5.1] },
"tire_rotation": 90,
"vehicle_side": "right",
"groove_masks": [
{ "depth_mm": 5.0, "score": 0.93, "polygon": [[0.0016, 0.7434], "…"] },
{ "depth_mm": 5.4, "score": 0.92, "polygon": [[0.0016, 0.6234], "…"] },
{ "depth_mm": 5.6, "score": 0.91, "polygon": [[0.0016, 0.5172], "…"] },
{ "depth_mm": 5.1, "score": 0.91, "polygon": [[0.0016, 0.4009], "…"] }
],
"tread_roi": { "bbox_xyxy_norm": [0.0013, 0.1625, 1.0, 0.925] }
}
}이 사진은 옆으로 누운 채 촬영되어 tire_rotation: 90이 왔습니다. 그래서 회전 전 프레임에서는 그루브가 가로로
놓여 각 폴리곤이 x 전 구간(0.0016~0.9997)에 걸치고, 네 그루브는 y 값으로 구분됩니다. 위에 실은 첫 점의 x가
모두 0.0016인 것은 외곽선이 누운 사진의 왼쪽 가장자리에서 시작하기 때문이며, 세우면 그 변이 위쪽 가장자리가
됩니다.
응답 필드와 좌표계
| 필드 | 의미 |
|---|---|
groove_masks[i].polygon | 그루브 i의 영역 폴리곤. [x, y] 목록이며 보낸 원본 이미지 기준 0~1 정규화, 원점은 좌상단입니다. 픽셀 좌표는 x × 가로, y × 세로입니다. 닫혀 있지 않고(첫 점과 끝 점이 다름) 점 개수는 그루브마다 다릅니다. 외곽선을 만들지 못하면 []입니다. |
groove_masks[i].depth_mm | 그루브 i의 잔여 깊이(mm). individual_remaining_depth[i]와 같은 값입니다. |
groove_masks[i].score | 세그멘테이션 신뢰도(0~1). 표시 여부를 가르는 기준으로 쓰지 마세요. 값의 분포는 모델 버전에 따라 달라지며, 배열에서 원소를 빼면 인덱스가 어긋납니다. 진단·로깅 용도로만 쓰세요. |
individual_depths.individual_remaining_depth | 그루브별 잔여 깊이(mm). groove_masks와 같은 길이, 같은 인덱스입니다. |
tire_rotation | 사진을 똑바로 세우기 위해 시계 방향으로 돌려야 할 각도. 0·90·180·270 중 하나입니다. |
vehicle_side | 똑바로 세운 사진에서 차량 안쪽(안쪽 숄더)이 있는 방향. left 또는 right입니다. 그 밖의 값이나 null이 오면 반전하지 마세요. |
tread_roi.bbox_xyxy_norm | 트레드 영역 [x1, y1, x2, y2]. 원본 이미지 기준 0~1 정규화이며 확대·크롭 힌트로 씁니다. |
배열 순서는 차량 바깥쪽 → 안쪽입니다. groove_masks[0]과 individual_remaining_depth[0]이 바깥쪽 숄더에
가장 가까운 그루브이고, 마지막 원소가 차량 안쪽 그루브입니다. 사진의 왼쪽 → 오른쪽 순서가 아니므로 화면 순서로
해석하지 마세요. 두 배열은 항상 같은 길이이며 같은 인덱스가 같은 그루브입니다.
aggregation_mode는 배열에 영향을 주지 않습니다
aggregation_mode: exclude_shoulder_groove는 aggregated_depth를 계산할 때 양쪽 끝 그루브를 제외할 뿐입니다.
individual_remaining_depth와 groove_masks는 어느 모드에서도 인식된 모든 그루브를 담습니다.
사진 세우기 — tire_rotation
엔진은 사진이 누워 있어도 분석하지만, 돌려주는 좌표는 보낸 사진 그대로의 프레임입니다. 화면에 똑바로
세워 보여주려면 사진과 폴리곤을 함께 tire_rotation만큼 시계 방향으로 돌립니다.
tire_rotation | CSS transform | 정규화 좌표 매핑 (x, y) → | 세운 뒤 크기 (원본 W×H) |
|---|---|---|---|
0 | 없음 | (x, y) | W × H |
90 | rotate(90deg) | (1 − y, x) | H × W |
180 | rotate(180deg) | (1 − x, 1 − y) | W × H |
270 | rotate(270deg) | (y, 1 − x) | H × W |
SVG의 transform 속성은 CSS와 문법이 다릅니다. 각도에 단위를 붙일 수 없고(rotate(90)), scaleX()가 없으며
(scale(-1 1)), 회전 기준점이 요소 중심이 아니라 사용자 좌표계 원점입니다. 속성에 rotate(90deg)처럼 쓰면
브라우저가 속성 전체를 무시해 오버레이가 누운 채로 남습니다. 속성으로 걸 때는 아래 예제처럼
translate(VW/2 VH/2) rotate(θ) translate(-W/2 -H/2) 형태를 쓰고, CSS 문법을 그대로 쓰려면 style="transform: …"로
겁니다.
구현은 둘 중 하나를 고릅니다.
- 사진과 폴리곤을 한 그룹에 넣고 그룹을 회전합니다. 좌표를 건드리지 않아 가장 단순합니다. 아래 예제가 이 방식입니다.
- 이미 세워 둔 사진 위에 그릴 때는 위 표의 매핑으로 폴리곤 좌표만 옮깁니다. 라벨 위치를 계산할 때도 이 매핑을 씁니다.
tread_roi.bbox_xyxy_norm은 점이 아니라 bbox입니다. 위 매핑(그리고 뒤에 나오는 좌우 반전)을 [x1, y1]·[x2, y2]에
그대로 적용하면 두 점의 순서가 뒤바뀌어 x1 > x2나 y1 > y2가 나올 수 있습니다. 두 꼭짓점을 각각 옮긴 뒤 다시
min/max를 취하세요. 반전만 있고 회전이 없을 때(θ = 0)도 x가 뒤집히므로 똑같이 필요합니다.
// map: 아래 구현 예시의 헬퍼 (회전 → 반전을 모두 적용한다)
const [a, b] = [[x1, y1], [x2, y2]].map(map);
const roi = [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[0], b[0]), Math.max(a[1], b[1])];같은 사진을 두 방향으로 보내면 매핑을 직접 확인할 수 있습니다. tread-a-6mm.json은
정방향 사진의 응답이고, tread-a-6mm-rot90.json은 같은 사진을 시계 방향으로
90° 돌려 보낸 응답(tire_rotation: 270)입니다. 뒤쪽 좌표에 270° 매핑을 적용하면 앞쪽 폴리곤과 같은 그루브 위에
겹칩니다. 두 응답은 각각 독립된 추론이라 꼭짓점 개수와 위치는 조금 다르므로, 점 단위 동등 비교가 아니라 그루브
단위로 겹치는지 확인하세요.
EXIF 회전 태그
좌표는 엔진이 디코딩한 픽셀 배열 기준입니다. EXIF 회전 태그가 있는 사진은 화면과 엔진이 서로 다른 방향의 픽셀을 볼 수 있습니다. 오버레이가 통째로 어긋나면 먼저 이 점을 확인하세요. 태그를 픽셀에 구워 넣은 사진을 보내고 같은 사진을 표시하면 이 문제가 없습니다.
차량 안쪽·바깥쪽 맞추기 — vehicle_side
똑바로 세운 사진에서 vehicle_side: "left"이면 화면 왼쪽이 차량 안쪽(안쪽 숄더), 오른쪽이 바깥쪽 숄더입니다.
"right"는 반대입니다. 같은 타이어라도 촬영자가 타이어 앞에서 찍었는지 뒤에서 찍었는지에 따라 좌우가 바뀌므로,
엔진이 사진마다 판단해 알려줍니다.
ATRACE 리포트의 표시 규칙은 네 타이어를 나란히 놓았을 때 바깥쪽 숄더가 차량 바깥쪽을 향하게 하는 것입니다. 왼쪽
타이어(T1 앞 왼쪽, T3 뒤 왼쪽)는 바깥쪽 숄더가 화면 왼쪽에, 오른쪽 타이어(T2 앞 오른쪽, T4 뒤 오른쪽)는 화면
오른쪽에 오게 합니다. 이 규칙을 권장 기본값으로 제시합니다. 다른 배치를 쓰려면 vehicle_side의 의미만
가져가 자체 규칙을 만들면 됩니다.
타이어 위치별 필요한 연산
호출자가 알고 있는 타이어 위치와 엔진이 돌려준 vehicle_side·tire_rotation 으로 아래 표에서 연산을
고릅니다. 회전은 위치와 무관하게 항상 tire_rotation(θ)만큼 시계 방향이고, 반전 여부만 위치와 vehicle_side로
정해집니다.
| 타이어 위치 | vehicle_side: "left" | vehicle_side: "right" | 세운 뒤 배열 순서 (화면 기준) |
|---|---|---|---|
| T1 · 앞 왼쪽 (FL) | θ 회전 + 반전 scaleX(-1) rotate(θdeg) | θ 회전 rotate(θdeg) | 왼쪽 → 오른쪽 |
| T2 · 앞 오른쪽 (FR) | θ 회전 rotate(θdeg) | θ 회전 + 반전 scaleX(-1) rotate(θdeg) | 오른쪽 → 왼쪽 |
| T3 · 뒤 왼쪽 (RL) | θ 회전 + 반전 scaleX(-1) rotate(θdeg) | θ 회전 rotate(θdeg) | 왼쪽 → 오른쪽 |
| T4 · 뒤 오른쪽 (RR) | θ 회전 rotate(θdeg) | θ 회전 + 반전 scaleX(-1) rotate(θdeg) | 오른쪽 → 왼쪽 |
요약하면 왼쪽 타이어는 vehicle_side가 left일 때, 오른쪽 타이어는 right일 때 반전합니다. θ가 0이면
rotate(0deg)는 생략합니다. 정규화하고 나면 왼쪽 타이어는 배열 순서가 화면 왼쪽 → 오른쪽, 오른쪽 타이어는
오른쪽 → 왼쪽이 됩니다. 값이 left·right가 아니면 반전하지 않습니다.
반전은 회전을 끝낸 프레임에서 합니다
vehicle_side는 똑바로 세운 사진 기준이므로 반전은 회전 뒤에 적용해야 합니다. CSS와 SVG 모두 transform은
오른쪽 함수부터 적용합니다. CSS는 transform: scaleX(-1) rotate(θdeg)이고, SVG transform 속성은 같은 순서를
translate(VW 0) scale(-1 1) translate(VW/2 VH/2) rotate(θ) translate(-W/2 -H/2)로 씁니다(아래 예제). 순서를
바꿔 rotate(θdeg) scaleX(-1)처럼 쓰면 θ가 90·270일 때 결과가 180° 어긋납니다(상하와 좌우가 모두 뒤집힘).
θ가 0·180일 때는 두 순서의 결과가 같아 테스트에서 놓치기 쉽습니다.
인터랙티브 데모
아래 데모는 docs 샘플 사진과 AI Engine의 실제 응답으로 위 규칙을 그립니다. 타이어 위치를 바꾸면 데모 안의 표에서 해당 칸이 강조되고, 회전을 끄면 폴리곤은 여전히 사진에 정렬되지만 사진이 누운 채로 남고, 반전을 끄면 바깥쪽 숄더가 차량 안쪽을 향합니다.
엔진 응답
- tire_rotation
- …
- vehicle_side
- …
- individual_remaining_depth
- …
- groove_masks
- …
이 조합에 필요한 연산
- 시계 방향
0°회전 (회전 없음) - 좌우 반전: 불필요 — FR(오른쪽 타이어) +
vehicle_side=null
CSS: transform: none
SVG: transform=""
| 위치 | vehicle_side=left | vehicle_side=right |
|---|---|---|
| FL (T1 · 앞 왼쪽) | rotate(θ) + 반전 | rotate(θ) |
| FR (T2 · 앞 오른쪽) | rotate(θ) | rotate(θ) + 반전 |
| RL (T3 · 뒤 왼쪽) | rotate(θ) + 반전 | rotate(θ) |
| RR (T4 · 뒤 오른쪽) | rotate(θ) | rotate(θ) + 반전 |
θ = tire_rotation. 반전은 회전을 끝낸 프레임에서 적용합니다.
구현 예시 — HTML + SVG
사진과 폴리곤을 같은 <g>에 넣고 그 그룹에 "회전 → 반전" transform을 겁니다. 라벨은 그룹 밖에 그려 글자가
돌아가거나 뒤집히지 않게 합니다. 위 데모가 이 코드와 같은 규칙으로 그립니다.
<div id="tread"></div>
<script>
// data : POST /v2/tire/tread 응답의 data 객체
// position : 호출자가 알고 있는 타이어 위치 (front_left · front_right · rear_left · rear_right)
function renderTreadOverlay(container, imageUrl, data, position) {
const img = new Image();
img.onload = () => {
const W = img.naturalWidth;
const H = img.naturalHeight;
const rot = data.tire_rotation || 0; // 0 · 90 · 180 · 270
const side = data.vehicle_side;
const isLeftTire = position.endsWith('_left');
// ATRACE 표시 규칙: 바깥쪽 숄더가 차량 바깥쪽을 향하게 한다
const mirror =
side === 'left' || side === 'right'
? isLeftTire ? side === 'left' : side === 'right'
: false;
// 세운 뒤 크기: 90·270이면 가로세로가 바뀐다
const swap = rot === 90 || rot === 270;
const VW = swap ? H : W;
const VH = swap ? W : H;
// ① 회전 → ② 반전. transform은 오른쪽부터 적용되므로 문자열에서는 반전이 앞에 온다.
const rotateT = rot
? `translate(${VW / 2} ${VH / 2}) rotate(${rot}) translate(${-W / 2} ${-H / 2})`
: '';
const mirrorT = mirror ? `translate(${VW} 0) scale(-1 1)` : '';
const groupT = [mirrorT, rotateT].filter(Boolean).join(' ');
// ATRACE 등급 A/B/C 3구간 (≥5 초록, 3 초과 5 미만 주황, ≤3 빨강)
const color = (mm) =>
mm == null ? '#94a3b8' : mm <= 3 ? '#ef4444' : mm < 5 ? '#f59e0b' : '#22c55e';
// 라벨 위치용: 정규화 좌표를 세운 뒤 프레임으로 옮긴다 (회전 → 반전)
const map = ([x, y]) => {
const p =
rot === 90 ? [1 - y, x] : rot === 180 ? [1 - x, 1 - y] : rot === 270 ? [y, 1 - x] : [x, y];
return mirror ? [1 - p[0], p[1]] : p;
};
// 폴리곤이 비어 있는 그루브는 그리지 않되, 배열 인덱스는 그대로 유지한다
const masks = (data.groove_masks || [])
.map((m, index) => ({ ...m, index }))
.filter((m) => m.polygon.length >= 3);
const fs = Math.round(Math.min(VW, VH) * 0.04);
const polygons = masks
.map(
(m) =>
`<polygon points="${m.polygon.map(([x, y]) => `${x * W},${y * H}`).join(' ')}"
fill="${color(m.depth_mm)}" fill-opacity="0.4"
stroke="${color(m.depth_mm)}" stroke-width="${W * 0.003}"/>`,
)
.join('');
// 라벨은 bbox 중심에 두되, 앞서 놓인 라벨과 실제로 겹칠 때만 위·아래로 번갈아 비켜 놓는다
const placed = [];
masks
.map((m) => {
const pts = m.polygon.map(map);
const xs = pts.map((p) => p[0]);
const ys = pts.map((p) => p[1]);
const text = m.depth_mm == null ? '-' : `${m.depth_mm.toFixed(1)}mm`;
return {
x: ((Math.min(...xs) + Math.max(...xs)) / 2) * VW,
y: ((Math.min(...ys) + Math.max(...ys)) / 2) * VH,
w: text.length * fs * 0.62 + fs, // 글자 길이에 맞춘 배지 폭
text,
};
})
.sort((a, b) => a.x - b.x) // 배열 순서는 바깥쪽 → 안쪽이라 화면 x 순서와 다를 수 있다
.forEach((label, i) => {
const hits = (y) =>
placed.some(
(p) => Math.abs(label.x - p.x) < (label.w + p.w) / 2 && Math.abs(y - p.y) < fs * 1.5,
);
let y = label.y;
for (const step of [0, 1, -1, 2, -2]) {
const candidate = label.y + (i % 2 ? 1 : -1) * step * fs * 1.9;
if (!hits(candidate)) {
y = candidate;
break;
}
}
placed.push({ ...label, y });
});
const labels = placed
.map(
(l) => `<rect x="${l.x - l.w / 2}" y="${l.y - fs * 0.75}" width="${l.w}" height="${fs * 1.5}"
rx="${fs * 0.2}" fill="rgba(2,6,23,0.8)"/>
<text x="${l.x}" y="${l.y}" fill="#fff" font-size="${fs}" font-weight="700"
text-anchor="middle" dominant-baseline="central">${l.text}</text>`,
)
.join('');
container.innerHTML = `
<svg viewBox="0 0 ${VW} ${VH}" style="display:block;width:100%;height:auto">
<g transform="${groupT}">
<image href="${imageUrl}" width="${W}" height="${H}"/>
${polygons}
</g>
${labels}
</svg>`;
};
img.src = imageUrl;
}
// 사용 예
fetch('https://docs.atrace.ai/samples/tire/analysis/tread-b-4mm.json')
.then((r) => r.json())
.then((body) =>
renderTreadOverlay(
document.getElementById('tread'),
'https://docs.atrace.ai/samples/tire/tread-b-4mm.jpg',
body.data,
'front_right',
),
);
</script>viewBox를 원본 픽셀 크기로 두면 정규화 좌표에 가로·세로만 곱해 그대로 쓸 수 있고, 컨테이너 크기가 바뀌어도
정렬이 유지됩니다. 회전이 없고 반전도 없는 경우에만 viewBox="0 0 1 1"과 preserveAspectRatio="none"으로
정규화 좌표를 변환 없이 넣는 방법도 씁니다.
<img> 위에 오버레이를 겹치는 구조라면 두 요소를 감싼 컨테이너에 transform: scaleX(-1) rotate(θdeg)를
같은 순서로 걸고, 라벨은 컨테이너 밖에 두면 됩니다.
CSS transform은 레이아웃 박스를 바꾸지 않습니다
transform은 그려지는 모습만 돌릴 뿐, 컨테이너가 차지하는 박스는 회전 전 W × H 그대로입니다. θ가
90·270이면 회전 결과(H × W)가 그 박스를 넘어가 옆 요소와 겹치고, 조상에 overflow: hidden이나 고정
종횡비가 걸려 있으면 잘립니다. 바깥 래퍼를 회전 뒤 종횡비로 두고, 안쪽 컨테이너에 회전 전 크기를 주어
중앙에 놓으세요. 회전 값이 사진마다 달라지는 화면이라면 크기 스왑을 viewBox가 알아서 처리하는 위 SVG 방식이
더 간단합니다.
/* θ = 90 · 270 — W, H는 원본 픽셀 크기 */
.wrap {
position: relative;
aspect-ratio: H / W; /* 회전 뒤 프레임 */
}
.wrap > .inner {
position: absolute;
inset: 0;
margin: auto; /* 회전 전 크기의 박스를 래퍼 중앙에 */
width: calc(100% * W / H);
height: calc(100% * H / W);
transform: scaleX(-1) rotate(θdeg); /* 순서는 그대로 */
}
/* θ = 0 · 180 이면 래퍼는 aspect-ratio: W / H 이고 크기 보정이 필요 없습니다 */색·라벨 권장값
시각화 전용 권장값입니다. 서비스의 교체 권고 기준과는 별개이며 자체 기준으로 바꿔도 됩니다.
| 구간 | 조건 | 색 |
|---|---|---|
| A (양호) | depth_mm ≥ 5 | #22c55e |
| B (주의) | 3 < depth_mm < 5 | #f59e0b |
| C (위험) | depth_mm ≤ 3 | #ef4444 |
| 깊이 없음 | depth_mm가 null | #94a3b8, 라벨 - |
- 이 구간은 AI Engine API 레퍼런스의 트레드 등급(A ≥ 5, B는 3 초과 5 미만, C ≤ 3)과 같은 기준입니다.
- 채움 투명도는 0.35~0.45가 사진과 색을 함께 보기에 알맞습니다.
- 라벨은
{depth_mm:.1f}mm형식으로, 변환을 마친 폴리곤의 bbox 중심에 둡니다. 라벨끼리 겹치면 위·아래로 번갈아 비켜 놓습니다. - 라벨 텍스트는 transform 대상에 넣지 마세요. 반전하면 글자가 거울상이 됩니다.
흔한 실수
- 사진만 돌리고 폴리곤은 그대로 둡니다(또는 그 반대). 둘은 같은 프레임이므로 항상 같이 변환해야 합니다.
- 반전을 회전 전에 적용합니다 (
rotate(θdeg) scaleX(-1)). θ가90·270일 때 180° 어긋납니다. - 라벨을 transform 안에 넣습니다. 글자가 뒤집히거나 눕습니다.
individual_remaining_depth를 사진의 왼쪽 → 오른쪽 순서로 해석합니다. 순서는 차량 바깥쪽 → 안쪽입니다.score로 그루브를 걸러냅니다. 배열에서 원소를 빼면individual_remaining_depth와 인덱스가 어긋납니다.- 점 개수나 닫힘을 가정합니다. 점 개수는 그루브마다 다르고 첫 점과 끝 점이 다릅니다.
[]도 처리해야 합니다. - 그루브 개수를 고정으로 가정합니다. 같은 타이어라도 사진마다 인식되는 그루브 수가 달라질 수 있으므로 방문 간 비교는 인덱스가 아니라 대표값(평균·최솟값)으로 해야 합니다.
- 화면 이미지와 보낸 바이트가 다릅니다. 리사이즈는 괜찮지만(정규화 좌표), 크롭이나 EXIF 방향 차이는 좌표를 어긋나게 합니다.
tread_roibbox의 두 꼭짓점만 변환하고 min/max를 다시 취하지 않습니다. 회전·반전 뒤x1 > x2·y1 > y2가 되어<rect>가 사라집니다.