diff --git a/README.md b/README.md index a322e5a4..4010a448 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ pick/place → 데이터와 정책 검증 → 실물 calibration** 순서로 진 | [`ros_arm/`](ros_arm/README.md) | Arduino Uno + SG90/MG90 4축 제어 | `JointState`→USB serial, sequence GUI, firmware와 안전 범위 | | [`onshape/jdcobot100/`](onshape/jdcobot100/) | CAD·mesh·MJCF 자산 | jdcobot100 visual과 MuJoCo reference model | +### PyTorch·컴퓨터 비전 실습 + +| 경로 | 내용 | 공개 범위 | +|---|---|---| +| [`pytorch/resnet_dataset_studio_step1/`](pytorch/resnet_dataset_studio_step1/README.md) | 웹캠 기반 4클래스 × 1,000장 데이터 수집기와 ImageFolder 무결성 검사 | 코드·테스트·카페 공유 ZIP만 포함하며 개인 촬영 이미지는 제외 | + 설계 판단과 중단 조건은 [`project-planning/`](project-planning/)에 남긴다. 원시 dataset이나 실행 영상 대신 사람이 다시 확인할 수 있는 revision, contract, metric과 hash를 기록한다. diff --git a/pytorch/resnet_dataset_studio_step1/.gitignore b/pytorch/resnet_dataset_studio_step1/.gitignore new file mode 100644 index 00000000..fd334de2 --- /dev/null +++ b/pytorch/resnet_dataset_studio_step1/.gitignore @@ -0,0 +1,3 @@ +collected_images/ +integrity_report.json +__pycache__/ diff --git a/pytorch/resnet_dataset_studio_step1/README.md b/pytorch/resnet_dataset_studio_step1/README.md new file mode 100644 index 00000000..bbc28fed --- /dev/null +++ b/pytorch/resnet_dataset_studio_step1/README.md @@ -0,0 +1,70 @@ +# STEP 1 — 웹캠 데이터 수집 + +PDF의 STEP 1을 직접 실행하기 위해 만든 로컬 데이터 수집 도구다. 브라우저의 웹캠과 내 컴퓨터의 Python 저장 서버를 사용하며 이미지는 외부로 전송하지 않는다. + +## 실행 + + conda activate lerobot-vision + cd ~/DAPIER/pytorch/resnet_dataset_studio_step1 + python serve.py --open + +브라우저가 열리지 않으면 http://127.0.0.1:8765 에 접속한다. + +## 수집 순서 + +1. 카메라 시작을 누르고 카메라 권한을 허용한다. +2. class_A부터 class_D까지 실제 라벨 이름으로 변경한다. +3. 데이터 폴더 준비를 눌러 저장 위치와 기존 이미지 수를 확인한다. +4. 객체를 중앙 ROI 안에 크게 배치한다. +5. 누르고 있는 동안 촬영 또는 자동 촬영 시작을 사용한다. +6. 클래스마다 정확히 1,000장을 채운다. + +기본 저장 위치는 이 폴더 아래 collected_images다. 다른 위치를 사용하려면 실행할 때 --output 경로를 추가한다. 저장 구조는 PyTorch datasets.ImageFolder가 바로 읽을 수 있다. + + 선택한_폴더/ + ├── class_A/ + ├── class_B/ + ├── class_C/ + ├── class_D/ + └── collection_state.json + +촬영 결과는 224×224 JPEG다. 흐림 필터는 Laplacian 분산을 사용하고, 중복 필터는 최근 프레임의 dHash를 비교한다. 필터가 너무 엄격하면 화면에서 개별 옵션을 끌 수 있다. + +## 데이터 수집 시 바꿀 조건 + +- 객체의 앞·뒤·옆과 기울어진 각도 +- 카메라와의 거리 +- 밝기와 그림자 +- ROI 내부의 좌·우·위·아래 위치 +- 손으로 잡은 상태와 바닥에 놓은 상태 + +배경만 바뀌고 객체가 거의 움직이지 않는 1,000장은 좋은 데이터가 아니다. 촬영 도중 의도적으로 위 조건을 바꾼다. + +## 무결성 검사 + + python integrity_check.py /path/to/선택한_폴더 \ + --target 1000 \ + --expected-classes 4 \ + --json integrity_report.json + +검사는 클래스 수, 클래스별 장수, 손상 이미지, 64×64 미만 이미지, 완전히 동일한 파일, ImageFolder 로딩을 확인한다. 모든 조건을 만족해야 PASS가 출력된다. + +## 카페 공유본 + +`ResNet_Dataset_Studio_STEP1.zip`은 코드, README, 테스트만 담은 배포본이다. +웹캠으로 촬영한 개인 이미지, `collected_images`, Python 캐시와 PC별 절대 경로는 +포함하지 않았다. + +- 압축 크기: 약 17 KiB +- SHA-256: `72c7e37b8a079ad5222ca78b3d3fbc38f63acb612ade9a95a3394e43fa3e40f9` +- 압축 무결성: `unzip -t` 통과 + +## 확인한 항목 + +- `node --check app.js` +- Python 문법 컴파일 +- `test_integrity_check.py` 단위 테스트 2개 +- 임시 JPEG 4장을 사용한 API 저장·무결성 검사 + +원본 촬영 데이터는 Git에 올리지 않는다. 각 사용자가 직접 수집하고 +`integrity_check.py`로 클래스별 장수와 손상 여부를 확인한다. diff --git a/pytorch/resnet_dataset_studio_step1/ResNet_Dataset_Studio_STEP1.zip b/pytorch/resnet_dataset_studio_step1/ResNet_Dataset_Studio_STEP1.zip new file mode 100644 index 00000000..9e55c871 Binary files /dev/null and b/pytorch/resnet_dataset_studio_step1/ResNet_Dataset_Studio_STEP1.zip differ diff --git a/pytorch/resnet_dataset_studio_step1/app.js b/pytorch/resnet_dataset_studio_step1/app.js new file mode 100644 index 00000000..cbf9b580 --- /dev/null +++ b/pytorch/resnet_dataset_studio_step1/app.js @@ -0,0 +1,568 @@ +"use strict"; + +const DEFAULT_CLASSES = ["class_A", "class_B", "class_C", "class_D"]; +const RECENT_HASH_LIMIT = 50; + +const els = { + video: document.querySelector("#video"), + previewShell: document.querySelector(".preview-shell"), + cameraStatus: document.querySelector("#cameraStatus"), + startCameraBtn: document.querySelector("#startCameraBtn"), + switchCameraBtn: document.querySelector("#switchCameraBtn"), + chooseFolderBtn: document.querySelector("#chooseFolderBtn"), + folderName: document.querySelector("#folderName"), + classList: document.querySelector("#classList"), + targetInput: document.querySelector("#targetInput"), + intervalInput: document.querySelector("#intervalInput"), + blurInput: document.querySelector("#blurInput"), + blurToggle: document.querySelector("#blurToggle"), + duplicateToggle: document.querySelector("#duplicateToggle"), + mirrorToggle: document.querySelector("#mirrorToggle"), + holdCaptureBtn: document.querySelector("#holdCaptureBtn"), + autoCaptureBtn: document.querySelector("#autoCaptureBtn"), + singleCaptureBtn: document.querySelector("#singleCaptureBtn"), + statusMessage: document.querySelector("#statusMessage"), + sessionStats: document.querySelector("#sessionStats"), + captureCanvas: document.querySelector("#captureCanvas"), + qualityCanvas: document.querySelector("#qualityCanvas"), +}; + +const state = { + stream: null, + storageReady: false, + selectedIndex: 0, + facingMode: "user", + captureMode: null, + loopTimer: null, + captureBusy: false, + savedThisSession: 0, + rejectedThisSession: 0, + classes: DEFAULT_CLASSES.map(function (name) { + return { name: name, count: 0, rejected: 0, hashes: [] }; + }), +}; + +function targetCount() { + return Math.max(1, Number.parseInt(els.targetInput.value, 10) || 1000); +} + +function safeClassName(name) { + return /^[\p{L}\p{N}_-]{1,50}$/u.test(name); +} + +function setStatus(message, kind) { + els.statusMessage.textContent = message; + els.statusMessage.style.color = + kind === "error" ? "var(--red)" : kind === "success" ? "var(--mint)" : ""; +} + +function updateSessionStats() { + els.sessionStats.textContent = + "저장 " + state.savedThisSession + " · 제외 " + state.rejectedThisSession; +} + +function renderClasses() { + const target = targetCount(); + els.classList.replaceChildren(); + + state.classes.forEach(function (item, index) { + const row = document.createElement("label"); + row.className = "class-row" + (index === state.selectedIndex ? " is-selected" : ""); + + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "selectedClass"; + radio.checked = index === state.selectedIndex; + radio.addEventListener("change", function () { + stopCapture(); + state.selectedIndex = index; + renderClasses(); + setStatus(item.name + " 촬영 모드"); + }); + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = item.name; + nameInput.setAttribute("aria-label", "클래스 " + (index + 1) + " 이름"); + nameInput.addEventListener("change", async function () { + const nextName = nameInput.value.trim(); + const previousName = item.name; + if (!safeClassName(nextName)) { + nameInput.value = item.name; + setStatus("클래스 이름은 한글·영문·숫자·_·-만 사용할 수 있습니다.", "error"); + return; + } + const isDuplicateName = state.classes.some(function (other, otherIndex) { + return otherIndex !== index && other.name === nextName; + }); + if (isDuplicateName) { + nameInput.value = item.name; + setStatus("클래스 이름은 서로 달라야 합니다.", "error"); + return; + } + stopCapture(); + if (state.storageReady && nextName !== previousName) { + try { + await apiRequest("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/api/rename", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ old: previousName, new: nextName }), + }); + } catch (error) { + nameInput.value = previousName; + setStatus("클래스 이름 변경 실패: " + error.message, "error"); + return; + } + } + item.name = nextName; + item.hashes = []; + if (state.storageReady) { + await prepareStorage(); + } + renderClasses(); + }); + + const count = document.createElement("span"); + count.className = "class-count"; + count.textContent = + item.count.toLocaleString() + " / " + target.toLocaleString(); + + const track = document.createElement("span"); + track.className = "progress-track"; + const fill = document.createElement("span"); + fill.className = "progress-fill"; + fill.style.width = Math.min(100, (item.count / target) * 100) + "%"; + track.append(fill); + + row.append(radio, nameInput, count, track); + els.classList.append(row); + }); +} + +async function startCamera() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setStatus("이 브라우저는 웹캠 API를 지원하지 않습니다.", "error"); + return; + } + + stopCameraTracks(); + try { + state.stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: { ideal: state.facingMode }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + audio: false, + }); + els.video.srcObject = state.stream; + await els.video.play(); + els.previewShell.classList.add("camera-ready"); + els.cameraStatus.textContent = "카메라 준비됨"; + els.cameraStatus.className = "status-pill status-ready"; + els.startCameraBtn.textContent = "카메라 재시작"; + setStatus("중앙 ROI 안에 객체를 크게 배치하세요.", "success"); + } catch (error) { + setStatus("카메라를 열 수 없습니다: " + error.message, "error"); + } +} + +function stopCameraTracks() { + if (!state.stream) { + return; + } + state.stream.getTracks().forEach(function (track) { + track.stop(); + }); + state.stream = null; +} + +async function switchCamera() { + state.facingMode = state.facingMode === "user" ? "environment" : "user"; + await startCamera(); +} + +function classNames() { + return state.classes.map(function (item) { + return item.name; + }); +} + +async function apiRequest(url, options) { + const response = await fetch(url, options); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || "서버 요청 실패"); + } + return payload; +} + +function applyCounts(counts) { + for (const item of state.classes) { + item.count = Number(counts[item.name] || 0); + item.hashes = []; + } + renderClasses(); +} + +async function prepareStorage() { + const names = classNames(); + if (new Set(names).size !== 4 || names.some(function (name) { return !safeClassName(name); })) { + setStatus("서로 다른 유효한 클래스 이름 4개가 필요합니다.", "error"); + return; + } + try { + const payload = await apiRequest("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/api/classes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classes: names }), + }); + state.storageReady = true; + els.folderName.textContent = "저장 위치: " + payload.output; + els.chooseFolderBtn.textContent = "데이터 폴더 새로고침"; + applyCounts(payload.counts); + await saveCollectionState(); + setStatus("기존 이미지 수를 불러왔습니다. 이어서 촬영할 수 있습니다.", "success"); + } catch (error) { + setStatus("데이터 폴더 준비 실패: " + error.message, "error"); + } +} + +async function loadConfig() { + try { + const payload = await apiRequest("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/api/config"); + els.folderName.textContent = "저장 예정 위치: " + payload.output; + } catch (error) { + setStatus("로컬 저장 서버 연결 실패: " + error.message, "error"); + } +} + +function ensureReady() { + if (!state.stream || els.video.readyState < 2) { + setStatus("먼저 카메라를 시작하세요.", "error"); + return false; + } + if (!state.storageReady) { + setStatus("먼저 데이터 폴더 준비를 누르세요.", "error"); + return false; + } + + const item = state.classes[state.selectedIndex]; + if (!safeClassName(item.name)) { + setStatus("유효한 클래스 이름을 입력하세요.", "error"); + return false; + } + if (item.count >= targetCount()) { + setStatus(item.name + "은 이미 목표 수량에 도달했습니다.", "success"); + selectNextIncompleteClass(); + return false; + } + return true; +} + +function drawRoiToCanvas() { + const videoWidth = els.video.videoWidth; + const videoHeight = els.video.videoHeight; + const roiSize = Math.floor(Math.min(videoWidth, videoHeight) * 0.7); + if (roiSize < 64) { + throw new Error("ROI가 64×64보다 작습니다."); + } + + const sourceX = Math.floor((videoWidth - roiSize) / 2); + const sourceY = Math.floor((videoHeight - roiSize) / 2); + const context = els.captureCanvas.getContext("2d", { alpha: false }); + context.save(); + context.clearRect(0, 0, 224, 224); + if (els.mirrorToggle.checked) { + context.translate(224, 0); + context.scale(-1, 1); + } + context.drawImage( + els.video, + sourceX, + sourceY, + roiSize, + roiSize, + 0, + 0, + 224, + 224 + ); + context.restore(); +} + +function analyzeFrame() { + const context = els.qualityCanvas.getContext("2d", { + willReadFrequently: true, + }); + context.drawImage(els.captureCanvas, 0, 0, 32, 32); + const pixels = context.getImageData(0, 0, 32, 32).data; + const gray = new Float32Array(32 * 32); + + for (let index = 0; index < gray.length; index += 1) { + const offset = index * 4; + gray[index] = + pixels[offset] * 0.299 + + pixels[offset + 1] * 0.587 + + pixels[offset + 2] * 0.114; + } + + const laplacians = []; + for (let y = 1; y < 31; y += 1) { + for (let x = 1; x < 31; x += 1) { + const index = y * 32 + x; + laplacians.push( + gray[index - 1] + + gray[index + 1] + + gray[index - 32] + + gray[index + 32] - + 4 * gray[index] + ); + } + } + + const mean = + laplacians.reduce(function (sum, value) { + return sum + value; + }, 0) / laplacians.length; + const blurScore = + laplacians.reduce(function (sum, value) { + return sum + Math.pow(value - mean, 2); + }, 0) / laplacians.length; + + let hash = ""; + for (let y = 0; y < 8; y += 1) { + for (let x = 0; x < 8; x += 1) { + const left = gray[y * 32 + x * 4]; + const right = gray[y * 32 + x * 4 + 3]; + hash += left > right ? "1" : "0"; + } + } + return { blurScore: blurScore, hash: hash }; +} + +function hammingDistance(first, second) { + let distance = 0; + const length = Math.min(first.length, second.length); + for (let index = 0; index < length; index += 1) { + if (first[index] !== second[index]) { + distance += 1; + } + } + return distance + Math.abs(first.length - second.length); +} + +function canvasBlob() { + return new Promise(function (resolve, reject) { + els.captureCanvas.toBlob( + function (blob) { + if (blob) { + resolve(blob); + } else { + reject(new Error("JPEG 변환 실패")); + } + }, + "image/jpeg", + 0.92 + ); + }); +} + +async function captureFrame() { + if (state.captureBusy || !ensureReady()) { + return false; + } + + state.captureBusy = true; + const item = state.classes[state.selectedIndex]; + try { + drawRoiToCanvas(); + const quality = analyzeFrame(); + const blurLimit = Number.parseFloat(els.blurInput.value) || 0; + + if (els.blurToggle.checked && quality.blurScore < blurLimit) { + item.rejected += 1; + state.rejectedThisSession += 1; + setStatus( + "흐림 제외 · 선명도 " + + quality.blurScore.toFixed(1) + + " < " + + blurLimit, + "error" + ); + updateSessionStats(); + return false; + } + + const isDuplicate = + els.duplicateToggle.checked && + item.hashes.some(function (hash) { + return hammingDistance(hash, quality.hash) <= 2; + }); + if (isDuplicate) { + item.rejected += 1; + state.rejectedThisSession += 1; + setStatus( + "최근 이미지와 너무 비슷합니다. 각도나 위치를 바꿔주세요.", + "error" + ); + updateSessionStats(); + return false; + } + + const result = await apiRequest( + "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/api/capture?class=" + + encodeURIComponent(item.name) + + "&target=" + + targetCount(), + { + method: "POST", + headers: { "Content-Type": "image/jpeg" }, + body: await canvasBlob(), + } + ); + + item.count = Number(result.count); + item.hashes.push(quality.hash); + if (item.hashes.length > RECENT_HASH_LIMIT) { + item.hashes.shift(); + } + state.savedThisSession += 1; + renderClasses(); + updateSessionStats(); + setStatus( + item.name + + ": " + + item.count + + " / " + + targetCount() + + " 저장 · 선명도 " + + quality.blurScore.toFixed(1), + "success" + ); + + if (item.count % 25 === 0 || item.count >= targetCount()) { + await saveCollectionState(); + } + if (item.count >= targetCount()) { + stopCapture(); + setStatus( + item.name + " 목표 완료! 다음 미완료 클래스로 이동합니다.", + "success" + ); + selectNextIncompleteClass(); + } + return true; + } catch (error) { + stopCapture(); + setStatus("촬영 실패: " + error.message, "error"); + return false; + } finally { + state.captureBusy = false; + } +} + +function selectNextIncompleteClass() { + const target = targetCount(); + const nextIndex = state.classes.findIndex(function (item, index) { + return index !== state.selectedIndex && item.count < target; + }); + if (nextIndex >= 0) { + state.selectedIndex = nextIndex; + } else { + setStatus("모든 클래스가 목표 수량에 도달했습니다.", "success"); + } + renderClasses(); +} + +async function saveCollectionState() { + if (!state.storageReady) { + return; + } + await apiRequest("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/api/metadata", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + target_per_class: targetCount(), + classes: classNames(), + }), + }); +} + +function startCapture(mode) { + if (state.captureMode || !ensureReady()) { + return; + } + state.captureMode = mode; + els.holdCaptureBtn.classList.toggle("is-recording", mode === "hold"); + els.autoCaptureBtn.classList.toggle("is-recording", mode === "auto"); + els.autoCaptureBtn.textContent = + mode === "auto" ? "자동 촬영 중지" : "자동 촬영 시작"; + captureLoop(); +} + +async function captureLoop() { + if (!state.captureMode) { + return; + } + await captureFrame(); + if (!state.captureMode) { + return; + } + const interval = Math.max( + 80, + Number.parseInt(els.intervalInput.value, 10) || 150 + ); + state.loopTimer = window.setTimeout(captureLoop, interval); +} + +function stopCapture() { + state.captureMode = null; + if (state.loopTimer) { + window.clearTimeout(state.loopTimer); + } + state.loopTimer = null; + els.holdCaptureBtn.classList.remove("is-recording"); + els.autoCaptureBtn.classList.remove("is-recording"); + els.autoCaptureBtn.textContent = "자동 촬영 시작"; +} + +els.startCameraBtn.addEventListener("click", startCamera); +els.switchCameraBtn.addEventListener("click", switchCamera); +els.chooseFolderBtn.addEventListener("click", prepareStorage); +els.singleCaptureBtn.addEventListener("click", captureFrame); +els.autoCaptureBtn.addEventListener("click", function () { + if (state.captureMode === "auto") { + stopCapture(); + } else { + startCapture("auto"); + } +}); +els.holdCaptureBtn.addEventListener("pointerdown", function (event) { + event.preventDefault(); + els.holdCaptureBtn.setPointerCapture(event.pointerId); + startCapture("hold"); +}); +["pointerup", "pointercancel", "pointerleave"].forEach(function (eventName) { + els.holdCaptureBtn.addEventListener(eventName, stopCapture); +}); +els.targetInput.addEventListener("change", renderClasses); +document.addEventListener("keydown", function (event) { + const tagName = document.activeElement.tagName; + if ( + event.code === "Space" && + tagName !== "INPUT" && + tagName !== "BUTTON" + ) { + event.preventDefault(); + captureFrame(); + } +}); +window.addEventListener("beforeunload", function () { + stopCapture(); + stopCameraTracks(); +}); + +renderClasses(); +updateSessionStats(); +loadConfig(); diff --git a/pytorch/resnet_dataset_studio_step1/index.html b/pytorch/resnet_dataset_studio_step1/index.html new file mode 100644 index 00000000..b7c03ecd --- /dev/null +++ b/pytorch/resnet_dataset_studio_step1/index.html @@ -0,0 +1,95 @@ + + + + + + ResNet Dataset Studio + + + +
+
+

STEP 1 · LOCAL DATA COLLECTION

+

ResNet Dataset Studio

+

웹캠 이미지를 4개 클래스별 1,000장씩 수집합니다.

+
+
외부 업로드 없음 · 로컬 저장
+
+ +
+
+
+
+

01 · CAMERA

+

촬영 화면

+
+ 카메라 꺼짐 +
+ +
+ + +
+ 카메라를 시작하세요 + 브라우저 권한 창에서 카메라 사용을 허용합니다. +
+
+ +
+ + + +
+
+ + +
+ +
+ 카메라와 저장 폴더를 준비하면 촬영할 수 있습니다. + 저장 0 · 제외 0 +
+ + + + + + diff --git a/pytorch/resnet_dataset_studio_step1/integrity_check.py b/pytorch/resnet_dataset_studio_step1/integrity_check.py new file mode 100644 index 00000000..aec1053f --- /dev/null +++ b/pytorch/resnet_dataset_studio_step1/integrity_check.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""ImageFolder 데이터셋의 수량, 손상, 해상도, 완전 중복을 검사한다.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + +from PIL import Image, UnidentifiedImageError +from torchvision.datasets import ImageFolder + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} + + +def inspect_dataset(root: Path, target: int, expected_classes: int) -> dict[str, Any]: + class_dirs = sorted( + path for path in root.iterdir() if path.is_dir() and not path.name.startswith(".") + ) + counts: dict[str, int] = {} + invalid: list[str] = [] + too_small: list[str] = [] + hashes: dict[str, list[str]] = {} + + for class_dir in class_dirs: + images = sorted( + path + for path in class_dir.iterdir() + if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS + ) + counts[class_dir.name] = len(images) + for path in images: + try: + with Image.open(path) as image: + image.verify() + with Image.open(path) as image: + if image.width < 64 or image.height < 64: + too_small.append(str(path)) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + hashes.setdefault(digest, []).append(str(path)) + except (OSError, UnidentifiedImageError): + invalid.append(str(path)) + + duplicates = [paths for paths in hashes.values() if len(paths) > 1] + total = sum(counts.values()) + exact_target = bool(counts) and all(count == target for count in counts.values()) + valid = ( + len(class_dirs) == expected_classes + and exact_target + and not invalid + and not too_small + and not duplicates + ) + + imagefolder_classes: list[str] = [] + imagefolder_total = 0 + imagefolder_error = "" + try: + dataset = ImageFolder(root) + imagefolder_classes = dataset.classes + imagefolder_total = len(dataset) + except (FileNotFoundError, RuntimeError) as error: + imagefolder_error = str(error) + valid = False + + return { + "valid": valid, + "root": str(root), + "expected_classes": expected_classes, + "target_per_class": target, + "class_counts": counts, + "total": total, + "invalid_files": invalid, + "too_small_files": too_small, + "exact_duplicate_groups": duplicates, + "imagefolder_classes": imagefolder_classes, + "imagefolder_total": imagefolder_total, + "imagefolder_error": imagefolder_error, + } + + +def print_report(report: dict[str, Any]) -> None: + print(f"데이터셋: {report['root']}") + print(f"클래스: {len(report['class_counts'])} / {report['expected_classes']}") + for name, count in report["class_counts"].items(): + mark = "OK" if count == report["target_per_class"] else "CHECK" + print(f" [{mark}] {name}: {count} / {report['target_per_class']}") + print(f"전체 이미지: {report['total']}") + print(f"손상 파일: {len(report['invalid_files'])}") + print(f"64×64 미만: {len(report['too_small_files'])}") + print(f"완전 중복 그룹: {len(report['exact_duplicate_groups'])}") + print(f"ImageFolder: {report['imagefolder_classes']} ({report['imagefolder_total']}장)") + print("결과:", "PASS" if report["valid"] else "FAIL") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ResNet ImageFolder 무결성 검사") + parser.add_argument("dataset", type=Path) + parser.add_argument("--target", type=int, default=1000) + parser.add_argument("--expected-classes", type=int, default=4) + parser.add_argument("--json", type=Path, help="JSON 결과 저장 경로") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not args.dataset.is_dir(): + print(f"데이터셋 폴더가 없습니다: {args.dataset}") + return 2 + report = inspect_dataset(args.dataset.resolve(), args.target, args.expected_classes) + print_report(report) + if args.json: + args.json.write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return 0 if report["valid"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pytorch/resnet_dataset_studio_step1/serve.py b/pytorch/resnet_dataset_studio_step1/serve.py new file mode 100644 index 00000000..0ea799ca --- /dev/null +++ b/pytorch/resnet_dataset_studio_step1/serve.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""로컬 전용 데이터 수집 웹앱 및 이미지 저장 API 서버.""" + +from __future__ import annotations + +import argparse +import functools +import http.server +import json +import re +import threading +import time +import urllib.parse +import webbrowser +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +CLASS_NAME_PATTERN = re.compile(r"^[\w-]{1,50}$", re.UNICODE) +MAX_IMAGE_BYTES = 5 * 1024 * 1024 + + +class DatasetServer(http.server.ThreadingHTTPServer): + def __init__(self, server_address: tuple[str, int], output_root: Path) -> None: + handler = functools.partial(AppHandler, directory=str(ROOT)) + super().__init__(server_address, handler) + self.output_root = output_root + self.capture_lock = threading.Lock() + + +class AppHandler(http.server.SimpleHTTPRequestHandler): + def end_headers(self) -> None: + self.send_header("Cache-Control", "no-store, max-age=0") + self.send_header("Pragma", "no-cache") + super().end_headers() + + @property + def dataset_server(self) -> DatasetServer: + return self.server # type: ignore[return-value] + + def send_json(self, status: int, payload: dict[str, object]) -> None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def valid_class_name(self, name: str) -> bool: + return bool(CLASS_NAME_PATTERN.fullmatch(name)) + + def image_count(self, class_name: str) -> int: + class_dir = self.dataset_server.output_root / class_name + if not class_dir.is_dir(): + return 0 + return sum( + path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS + for path in class_dir.iterdir() + ) + + def parse_json_body(self) -> dict[str, object]: + length = int(self.headers.get("Content-Length", "0")) + if length <= 0 or length > 100_000: + raise ValueError("잘못된 요청 크기") + payload = json.loads(self.rfile.read(length).decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("JSON 객체가 필요합니다.") + return payload + + def do_GET(self) -> None: + parsed = urllib.parse.urlparse(self.path) + try: + relative_output = self.dataset_server.output_root.relative_to(ROOT) + protected_prefix = "/" + relative_output.as_posix().rstrip("/") + "/" + if parsed.path.startswith(protected_prefix): + self.send_json(404, {"error": "수집 이미지는 HTTP로 제공하지 않습니다."}) + return + except ValueError: + pass + if parsed.path == "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/api/config": + self.send_json( + 200, + { + "output": str(self.dataset_server.output_root), + "max_image_bytes": MAX_IMAGE_BYTES, + }, + ) + return + if parsed.path == "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/api/state": + query = urllib.parse.parse_qs(parsed.query) + classes = [name for name in query.get("classes", [""])[0].split(",") if name] + if not classes or any(not self.valid_class_name(name) for name in classes): + self.send_json(400, {"error": "유효한 클래스 이름이 필요합니다."}) + return + counts = {name: self.image_count(name) for name in classes} + self.send_json(200, {"counts": counts}) + return + super().do_GET() + + def do_POST(self) -> None: + parsed = urllib.parse.urlparse(self.path) + try: + if parsed.path == "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/api/classes": + self.handle_classes() + elif parsed.path == "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/api/rename": + self.handle_rename() + elif parsed.path == "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/api/capture": + self.handle_capture(parsed.query) + elif parsed.path == "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/api/metadata": + self.handle_metadata() + else: + self.send_json(404, {"error": "API를 찾을 수 없습니다."}) + except (ValueError, json.JSONDecodeError) as error: + self.send_json(400, {"error": str(error)}) + except OSError as error: + self.send_json(500, {"error": f"파일 저장 실패: {error}"}) + + def handle_classes(self) -> None: + payload = self.parse_json_body() + classes = payload.get("classes") + if ( + not isinstance(classes, list) + or len(classes) != 4 + or len(set(classes)) != 4 + or any(not isinstance(name, str) or not self.valid_class_name(name) for name in classes) + ): + raise ValueError("서로 다른 클래스 이름 4개가 필요합니다.") + for name in classes: + (self.dataset_server.output_root / name).mkdir(parents=True, exist_ok=True) + counts = {name: self.image_count(name) for name in classes} + self.send_json(200, {"counts": counts, "output": str(self.dataset_server.output_root)}) + + def handle_rename(self) -> None: + payload = self.parse_json_body() + old = payload.get("old") + new = payload.get("new") + if not isinstance(old, str) or not isinstance(new, str): + raise ValueError("이전/새 클래스 이름이 필요합니다.") + if not self.valid_class_name(old) or not self.valid_class_name(new): + raise ValueError("유효하지 않은 클래스 이름입니다.") + old_dir = self.dataset_server.output_root / old + new_dir = self.dataset_server.output_root / new + if new_dir.exists() and any(new_dir.iterdir()): + self.send_json(409, {"error": "새 클래스 폴더에 이미 파일이 있습니다."}) + return + if new_dir.exists(): + new_dir.rmdir() + if old_dir.exists(): + old_dir.rename(new_dir) + else: + new_dir.mkdir(parents=True, exist_ok=True) + self.send_json(200, {"renamed": True}) + + def handle_capture(self, query_string: str) -> None: + query = urllib.parse.parse_qs(query_string) + class_name = query.get("class", [""])[0] + target = int(query.get("target", ["1000"])[0]) + if not self.valid_class_name(class_name): + raise ValueError("유효하지 않은 클래스 이름입니다.") + if not 1 <= target <= 10_000: + raise ValueError("목표 수량은 1~10,000이어야 합니다.") + length = int(self.headers.get("Content-Length", "0")) + if length <= 0 or length > MAX_IMAGE_BYTES: + raise ValueError("JPEG 크기가 허용 범위를 벗어났습니다.") + image = self.rfile.read(length) + if not image.startswith(b"\xff\xd8") or not image.endswith(b"\xff\xd9"): + raise ValueError("올바른 JPEG 파일이 아닙니다.") + + with self.dataset_server.capture_lock: + class_dir = self.dataset_server.output_root / class_name + class_dir.mkdir(parents=True, exist_ok=True) + current_count = self.image_count(class_name) + if current_count >= target: + self.send_json(409, {"error": "이 클래스는 목표 수량에 도달했습니다."}) + return + next_count = current_count + 1 + filename = f"{class_name}_{next_count:04d}_{time.time_ns()}.jpg" + output_path = class_dir / filename + temporary_path = output_path.with_suffix(".tmp") + temporary_path.write_bytes(image) + temporary_path.replace(output_path) + + self.send_json(201, {"count": next_count, "filename": filename}) + + def handle_metadata(self) -> None: + payload = self.parse_json_body() + classes = payload.get("classes") + target = payload.get("target_per_class") + if ( + not isinstance(classes, list) + or len(classes) != 4 + or len(set(classes)) != 4 + or any( + not isinstance(name, str) or not self.valid_class_name(name) + for name in classes + ) + ): + raise ValueError("클래스 목록이 필요합니다.") + if not isinstance(target, int) or not 1 <= target <= 10_000: + raise ValueError("목표 수량이 필요합니다.") + counts = {name: self.image_count(name) for name in classes} + metadata = { + "updated_at_unix": time.time(), + "target_per_class": target, + "image_size": [224, 224], + "classes": counts, + } + path = self.dataset_server.output_root / "collection_state.json" + path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + self.send_json(200, {"saved": True, "counts": counts}) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ResNet Dataset Studio 로컬 서버") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--open", action="store_true", help="브라우저 자동 열기") + parser.add_argument( + "--output", + type=Path, + default=ROOT / "collected_images", + help="수집 이미지 저장 루트", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + output_root = args.output.expanduser().resolve() + output_root.mkdir(parents=True, exist_ok=True) + server = DatasetServer((args.host, args.port), output_root) + url = f"http://{args.host}:{args.port}" + print(f"ResNet Dataset Studio: {url}") + print(f"이미지 저장 위치: {output_root}") + print("종료: Ctrl+C") + if args.open: + threading.Timer(0.5, webbrowser.open, args=(url,)).start() + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n서버를 종료합니다.") + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/pytorch/resnet_dataset_studio_step1/styles.css b/pytorch/resnet_dataset_studio_step1/styles.css new file mode 100644 index 00000000..e6161673 --- /dev/null +++ b/pytorch/resnet_dataset_studio_step1/styles.css @@ -0,0 +1,143 @@ +:root { + color-scheme: dark; + --bg: #0d1117; + --panel: #151b23; + --panel-2: #1b2430; + --line: #2c3745; + --text: #f3f6fa; + --muted: #98a7b8; + --mint: #4ee1a0; + --mint-dark: #153d30; + --red: #ff6b72; + --radius: 18px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif; +} +button, input { font: inherit; } +h1, h2, p { margin: 0; } +h1 { margin-top: 4px; font-size: clamp(1.65rem, 3vw, 2.45rem); letter-spacing: -.04em; } +h2 { font-size: 1.05rem; letter-spacing: -.02em; } +.eyebrow, .step-label { color: var(--mint); font-size: .72rem; font-weight: 800; letter-spacing: .13em; } +.subtitle, .helper { color: var(--muted); } +.subtitle { margin-top: 8px; } +.helper { margin-top: 10px; font-size: .82rem; line-height: 1.5; overflow-wrap: anywhere; } +.top-gap { margin-top: 14px; } + +.topbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + max-width: 1440px; + margin: 0 auto; + padding: 30px 32px 20px; + border-bottom: 1px solid var(--line); +} +.privacy-badge { padding: 9px 13px; border: 1px solid #286a50; border-radius: 999px; color: var(--mint); font-size: .8rem; white-space: nowrap; } +.workspace { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(340px, .8fr); + gap: 20px; + max-width: 1440px; + margin: 0 auto; + padding: 22px 32px 92px; +} +.camera-panel, .control-panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); } +.camera-panel { padding: 20px; align-self: start; } +.control-panel { overflow: hidden; } +.control-section { padding: 19px; border-bottom: 1px solid var(--line); } +.control-section:last-child { border-bottom: 0; } +.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 15px; } +.section-heading.compact { margin-bottom: 12px; } +.step-label { margin-bottom: 4px; } + +.preview-shell { position: relative; overflow: hidden; aspect-ratio: 16 / 10; border-radius: 14px; background: #07090d; } +#video { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); } +.preview-shell:not(.camera-ready) #video { visibility: hidden; } +.preview-placeholder { position: absolute; inset: 0; display: grid; place-content: center; gap: 7px; text-align: center; color: var(--muted); } +.preview-placeholder strong { color: var(--text); font-size: 1.1rem; } +.camera-ready .preview-placeholder { display: none; } +.roi-guide { + position: absolute; + width: min(70%, 68vh); + aspect-ratio: 1; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + border: 2px solid var(--mint); + border-radius: 18px; + box-shadow: 0 0 0 999px rgb(0 0 0 / .18), inset 0 0 0 1px rgb(255 255 255 / .2); + pointer-events: none; + opacity: 0; +} +.camera-ready .roi-guide { opacity: 1; } +.roi-guide::before, .roi-guide::after { content: ""; position: absolute; background: rgb(78 225 160 / .45); } +.roi-guide::before { width: 1px; height: 100%; left: 50%; } +.roi-guide::after { height: 1px; width: 100%; top: 50%; } +.roi-guide span { position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%); padding: 6px 9px; border-radius: 7px; background: rgb(6 12 10 / .76); color: var(--mint); font-size: .75rem; white-space: nowrap; } + +.camera-actions, .split-actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; } +.split-actions > * { flex: 1; } +.button { min-height: 42px; padding: 9px 14px; border: 1px solid transparent; border-radius: 10px; color: var(--text); cursor: pointer; font-weight: 750; transition: transform .12s ease, border-color .12s ease, background .12s ease; } +.button:hover { transform: translateY(-1px); } +.button:disabled { cursor: not-allowed; opacity: .48; transform: none; } +.button-primary { background: var(--mint); color: #062216; } +.button-secondary { border-color: var(--line); background: var(--panel-2); } +.button-record { border-color: #843d42; background: #4c2227; color: #ffb3b7; } +.button-record.is-recording, #autoCaptureBtn.is-recording { background: var(--red); color: #250609; border-color: var(--red); } +.full { width: 100%; } +.status-pill { padding: 6px 9px; border-radius: 999px; font-size: .75rem; font-weight: 750; } +.status-idle { color: var(--muted); background: var(--panel-2); } +.status-ready { color: var(--mint); background: var(--mint-dark); } + +.target-field { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: .8rem; } +.target-field input, .capture-settings input[type="number"] { width: 76px; border: 1px solid var(--line); border-radius: 7px; background: #0d131b; color: var(--text); padding: 6px 7px; } +.class-list { display: grid; gap: 9px; } +.class-row { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: #111821; } +.class-row.is-selected { border-color: var(--mint); box-shadow: inset 3px 0 0 var(--mint); } +.class-row input[type="text"] { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font-weight: 700; } +.class-count { min-width: 88px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; font-size: .82rem; } +.progress-track { grid-column: 2 / 4; height: 5px; overflow: hidden; border-radius: 99px; background: #283240; } +.progress-fill { height: 100%; width: 0; background: var(--mint); transition: width .2s ease; } +.capture-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 14px; } +.capture-settings > label:not(.toggle) { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted); font-size: .82rem; } +.toggle { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: .82rem; } +.toggle input { accent-color: var(--mint); } +.status-bar { + position: fixed; + left: 50%; + bottom: 16px; + z-index: 10; + width: min(calc(100% - 32px), 900px); + transform: translateX(-50%); + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 16px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgb(21 27 35 / .94); + backdrop-filter: blur(12px); + color: var(--muted); + font-size: .84rem; + box-shadow: 0 12px 35px rgb(0 0 0 / .32); +} +@media (max-width: 900px) { + .topbar { align-items: flex-start; padding: 22px 18px 16px; } + .privacy-badge { display: none; } + .workspace { grid-template-columns: 1fr; padding: 18px 18px 100px; } +} +@media (max-width: 520px) { + .camera-actions { align-items: stretch; flex-direction: column; } + .camera-actions .button { width: 100%; } + .capture-settings { grid-template-columns: 1fr; } + .status-bar { align-items: flex-start; flex-direction: column; gap: 4px; } +} diff --git a/pytorch/resnet_dataset_studio_step1/test_integrity_check.py b/pytorch/resnet_dataset_studio_step1/test_integrity_check.py new file mode 100644 index 00000000..8f1d023a --- /dev/null +++ b/pytorch/resnet_dataset_studio_step1/test_integrity_check.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +from integrity_check import inspect_dataset + + +class IntegrityCheckTest(unittest.TestCase): + def create_image(self, path: Path, color: tuple[int, int, int]) -> None: + Image.new("RGB", (224, 224), color).save(path, format="JPEG") + + def test_passes_balanced_imagefolder(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + for class_index, class_name in enumerate(("class_A", "class_B")): + class_dir = root / class_name + class_dir.mkdir() + for image_index in range(2): + color = (class_index * 80, image_index * 60, 30) + self.create_image(class_dir / f"{image_index}.jpg", color) + + report = inspect_dataset(root, target=2, expected_classes=2) + + self.assertTrue(report["valid"]) + self.assertEqual(report["total"], 4) + self.assertEqual(report["imagefolder_classes"], ["class_A", "class_B"]) + + def test_fails_when_class_is_short(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + for class_name in ("class_A", "class_B"): + class_dir = root / class_name + class_dir.mkdir() + self.create_image(class_dir / "0.jpg", (20, 40, 60)) + self.create_image(root / "class_A" / "1.jpg", (60, 40, 20)) + + report = inspect_dataset(root, target=2, expected_classes=2) + + self.assertFalse(report["valid"]) + self.assertEqual(report["class_counts"]["class_B"], 1) + + +if __name__ == "__main__": + unittest.main()