분석 결과 시각화 가이드

트레드 그루브 오버레이

AI Engine API가 돌려주는 groove_masks·tire_rotation·vehicle_side로 트레드 사진 위에 그루브 깊이를 그리는 방법을 설명합니다.

POST /v2/tire/tread는 그루브별 잔여 깊이와 함께 그루브 영역 폴리곤(groove_masks) 과, 사진을 똑바로 세우고 좌우 방향을 맞추는 데 필요한 tire_rotation·vehicle_side 를 돌려줍니다. 이 페이지는 이 세 값을 어떤 순서로 적용해 자체 화면에 오버레이를 그리는지 설명합니다.

핵심 세 가지

  1. 폴리곤 좌표는 보낸 사진 그대로(회전 전) 의 정규화 좌표입니다. 사진을 돌려 표시하면 폴리곤도 같이 돌려야 합니다.
  2. tire_rotation은 사진을 똑바로 세우기 위해 시계 방향으로 돌려야 할 각도입니다.
  3. 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_rotationCSS transform정규화 좌표 매핑 (x, y) →세운 뒤 크기 (원본 W×H)
0없음(x, y)W × H
90rotate(90deg)(1 − y, x)H × W
180rotate(180deg)(1 − x, 1 − y)W × H
270rotate(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의 실제 응답으로 위 규칙을 그립니다. 타이어 위치를 바꾸면 데모 안의 표에서 해당 칸이 강조되고, 회전을 끄면 폴리곤은 여전히 사진에 정렬되지만 사진이 누운 채로 남고, 반전을 끄면 바깥쪽 숄더가 차량 안쪽을 향합니다.

◀ ?? ▶
샘플 불러오는 중…

사진 · 응답 원문 tread-b-4mm.json

타이어 위치 (호출자가 알고 있는 값)
적용

엔진 응답

tire_rotation
…
vehicle_side
…
individual_remaining_depth
…
groove_masks
…

이 조합에 필요한 연산

  1. 시계 방향 0° 회전 (회전 없음)
  2. 좌우 반전: 불필요 — FR(오른쪽 타이어) + vehicle_side=null

CSS: transform: none

SVG: transform=""

위치vehicle_side=leftvehicle_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 대상에 넣지 마세요. 반전하면 글자가 거울상이 됩니다.

흔한 실수

  1. 사진만 돌리고 폴리곤은 그대로 둡니다(또는 그 반대). 둘은 같은 프레임이므로 항상 같이 변환해야 합니다.
  2. 반전을 회전 전에 적용합니다 (rotate(θdeg) scaleX(-1)). θ가 90·270일 때 180° 어긋납니다.
  3. 라벨을 transform 안에 넣습니다. 글자가 뒤집히거나 눕습니다.
  4. individual_remaining_depth를 사진의 왼쪽 → 오른쪽 순서로 해석합니다. 순서는 차량 바깥쪽 → 안쪽입니다.
  5. score로 그루브를 걸러냅니다. 배열에서 원소를 빼면 individual_remaining_depth와 인덱스가 어긋납니다.
  6. 점 개수나 닫힘을 가정합니다. 점 개수는 그루브마다 다르고 첫 점과 끝 점이 다릅니다. []도 처리해야 합니다.
  7. 그루브 개수를 고정으로 가정합니다. 같은 타이어라도 사진마다 인식되는 그루브 수가 달라질 수 있으므로 방문 간 비교는 인덱스가 아니라 대표값(평균·최솟값)으로 해야 합니다.
  8. 화면 이미지와 보낸 바이트가 다릅니다. 리사이즈는 괜찮지만(정규화 좌표), 크롭이나 EXIF 방향 차이는 좌표를 어긋나게 합니다.
  9. tread_roi bbox의 두 꼭짓점만 변환하고 min/max를 다시 취하지 않습니다. 회전·반전 뒤 x1 > x2·y1 > y2가 되어 <rect>가 사라집니다.

다음 단계

On this page