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
{stream && }
{result && (

구분 결과

Top 5
{result.predicted_class} 신뢰도 {(result.confidence * 100).toFixed(2)}%
)}
); } 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.title}
{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 (
{post.title}

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}

{`${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 (

Webcam Test

웹캠 테스트

); } 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();