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
+
+
+
+
+
+
+
+
+
+
+
+
+ 객체를 이 안에 크게 배치
+
+
+ 카메라를 시작하세요
+ 브라우저 권한 창에서 카메라 사용을 허용합니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 카메라와 저장 폴더를 준비하면 촬영할 수 있습니다.
+ 저장 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()