import React from "react";
import { createRoot } from "react-dom/client";
import { Camera, ChevronLeft, ImagePlus, Save, Trash2 } from "lucide-react";
import { requestJson, getConfidenceState } from "./api/client";
import "./style.css";
const fallbackLabels = Array.from({ length: 26 }, (_, index) => String.fromCharCode(65 + index));
function navigate(path) {
window.history.pushState({}, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
}
function Link({ href, children, className, current, ...props }) {
return (
{
event.preventDefault();
navigate(href);
}}
>
{children}
);
}
function Navbar({ path }) {
return (
ASL
ASL Letter Board
);
}
function Home() {
const [intro, setIntro] = React.useState(() => sessionStorage.getItem("intro-done") === "1");
function startIntro() {
sessionStorage.setItem("intro-done", "1");
setIntro(true);
}
return (
<>
{!intro && (
)}
ASL A-Z Learning
ASL A-Z 수화 알파벳 분류 게시판
웹캠이나 이미지를 통해 ASL 알파벳 A부터 Z까지 대략적으로 구분하고, 결과를 게시판에 저장해 간단한 학습 기록처럼 관리할 수 있습니다.
분류 시작
단어 학습하기
학습 기록 보기
>
);
}
function Classify() {
const [file, setFile] = React.useState(null);
const [preview, setPreview] = React.useState("");
const [result, setResult] = React.useState(null);
const [dragging, setDragging] = React.useState(false);
const [stream, setStream] = React.useState(null);
const videoRef = React.useRef(null);
React.useEffect(() => () => stopCamera(), [stream]);
function stopCamera() {
if (!stream) return;
stream.getTracks().forEach((track) => track.stop());
setStream(null);
}
function selectFile(nextFile) {
if (!nextFile) return;
stopCamera();
setFile(nextFile);
const reader = new FileReader();
reader.onload = () => setPreview(reader.result);
reader.readAsDataURL(nextFile);
}
async function runPrediction(blob, filename = "capture.jpg") {
const formData = new FormData();
formData.append("file", blob, filename);
try {
const data = await requestJson("/api/v1/predict", { method: "POST", body: formData });
setResult(data);
} catch (error) {
alert(error.message);
}
}
async function submit(event) {
event.preventDefault();
if (!file) {
alert("이미지를 선택해 주세요.");
return;
}
await runPrediction(file, file.name);
}
async function toggleCamera() {
if (stream) {
stopCamera();
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
alert("이 브라우저에서는 웹캠을 사용할 수 없습니다.");
return;
}
try {
const nextStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false });
setPreview("");
setStream(nextStream);
if (videoRef.current) {
videoRef.current.srcObject = nextStream;
}
} catch {
alert("웹캠 권한을 허용해 주세요.");
}
}
React.useEffect(() => {
if (videoRef.current && stream) {
videoRef.current.srcObject = stream;
}
}, [stream]);
async function captureCamera() {
const video = videoRef.current;
if (!stream || !video?.videoWidth) {
alert("웹캠 화면을 불러오는 중입니다.");
return;
}
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL("image/jpeg", 0.92);
setPreview(dataUrl);
stopCamera();
canvas.toBlob(async (blob) => {
if (!blob) {
alert("웹캠 이미지를 캡처하지 못했습니다.");
return;
}
const capturedFile = new File([blob], "webcam-capture.jpg", { type: "image/jpeg" });
setFile(capturedFile);
await runPrediction(blob, "webcam-capture.jpg");
}, "image/jpeg", 0.92);
}
async function savePost() {
if (!result) return;
try {
const post = await requestJson("/api/v1/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: `ASL ${result.predicted_class} 분류 결과`,
image_url: preview,
prediction: result.predicted_class,
confidence: result.confidence,
}),
});
navigate(`/post/${post.id}`);
} catch {
alert("학습 기록을 저장하지 못했습니다.");
}
}
return (
<>
ASL A-Z Classification
ASL 알파벳 이미지를 분류하세요
A부터 Z까지의 영어 수화 이미지를 업로드하거나 웹캠으로 촬영해 어떤 알파벳에 가까운지 간단히 구분합니다.
수화 이미지 입력
JPG, PNG · 최대 10MB
{result && (
구분 결과
Top 5
{result.predicted_class}
신뢰도 {(result.confidence * 100).toFixed(2)}%
{result.top_k.map((item) => (
-
{item.label}
{(item.score * 100).toFixed(1)}%
))}
)}
>
);
}
function Board() {
const [labels, setLabels] = React.useState(fallbackLabels);
const [posts, setPosts] = React.useState([]);
const [total, setTotal] = React.useState(0);
const [category, setCategory] = React.useState(() => new URLSearchParams(window.location.search).get("category") || "");
const limit = 6;
React.useEffect(() => {
requestJson("/api/v1/predict/labels")
.then((data) => setLabels(Array.isArray(data.classes) && data.classes.length ? data.classes : fallbackLabels))
.catch(() => setLabels(fallbackLabels));
}, []);
React.useEffect(() => {
loadPosts(0, true);
}, [category]);
async function loadPosts(skip = posts.length, reset = false) {
const params = new URLSearchParams({ skip, limit });
if (category) params.set("category", category);
const data = await requestJson(`/api/v1/posts?${params.toString()}`);
setPosts((current) => (reset ? data.items : [...current, ...data.items]));
setTotal(data.total);
}
return (
<>
Learning Archive
ASL A-Z 학습 기록
저장된 구분 결과를 A-Z 알파벳별로 확인하고, 제목 수정과 삭제를 할 수 있습니다.
새 알파벳 분류
{["", ...labels].map((label) => (
))}
{posts.length === 0 && 아직 저장된 ASL 학습 기록이 없습니다. 분류 화면에서 A-Z 결과를 저장해 주세요.
}
{posts.map((post, index) => {
const state = getConfidenceState(post.confidence);
return (
{post.prediction}
{state.label}
{post.title}
{(post.confidence * 100).toFixed(1)}%
ASL {post.prediction}
상세 보기
);
})}
{posts.length < total && }
>
);
}
function PostDetail({ id }) {
const [post, setPost] = React.useState(null);
const [notFound, setNotFound] = React.useState(false);
const [editing, setEditing] = React.useState(false);
const [title, setTitle] = React.useState("");
React.useEffect(() => {
requestJson(`/api/v1/posts/${id}`)
.then((data) => {
setPost(data);
setTitle(data.title);
})
.catch(() => setNotFound(true));
}, [id]);
async function saveTitle() {
const nextTitle = title.trim();
if (!nextTitle) {
alert("제목을 입력해 주세요.");
return;
}
const data = await requestJson(`/api/v1/posts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: nextTitle }),
});
setPost(data);
setTitle(data.title);
setEditing(false);
}
async function deletePost() {
if (!confirm("게시글을 삭제할까요?")) return;
await requestJson(`/api/v1/posts/${id}`, { method: "DELETE" });
navigate("/board");
}
if (notFound) return 학습 기록을 찾을 수 없습니다.
;
if (!post) return 불러오는 중입니다.
;
const state = getConfidenceState(post.confidence);
return (
Saved ASL Prediction
{post.title}
{editing && (
setTitle(event.target.value)} />
)}
예측 알파벳{post.prediction}
신뢰도{(post.confidence * 100).toFixed(1)}%
판단 상태{state.label}
같은 알파벳 보기
다시 분류하기
);
}
function LoveLearning() {
const [stream, setStream] = React.useState(null);
const [word, setWord] = React.useState("LOVE");
const [activeIndex, setActiveIndex] = React.useState(0);
const videoRef = React.useRef(null);
const letters = React.useMemo(() => word.toUpperCase().replace(/[^A-Z]/g, "").split(""), [word]);
const currentIndex = letters.length > 0 ? Math.min(activeIndex, letters.length - 1) : 0;
const activeLetter = letters[currentIndex] || "";
async function startPractice() {
try {
const nextStream = await navigator.mediaDevices.getUserMedia({ video: true });
setStream(nextStream);
if (videoRef.current) videoRef.current.srcObject = nextStream;
} catch {
alert("웹캠 권한을 허용해 주세요.");
}
}
React.useEffect(() => {
setActiveIndex(0);
}, [word]);
React.useEffect(() => {
if (videoRef.current && stream) videoRef.current.srcObject = stream;
return () => stream?.getTracks().forEach((track) => track.stop());
}, [stream]);
return (
<>
Word Learning
단어 ASL 알파벳 학습
원하는 영어 단어를 입력하고 알파벳별 ASL 손 모양을 순서대로 확인하세요.
setWord(event.target.value)}
/>
{letters.map((letter, index) => (
))}
{letters.length > 0 ? (
{currentIndex + 1} / {letters.length}
{activeLetter}

{ event.currentTarget.src = "/static/images/placeholder.jpg"; }}
/>
{activeLetter} 손 모양을 웹캠 거울로 비교해보세요.
거울 연습
판정 없이 웹캠 화면으로 내 손 모양을 샘플과 직접 비교합니다.
) : (
A-Z 영어 알파벳으로 단어를 입력해 주세요.
)}
>
);
}
function WebcamTest() {
const [cameras, setCameras] = React.useState([]);
const [selectedDeviceId, setSelectedDeviceId] = React.useState("");
const [status, setStatus] = React.useState("대기 중");
const [stream, setStream] = React.useState(null);
const videoRef = React.useRef(null);
async function loadCameras() {
if (!navigator.mediaDevices?.enumerateDevices) {
setCameras([]);
setStatus("장치 목록을 사용할 수 없습니다.");
return [];
}
const devices = await navigator.mediaDevices.enumerateDevices();
const nextCameras = devices.filter((device) => device.kind === "videoinput");
setCameras(nextCameras);
if (!selectedDeviceId && nextCameras[0]) {
setSelectedDeviceId(nextCameras[0].deviceId);
}
return nextCameras;
}
function stopCamera() {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setStream(null);
setStatus("중지됨");
}
async function startCamera(mode) {
stopCamera();
if (!navigator.mediaDevices?.getUserMedia) {
setStatus("실패: 이 브라우저에서 getUserMedia를 지원하지 않습니다.");
return;
}
try {
const availableCameras = await loadCameras();
const useSafeMode = mode === "safe";
const video = {
width: useSafeMode ? { ideal: 640 } : { ideal: 1280 },
height: useSafeMode ? { ideal: 480 } : { ideal: 720 },
};
if (selectedDeviceId) {
video.deviceId = { exact: selectedDeviceId };
}
const nextStream = await navigator.mediaDevices.getUserMedia({ video, audio: false });
setStream(nextStream);
if (videoRef.current) {
videoRef.current.srcObject = nextStream;
}
const track = nextStream.getVideoTracks()[0];
const settings = track.getSettings();
setStatus([
"성공: 웹캠 스트림이 열렸습니다.",
`카메라 개수: ${availableCameras.length}`,
`사용 중: ${track.label || "이름 확인 불가"}`,
`해상도: ${settings.width || "?"} x ${settings.height || "?"}`,
].join("\n"));
await loadCameras();
} catch (error) {
setStatus([
"실패: 웹캠을 열 수 없습니다.",
`이름: ${error.name}`,
`메시지: ${error.message}`,
"",
"확인:",
"1. Windows 카메라 앱, Zoom, Teams, OBS, 다른 브라우저 탭을 모두 종료",
"2. USB 웹캠이면 뺐다가 다시 연결",
"3. 저해상도 테스트 버튼으로 다시 시도",
].join("\n"));
}
}
React.useEffect(() => {
loadCameras().catch((error) => setStatus(`장치 목록을 불러오지 못했습니다: ${error.message}`));
return () => stream?.getTracks().forEach((track) => track.stop());
}, []);
return (
{status}
);
}
function App() {
const [path, setPath] = React.useState(window.location.pathname);
React.useEffect(() => {
const onPopState = () => setPath(window.location.pathname);
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
const postMatch = path.match(/^\/post\/(\d+)$/);
return (
<>
{path === "/" && }
{path === "/classify" && }
{path === "/board" && }
{path === "/learning" && }
{path === "/webcam-test" && }
{postMatch && }
{!["/", "/classify", "/board", "/learning", "/webcam-test"].includes(path) && !postMatch && (
페이지를 찾을 수 없습니다.
)}
>
);
}
createRoot(document.getElementById("root")).render();