From 5f8bd0b05d2744236a73f647d1cc0e82e16db91d Mon Sep 17 00:00:00 2001 From: Alpenj <29724960+Alpenj@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:09:00 +0900 Subject: [PATCH] feat: add ResNet dataset studio step 1 --- README.md | 6 + .../resnet_dataset_studio_step1/.gitignore | 3 + pytorch/resnet_dataset_studio_step1/README.md | 70 +++ .../ResNet_Dataset_Studio_STEP1.zip | Bin 0 -> 17399 bytes pytorch/resnet_dataset_studio_step1/app.js | 568 ++++++++++++++++++ .../resnet_dataset_studio_step1/index.html | 95 +++ .../integrity_check.py | 122 ++++ pytorch/resnet_dataset_studio_step1/serve.py | 247 ++++++++ .../resnet_dataset_studio_step1/styles.css | 143 +++++ .../test_integrity_check.py | 48 ++ 10 files changed, 1302 insertions(+) create mode 100644 pytorch/resnet_dataset_studio_step1/.gitignore create mode 100644 pytorch/resnet_dataset_studio_step1/README.md create mode 100644 pytorch/resnet_dataset_studio_step1/ResNet_Dataset_Studio_STEP1.zip create mode 100644 pytorch/resnet_dataset_studio_step1/app.js create mode 100644 pytorch/resnet_dataset_studio_step1/index.html create mode 100644 pytorch/resnet_dataset_studio_step1/integrity_check.py create mode 100644 pytorch/resnet_dataset_studio_step1/serve.py create mode 100644 pytorch/resnet_dataset_studio_step1/styles.css create mode 100644 pytorch/resnet_dataset_studio_step1/test_integrity_check.py 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 0000000000000000000000000000000000000000..9e55c87147573fe6f7ed62ede42ede4094903e0c GIT binary patch literal 17399 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 kS7P+)(G2LQmgKcD$O=W`(P|LfoX0?Oz2b^rhX literal 0 HcmV?d00001 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()