# AI 꾸러미 — AI 픽셀풍 그림 정리 (격자 · 팔레트 되찾기) — Pixel art cleanup (grid detection · downsampling)
> AI 가 그린 픽셀풍 그림에서 격자 간격을 찾아 칸마다 대표 색을 뽑고 색을 수십 가지로 줄여, 번진 그림을 진짜 도트 그림으로 되돌린다.  
> 견본: https://ai-techstudio.web.app/#t/i500

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: AI 픽셀풍 그림 정리 (격자 · 팔레트 되찾기) — Pixel art cleanup (grid detection · downsampling)

#### 1. 목표
AI 로 뽑은 캐릭터 도트 그림을 진짜 도트 그림으로 정리하는 도구를 만들어 줘 — 격자를 찾아 칸 크기로 줄이고, 색을 줄이고, 배경을 지운다. 결과는 칸 크기 그대로 PNG (게임에서 정수 배로 키워 쓰기).

#### 2. 핵심 기술 용어
- **Pixel art cleanup (grid detection · downsampling)** — 픽셀 그림 정리 — 격자 찾기 · 칸 단위로 줄이기
- **Color quantization (k-means in CIELAB)** — 색 줄이기 — 사람 눈 기준 색 공간에서 k-평균
- **Median cell sampling** — 칸 가운데 화소들의 중앙값으로 대표 색
- **Background removal (flood fill · halo cleanup)** — 배경 지우기 — 가장자리에서 채우기 · 흰 테두리 찌꺼기 걷기

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 칸 크기를 사람이 넣지 않아도 되게 격자 간격 · 시작점을 자동으로 찾기 (4~16px 범위를 모두 시험)
- 대표 색은 칸 가장자리를 버린 중앙값으로 (평균 금지 — 탁해진다)
- 색 줄이기는 배경을 뺀 그림 칸만으로, RGB 가 아니라 CIELAB 거리로
- 흰 배경 찌꺼기(테두리 · 갇힌 흰 칸)까지 지우되, 옷깃 · 눈 흰자 같은 작은 밝은 칸은 남기기
- 단계별 결과를 나란히 보여 주기 (원본 · 격자 · 칸 · 색 줄임 · 배경 지움)

#### 5. 완성 기준 (이게 보이면 성공)
- 찾은 격자 선을 원본 위에 겹치면 칸 경계와 정확히 맞는다
- 결과 그림을 정수 배로 키우면 칸 크기가 모두 같고 경계가 번지지 않는다
- 색 수 슬라이더를 줄여도 금빛 · 초록 같은 작은 장식 색이 회색으로 빠지지 않는다
- 어두운 바탕 위에 올려도 흰 점 테두리가 보이지 않는다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- AI 픽셀풍 그림은 큰 그림(예: 2048px)에 칸(예: 8px)을 흉내 낸 것이라, 칸 경계가 번지고 색이 수천 가지로 섞여 있다.
- 격자 찾기: 이웃 화소 색 차이를 세로줄 · 가로줄마다 더하면 칸 경계에서 값이 튄다 — 간격 p · 시작 o 를 바꿔 가며 경계 세기가 가장 큰 것을 고른다.
- 칸 대표 색: 칸 가장자리(번진 곳)를 버리고 가운데 화소들의 중앙값 — 평균은 탁해진다.
- 색 줄이기: 그림 칸만 놓고 CIELAB 공간에서 k-평균. 채도가 큰 칸에 무게를 주면 금빛 · 초록 장식처럼 수가 적은 색도 살아남는다.
- 배경: 가장자리에서 이어진 밝은 칸 + 고리 안쪽에 갇힌 밝은 덩어리 + 배경에 닿은 밝은 테두리(흰 바탕 찌꺼기)를 지운다.

## 핵심 코드 — 격자 간격 · 시작점 찾기 (세로 경계 기준)
(발췌: demos/demosPixelArt.ts findGrid() 를 정리)
```ts
// d: RGBA 화소, w · h: 크기. 세로줄마다 「왼쪽 이웃과의 색 차이」 합
function findGridX(d: Uint8ClampedArray, w: number, h: number) {
  const col = new Float64Array(w);
  for (let y = 0; y < h; y++)
    for (let x = 0; x < w - 1; x++) {
      const i = (y * w + x) * 4;
      col[x] += Math.abs(d[i] - d[i + 4]) + Math.abs(d[i + 1] - d[i + 5]) + Math.abs(d[i + 2] - d[i + 6]);
    }
  const mean = col.reduce((a, b) => a + b, 0) / w;
  let best = { p: 8, o: 0, score: 0 };
  for (let p = 4; p <= 16; p++)
    for (let o = 0; o < p; o++) {
      let s = 0, k = 0;
      for (let x = o; x < w - 1; x += p) { s += col[x]; k++; }
      const score = s / k / mean; // 경계가 칸 사이에 몰려 있으면 평균보다 몇 배 크다
      if (score > best.score) best = { p, o, score };
    }
  // 경계는 칸 끝 화소와 다음 칸 첫 화소 사이 → 칸 시작은 o + 1
  return { p: best.p, start: (best.o + 1) % best.p, score: best.score };
}
```

## 흔한 실수 · 확인 목록
- [ ] **흰 배경을 가장자리에서만 채우면 고리 안쪽 흰 칸이 남는다** — 팔 · 띠 고리 안쪽처럼 갇힌 밝은 덩어리도 8칸 이상이면 배경으로. 작은 덩어리(눈 흰자 · 옷깃)는 남긴다.
- [ ] **배경을 지워도 머리카락 끝에 흰 점 테두리가 남는다** — AI 가 흰 바탕 위에 그려 경계 칸이 밝게 섞였다 — 배경에 닿은 아주 밝은 칸을 한두 번 더 걷는다.
- [ ] **RGB 로 색을 줄이면 금빛 · 초록 장식이 회색으로 빠진다** — CIELAB 거리 + 채도에 무게를 준 k-평균, 그리고 배경 칸은 빼고 계산한다.
- [ ] **칸 평균색을 쓰면 그림이 탁해진다** — 번진 가장자리를 버리고 가운데 화소들의 중앙값을 쓴다.

## 완성 기준 체크리스트
- [ ] 찾은 격자 선을 원본 위에 겹치면 칸 경계와 정확히 맞는다
- [ ] 결과 그림을 정수 배로 키우면 칸 크기가 모두 같고 경계가 번지지 않는다
- [ ] 색 수 슬라이더를 줄여도 금빛 · 초록 같은 작은 장식 색이 회색으로 빠지지 않는다
- [ ] 어두운 바탕 위에 올려도 흰 점 테두리가 보이지 않는다

## 이 기술 정보
- id: `i500` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 한 번만 하는 정리. 2048px 그림 격자 찾기는 수십 ms, 256×256 칸 k-평균(40색 · 12번)은 1초 안쪽. 게임 실행 중이 아니라 미리 해 두는 일.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i500
- 쓰면 좋을 때: AI 이미지 도구로 뽑은 도트 캐릭터 · 아이템을 게임에 넣기 전 / 도트 그림을 애니메이션(i501)하기 전 — 칸이 고르지 않으면 움직일 때 깨진다
- 쓰지 말 때: 처음부터 사람이 칸 단위로 그린 그림 — 이미 깨끗하다 / 사진 · 매끈한 그림을 도트로 바꾸기 — 그건 픽셀화(모자이크 + 디더)라는 다른 일이다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i500 견본 항목 — `src/demos/demosPixelArt.ts:265`
```ts
  i500: {
    kind: 'dom',
    caption: 'AI 가 그린 「픽셀풍」 그림(번진 칸 · 색 수천 가지) → 격자 찾기 → 칸 대표 색 → 색 줄이기 → 배경 지우기',
    make(box) {
      const { cv, g } = fitCanvas(box, '#16131f');
      let raw: { w: number; h: number; d: Uint8ClampedArray } | null = null;
      let grid = { p: 8, ox: 0, oy: 0, score: 0 };
      let cells: { cw: number; ch: number; c: Uint8ClampedArray } | null = null;
      let K = 24;
      let quant: { out: Uint8ClampedArray; pal: [number, number, number][] } | null = null;
      let clean: Uint8ClampedArray | null = null;
      const nColors = { raw: 0, cells: 0 };
      const rawC = document.createElement('canvas');
      const smallC = document.createElement('canvas');
      let step = 0;
      let auto = true;
      let timer = 0;
      const rebuild = (): void => {
        if (!cells) return;
        quant = quantize(cells.c, cells.cw * cells.ch, K);
        clean = new Uint8ClampedArray(quant.out);
        removeBg(clean, cells.cw, cells.ch);
      };
      void loadPixels(URL_RAW).then((r) => {
        raw = r;
        rawC.width = r.w;
        rawC.height = r.h;
        rawC.getContext('2d')!.putImageData(new ImageData(new Uint8ClampedArray(r.d), r.w, r.h), 0, 0);
        grid = findGrid(r.d, r.w, r.h);
        cells = cellColors(r.d, r.w, r.h, grid.p, grid.ox, grid.oy);
        nColors.raw = countColors(r.d);
        nColors.cells = countColors(cells.c);
        rebuild();
      });
      const putSmall = (d: Uint8ClampedArray): HTMLCanvasElement => {
        smallC.width = cells!.cw;
        smallC.height = cells!.ch;
        smallC.getContext('2d')!.putImageData(new ImageData(new Uint8ClampedArray(d), cells!.cw, cells!.ch), 0, 0);
        return smallC;
      };
      const controls: Control[] = [
        { type: 'button', label: '다음 단계 ▶', on: () => { auto = false; step = (step + 1) % STEPS.length; } },
        { type: 'toggle', label: '저절로 넘기기', value: true, on: (v) => { auto = v; } },
        { type: 'range', label: '줄인 색 수', min: 4, max: 48, step: 1, value: 24, on: (v) => { K = v; rebuild(); if (!auto) step = Math.max(step, 3); } },
      ];
      return {
        controls,
        update(_t, dt) {
          const [w, h] = sizeTo(cv);
          g.imageSmoothingEnabled = false;
          g.fillStyle = step === 4 ? '#2a2236' : '#16131f';
          g.fillRect(0, 0, w, h);
          if (!raw || !cells || !quant || !clean) {
            g.fillStyle = '#aaa';
            g.font = `600 ${Math.round(h * 0.05)}px ${FONT}`;
            g.textAlign = 'center';
            g.fillText('그림 정리하는 중…', w / 2, h / 2);
            return;
          }
          if (auto) {
            timer += dt;
            if (timer > 2.6) {
              timer = 0;
              step = (step + 1) % STEPS.length;
            }
          }
          // 왼쪽: 그림 (정사각) · 오른쪽: 확대 돋보기 + 설명
          const side = Math.min(h, w * 0.58);
          const x0 = Math.round((w * 0.6 - side) / 2);
          const y0 = Math.round((h - side) / 2);
          const zoomSrc = { x: 0.36, y: 0.28, s: 0.22 }; // 얼굴 근처 (그림 비율)
          const draw = (dx: number, dy: number, ds: number, sx: number, sy: number, ss: number): void => {
            if (step === 0 || step === 1) g.drawImage(rawC, sx * raw!.w, sy * raw!.h, ss * raw!.w, ss * raw!.h, dx, dy, ds, ds);
            else {
              const src = putSmall(step === 2 ? cells!.c : step === 3 ? quant!.out : clean!);
              g.drawImage(src, sx * cells!.cw, sy * cells!.ch, ss * cells!.cw, ss * cells!.ch, dx, dy, ds, ds);
            }
            if (step === 1) {
              // 찾은 격자 선
              const k = ds / (ss * raw!.w);
              g.strokeStyle = 'rgba(80,200,255,0.55)';
              g.lineWidth = 1;
              g.beginPath();
              for (let x = grid.ox; x < raw!.w; x += grid.p) {
                const X = dx + (x - sx * raw!.w) * k;
                if (X >= dx && X <= dx + ds) {
                  g.moveTo(Math.round(X) + 0.5, dy);
                  g.lineTo(Math.round(X) + 0.5, dy + ds);
                }
              }
              for (let y = grid.oy; y < raw!.h; y += grid.p) {
                const Y = dy + (y - sy * raw!.h) * k;
                if (Y >= dy && Y <= dy + ds) {
                  g.moveTo(dx, Math.round(Y) + 0.5);
                  g.lineTo(dx + ds, Math.round(Y) + 0.5);
                }
              }
              g.stroke();
            }
          };
          draw(x0, y0, side, 0, 0, 1);
          // 확대 돋보기 (작은 견본 카드에선 생략)
          const zx = Math.round(w * 0.62);
          const zs = Math.round(Math.min(w * 0.34, h * 0.5));
          const big = w > 360;
          if (big) {
            draw(zx, y0, zs, zoomSrc.x, zoomSrc.y, zoomSrc.s);
            g.strokeStyle = 'rgba(255,255,255,0.7)';
            g.lineWidth = 2;
            g.strokeRect(zx, y0, zs, zs);
            g.strokeStyle = 'rgba(255,255,255,0.5)';
            g.strokeRect(x0 + zoomSrc.x * side, y0 + zoomSrc.y * side, zoomSrc.s * side, zoomSrc.s * side);
          }
          // 설명
          const fs = Math.max(11, Math.round(h * (big ? 0.045 : 0.07)));
          g.font = `800 ${fs}px ${FONT}`;
          g.textAlign = 'left';
          g.fillStyle = '#fff';
          const tx = big ? zx : x0 + 6;
          let ty = big ? y0 + zs + fs * 1.6 : y0 + fs * 1.3;
          g.fillText(STEPS[step]!, tx, ty);
          g.font = `600 ${Math.round(fs * 0.78)}px ${FONT}`;
          g.fillStyle = 'rgba(255,255,255,0.75)';
          const info = [
            `${raw.w}×${raw.h} 화소 · 색 ${nColors.raw.toLocaleString()}가지 — 칸 경계가 번짐`,
            `간격 ${grid.p}화소 · 시작 (${grid.ox}, ${grid.oy}) — 경계 세기 ${grid.score.toFixed(1)}배`,
            `${cells.cw}×${cells.ch} 칸 · 색 ${nColors.cells.toLocaleString()}가지 — 가장자리 버리고 중앙값`,
            `색 ${quant.pal.length}가지 (Lab k-평균 · 채도에 무게)`,
            '밝은 배경 · 갇힌 흰 덩어리 · 흰 테두리 찌꺼기 제거',
          ];
          if (big) {
            ty += fs * 1.3;
            g.fillText(info[step]!, tx, ty);
          }
          // 팔레트 띠
          if (step >= 3 && big) {
            const pw = zs / Math.max(1, quant.pal.length);
            quant.pal.forEach((p, i) => {
              g.fillStyle = `rgb(${p[0]},${p[1]},${p[2]})`;
              g.fillRect(zx + i * pw, ty + fs * 0.7, Math.ceil(pw), fs * 1.1);
            });
          }
        },
        dispose() {
          box.innerHTML = '';
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [픽셀 아트 확대 (계단 그대로 · 서브픽셀)](https://ai-techstudio.web.app/ai/t/i304.md) `i304`
- 다음에 해 볼 기술: [픽셀 그림 한 장 살리기 (부위 겹 · 칸 단위 움직임)](https://ai-techstudio.web.app/ai/t/i501.md) `i501` · [색 바꾸기 · 팔레트 교체 (2D)](https://ai-techstudio.web.app/ai/t/i296.md) `i296` · [팔레트 색 순환 (그림은 그대로, 색만 흐름)](https://ai-techstudio.web.app/ai/t/i236.md) `i236`
- 참고 문서: [Wikipedia — Color quantization](https://en.wikipedia.org/wiki/Color_quantization) · [Wikipedia — CIELAB color space](https://en.wikipedia.org/wiki/CIELAB_color_space)
