| import { useCallback, useEffect, useRef, useState } from "react" |
| import MetricCard from "../components/MetricCard" |
| import StatusBadge from "../components/StatusBadge" |
| import { inspectFrame, inspectImage } from "../lib/api" |
| import { formatDateTime, titleCase } from "../lib/formatters" |
| import { getDecisionTone } from "../lib/status" |
|
|
| function fileToDataUrl(file) { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader() |
| reader.onload = () => resolve(reader.result) |
| reader.onerror = () => reject(new Error("Unable to read the selected image")) |
| reader.readAsDataURL(file) |
| }) |
| } |
|
|
| function playAlertTone() { |
| const AudioContextRef = window.AudioContext || window.webkitAudioContext |
| if (!AudioContextRef) return |
|
|
| const audioContext = new AudioContextRef() |
| const oscillator = audioContext.createOscillator() |
| const gainNode = audioContext.createGain() |
|
|
| oscillator.type = "triangle" |
| oscillator.frequency.setValueAtTime(880, audioContext.currentTime) |
| gainNode.gain.setValueAtTime(0.0001, audioContext.currentTime) |
| gainNode.gain.exponentialRampToValueAtTime(0.08, audioContext.currentTime + 0.02) |
| gainNode.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + 0.28) |
|
|
| oscillator.connect(gainNode) |
| gainNode.connect(audioContext.destination) |
| oscillator.start() |
| oscillator.stop(audioContext.currentTime + 0.3) |
| } |
|
|
| export default function Live() { |
| const [mode, setMode] = useState("upload") |
| const [selectedFile, setSelectedFile] = useState(null) |
| const [imagePreview, setImagePreview] = useState("") |
| const [inspectionResult, setInspectionResult] = useState(null) |
| const [isInspecting, setIsInspecting] = useState(false) |
| const [error, setError] = useState("") |
| const [cameraReady, setCameraReady] = useState(false) |
| const [streaming, setStreaming] = useState(false) |
| const [autoInspect, setAutoInspect] = useState(true) |
| const [persistLive, setPersistLive] = useState(false) |
| const [deepAnalysisEnabled, setDeepAnalysisEnabled] = useState(false) |
| const [alertsEnabled, setAlertsEnabled] = useState(true) |
| const [cameraStatus, setCameraStatus] = useState("Camera offline") |
| const [lastInspectedAt, setLastInspectedAt] = useState("") |
|
|
| const videoRef = useRef(null) |
| const canvasRef = useRef(null) |
| const streamRef = useRef(null) |
| const requestInFlight = useRef(false) |
| const lastAlertDecision = useRef("") |
|
|
| async function handleImageSelection(event) { |
| const file = event.target.files?.[0] |
|
|
| if (!file) return |
|
|
| setError("") |
| setSelectedFile(file) |
|
|
| try { |
| const previewUrl = await fileToDataUrl(file) |
| setImagePreview(previewUrl) |
| } catch (selectionError) { |
| setError(selectionError.message) |
| } |
| } |
|
|
| async function handleImageInspection() { |
| if (!selectedFile) { |
| setError("Select an image to run inspection.") |
| return |
| } |
|
|
| setIsInspecting(true) |
| setError("") |
|
|
| try { |
| const imageBase64 = await fileToDataUrl(selectedFile) |
| const result = await inspectImage({ |
| image_base64: imageBase64, |
| filename: selectedFile.name, |
| source: "upload", |
| persist: true, |
| llm_mode: deepAnalysisEnabled ? "always" : "off", |
| }) |
|
|
| setInspectionResult(result) |
| setLastInspectedAt(result.timestamp) |
| if (alertsEnabled && result.decision === "FAIL" && lastAlertDecision.current !== "FAIL") { |
| playAlertTone() |
| } |
| lastAlertDecision.current = result.decision |
| } catch (inspectionError) { |
| setError(inspectionError.message) |
| } finally { |
| setIsInspecting(false) |
| } |
| } |
|
|
| async function startCamera() { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ |
| video: { |
| facingMode: "environment", |
| width: { ideal: 1280 }, |
| height: { ideal: 720 }, |
| }, |
| audio: false, |
| }) |
|
|
| streamRef.current = stream |
|
|
| if (videoRef.current) { |
| videoRef.current.srcObject = stream |
| await videoRef.current.play() |
| } |
|
|
| setCameraReady(true) |
| setStreaming(true) |
| setCameraStatus("Camera active and scanning live frames") |
| setError("") |
| } catch { |
| setError("Camera access was blocked or unavailable.") |
| } |
| } |
|
|
| function stopCamera() { |
| if (streamRef.current) { |
| streamRef.current.getTracks().forEach((track) => track.stop()) |
| streamRef.current = null |
| } |
|
|
| if (videoRef.current) { |
| videoRef.current.srcObject = null |
| } |
|
|
| requestInFlight.current = false |
| setStreaming(false) |
| setCameraReady(false) |
| setCameraStatus("Camera offline") |
| } |
|
|
| const captureAndInspectFrame = useCallback(async ({ persist = false } = {}) => { |
| if (!videoRef.current || !canvasRef.current || requestInFlight.current) return |
| if (videoRef.current.readyState < 2) return |
|
|
| const canvas = canvasRef.current |
| const videoWidth = videoRef.current.videoWidth || 1280 |
| const videoHeight = videoRef.current.videoHeight || 720 |
| const targetWidth = Math.min(768, videoWidth) |
| const targetHeight = Math.round((videoHeight / videoWidth) * targetWidth) |
|
|
| canvas.width = targetWidth |
| canvas.height = targetHeight |
|
|
| const context = canvas.getContext("2d") |
| if (!context) { |
| setError("Unable to capture the current camera frame.") |
| return |
| } |
|
|
| context.drawImage(videoRef.current, 0, 0, targetWidth, targetHeight) |
|
|
| requestInFlight.current = true |
| setIsInspecting(true) |
| setCameraStatus("Inspecting live frame") |
| setError("") |
|
|
| try { |
| const result = await inspectFrame({ |
| image_base64: canvas.toDataURL("image/jpeg", 0.82), |
| source: "camera", |
| persist, |
| llm_mode: deepAnalysisEnabled ? "always" : "off", |
| }) |
|
|
| setInspectionResult(result) |
| setLastInspectedAt(result.timestamp) |
| setCameraStatus( |
| result.total_defects > 0 |
| ? `Detected ${result.total_defects} defect${result.total_defects > 1 ? "s" : ""} in the latest frame` |
| : "No actionable defects detected in the latest frame" |
| ) |
|
|
| if (alertsEnabled && result.decision === "FAIL" && lastAlertDecision.current !== "FAIL") { |
| playAlertTone() |
| } |
| lastAlertDecision.current = result.decision |
| } catch (inspectionError) { |
| setError(inspectionError.message) |
| setCameraStatus("Camera active, waiting for a successful inspection response") |
| } finally { |
| requestInFlight.current = false |
| setIsInspecting(false) |
| } |
| }, [alertsEnabled, deepAnalysisEnabled]) |
|
|
| useEffect(() => { |
| return () => { |
| stopCamera() |
| } |
| }, []) |
|
|
| useEffect(() => { |
| if (mode !== "camera" || !streaming || !cameraReady || !autoInspect) return undefined |
|
|
| const interval = window.setInterval(() => { |
| captureAndInspectFrame({ persist: persistLive }) |
| }, 2000) |
|
|
| return () => { |
| window.clearInterval(interval) |
| } |
| }, [autoInspect, cameraReady, captureAndInspectFrame, mode, persistLive, streaming]) |
|
|
| useEffect(() => { |
| if (!cameraReady || mode !== "camera") return |
|
|
| const timer = window.setTimeout(() => { |
| captureAndInspectFrame({ persist: persistLive }) |
| }, 500) |
|
|
| return () => { |
| window.clearTimeout(timer) |
| } |
| }, [cameraReady, captureAndInspectFrame, mode, persistLive]) |
|
|
| useEffect(() => { |
| if (mode === "camera") { |
| setSelectedFile(null) |
| setImagePreview("") |
| return |
| } |
|
|
| setStreaming(false) |
| setCameraStatus("Camera offline") |
| }, [mode]) |
|
|
| return ( |
| <div className="space-y-6 pb-10"> |
| <section className="surface-card p-6 md:p-8"> |
| <div className="grid gap-6 xl:grid-cols-[1.25fr_0.75fr]"> |
| <div> |
| <p className="eyebrow">Live Monitoring Page</p> |
| <h2 className="hero-title mt-3">Upload images or use the browser camera for organized, real-time manufacturing inspection.</h2> |
| <p className="body-copy mt-5 max-w-3xl"> |
| Use the live workspace to inspect saved images, scan frames from the browser camera, and review defect decisions with optional AI recommendations. |
| </p> |
| |
| <div className="mt-6 flex flex-wrap gap-3"> |
| <button |
| type="button" |
| className={mode === "upload" ? "primary-btn" : "secondary-btn"} |
| onClick={() => setMode("upload")} |
| > |
| Upload images |
| </button> |
| <button |
| type="button" |
| className={mode === "camera" ? "primary-btn" : "secondary-btn"} |
| onClick={() => setMode("camera")} |
| > |
| Live camera feed |
| </button> |
| </div> |
| </div> |
| |
| <div className="surface-card p-6"> |
| <p className="eyebrow">Camera Setup</p> |
| <p className="mt-4 text-sm leading-7 text-slate-300"> |
| For reliable live detection, keep the steel surface clearly visible in a stable top view. Reduce background clutter, avoid steep camera angles, and keep lighting even across the surface. |
| </p> |
| </div> |
| </div> |
| </section> |
| |
| <section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> |
| <div className="surface-card p-6"> |
| <div className="flex flex-wrap items-center justify-between gap-3"> |
| <div> |
| <p className="eyebrow">Detection Workspace</p> |
| <h3 className="section-title mt-2"> |
| {mode === "upload" ? "Image-based inspection" : "Camera-based live detection"} |
| </h3> |
| </div> |
| {inspectionResult ? ( |
| <StatusBadge label={inspectionResult.decision} tone={getDecisionTone(inspectionResult.decision)} pulse={inspectionResult.decision === "FAIL"} /> |
| ) : null} |
| </div> |
| |
| {mode === "upload" ? ( |
| <div className="mt-6 space-y-5"> |
| <label className="flex min-h-[240px] cursor-pointer flex-col items-center justify-center rounded-[28px] border border-dashed border-white/15 bg-white/5 px-6 py-8 text-center transition hover:border-cyan-300/40 hover:bg-cyan-400/[0.08]"> |
| <input type="file" accept="image/*" className="hidden" onChange={handleImageSelection} /> |
| <p className="text-lg font-medium text-slate-100">Drop or choose a steel surface image</p> |
| <p className="mt-3 max-w-xl text-sm leading-7 text-slate-400"> |
| Use this mode to inspect saved surface samples, log a full report, and broadcast the result to the dashboard and history views. |
| </p> |
| </label> |
| |
| {imagePreview ? ( |
| <div className="overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/60"> |
| <img src={imagePreview} alt="Selected inspection" className="max-h-[420px] w-full object-cover" /> |
| </div> |
| ) : null} |
| |
| <div className="flex flex-wrap gap-3"> |
| <button type="button" className="primary-btn" onClick={handleImageInspection} disabled={isInspecting}> |
| {isInspecting ? "Inspecting image..." : "Run image inspection"} |
| </button> |
| <div className="rounded-full border border-white/10 bg-white/5 px-4 py-3 text-sm text-slate-300"> |
| Uploaded inspections are saved as reports automatically. |
| </div> |
| </div> |
| |
| <label className="flex items-center justify-between rounded-3xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-slate-300"> |
| <span>Enable GenAI deep analysis (slower response)</span> |
| <input |
| type="checkbox" |
| checked={deepAnalysisEnabled} |
| onChange={(event) => setDeepAnalysisEnabled(event.target.checked)} |
| className="h-5 w-5 accent-cyan-300" |
| /> |
| </label> |
| |
| <div className="rounded-3xl border border-amber-300/20 bg-amber-400/10 px-4 py-4 text-sm leading-7 text-amber-50"> |
| Fast mode is optimized for real-time monitoring. Turn on deep analysis only when you want the slower cloud GenAI recommendation layer. |
| </div> |
| </div> |
| ) : ( |
| <div className="mt-6 space-y-5"> |
| <div className="grid gap-4 lg:grid-cols-2"> |
| <div className="overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/60"> |
| <video ref={videoRef} className="aspect-video w-full object-cover" muted playsInline /> |
| </div> |
| |
| <div className="rounded-[28px] border border-white/10 bg-white/5 p-5"> |
| <p className="eyebrow">Camera Controls</p> |
| <div className="mt-4 flex flex-wrap gap-3"> |
| {!cameraReady ? ( |
| <button type="button" className="primary-btn" onClick={startCamera}> |
| Enable camera |
| </button> |
| ) : ( |
| <> |
| <button |
| type="button" |
| className={streaming ? "secondary-btn" : "primary-btn"} |
| onClick={() => setStreaming((current) => !current)} |
| > |
| {streaming ? "Pause live inspection" : "Start live inspection"} |
| </button> |
| <button |
| type="button" |
| className="secondary-btn" |
| onClick={() => captureAndInspectFrame({ persist: persistLive })} |
| > |
| Inspect current frame |
| </button> |
| <button type="button" className="secondary-btn" onClick={stopCamera}> |
| Stop camera |
| </button> |
| </> |
| )} |
| </div> |
| |
| <div className="mt-5 space-y-3"> |
| <label className="flex items-center justify-between rounded-3xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-slate-300"> |
| <span>Auto inspect every few seconds</span> |
| <input |
| type="checkbox" |
| checked={autoInspect} |
| onChange={(event) => setAutoInspect(event.target.checked)} |
| className="h-5 w-5 accent-cyan-300" |
| /> |
| </label> |
| <label className="flex items-center justify-between rounded-3xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-slate-300"> |
| <span>Log live detections to reports and database</span> |
| <input |
| type="checkbox" |
| checked={persistLive} |
| onChange={(event) => setPersistLive(event.target.checked)} |
| className="h-5 w-5 accent-cyan-300" |
| /> |
| </label> |
| <label className="flex items-center justify-between rounded-3xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-slate-300"> |
| <span>Enable GenAI deep analysis (slower response)</span> |
| <input |
| type="checkbox" |
| checked={deepAnalysisEnabled} |
| onChange={(event) => setDeepAnalysisEnabled(event.target.checked)} |
| className="h-5 w-5 accent-cyan-300" |
| /> |
| </label> |
| <label className="flex items-center justify-between rounded-3xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-slate-300"> |
| <span>Play alert tone on FAIL states</span> |
| <input |
| type="checkbox" |
| checked={alertsEnabled} |
| onChange={(event) => setAlertsEnabled(event.target.checked)} |
| className="h-5 w-5 accent-cyan-300" |
| /> |
| </label> |
| </div> |
| |
| <div className="mt-5 rounded-3xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-4 text-sm text-cyan-50"> |
| <p className="font-medium uppercase tracking-[0.2em] text-cyan-100/80">Live Status</p> |
| <p className="mt-2 leading-7">{cameraStatus}</p> |
| <p className="mt-2 text-cyan-100/80"> |
| {lastInspectedAt ? `Last inspection: ${formatDateTime(lastInspectedAt)}` : "Waiting for the first live inspection result."} |
| </p> |
| </div> |
| <div className="rounded-3xl border border-white/10 bg-white/5 px-4 py-4 text-sm leading-7 text-slate-300"> |
| Calibrate the camera so the top view of the steel surface fills most of the frame. This gives the detector a cleaner visual field and improves live inspection consistency. |
| </div> |
| <div className="rounded-3xl border border-amber-300/20 bg-amber-400/10 px-4 py-4 text-sm leading-7 text-amber-50"> |
| Keep deep analysis off during continuous monitoring for the fastest response. Turn it on only when you want a slower GenAI disposition note for a specific frame. |
| </div> |
| </div> |
| </div> |
| |
| <canvas ref={canvasRef} className="hidden" /> |
| </div> |
| )} |
| |
| {error ? ( |
| <div className="mt-5 rounded-3xl border border-rose-300/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100"> |
| {error} |
| </div> |
| ) : null} |
| </div> |
| |
| <div className="space-y-6"> |
| <div className="surface-card p-6"> |
| <p className="eyebrow">Inspection Result</p> |
| <h3 className="section-title mt-2">Annotated output and operator guidance</h3> |
| |
| {inspectionResult?.annotated_image ? ( |
| <div className="mt-5 overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/60"> |
| <img |
| src={inspectionResult.annotated_image} |
| alt="Annotated inspection result" |
| className="max-h-[420px] w-full object-cover" |
| /> |
| </div> |
| ) : ( |
| <div className="mt-5 rounded-[28px] border border-dashed border-white/15 bg-white/5 p-6 text-sm leading-7 text-slate-400"> |
| Run an inspection to see the AI overlay, severity callouts, and decision banner. |
| </div> |
| )} |
| |
| {inspectionResult ? ( |
| <div className="mt-5 space-y-4"> |
| <div className="flex flex-wrap items-center gap-3"> |
| <StatusBadge label={inspectionResult.decision} tone={getDecisionTone(inspectionResult.decision)} pulse={inspectionResult.decision === "FAIL"} /> |
| <div className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300"> |
| {formatDateTime(inspectionResult.timestamp)} |
| </div> |
| </div> |
| |
| <p className="text-sm leading-7 text-slate-300">{inspectionResult.recommendation}</p> |
| |
| <div className="rounded-[28px] border border-cyan-300/20 bg-cyan-400/10 p-5"> |
| <div className="flex flex-wrap items-center gap-3"> |
| <StatusBadge |
| label={ |
| inspectionResult.agent_mode === "llm" |
| ? "Decision Copilot" |
| : "Rules Engine" |
| } |
| tone={ |
| inspectionResult.agent_mode === "llm" |
| ? "info" |
| : "warning" |
| } |
| /> |
| <div className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200"> |
| {inspectionResult.agent_provider} {inspectionResult.agent_model && inspectionResult.agent_model !== "fallback" ? `· ${inspectionResult.agent_model}` : ""} |
| </div> |
| </div> |
| <p className="mt-4 text-sm leading-7 text-cyan-50/95"> |
| {inspectionResult.summary_text || "Decision support guidance is not available for this inspection yet."} |
| </p> |
| </div> |
| |
| <div className="grid gap-4 sm:grid-cols-3"> |
| <MetricCard label="Minor" value={inspectionResult.minor} detail="Low severity anomalies" tone="success" /> |
| <MetricCard label="Moderate" value={inspectionResult.moderate} detail="Review threshold" tone="warning" /> |
| <MetricCard label="Critical" value={inspectionResult.critical} detail="Immediate action" tone="danger" /> |
| </div> |
| |
| <div className="space-y-3"> |
| {(inspectionResult.defects || []).length ? ( |
| (inspectionResult.defects || []).map((defect, index) => ( |
| <div key={`${defect.type}-${index}`} className="rounded-3xl border border-white/10 bg-white/5 px-4 py-4"> |
| <div className="flex flex-wrap items-center justify-between gap-3"> |
| <p className="text-base font-medium text-slate-50">{titleCase(defect.type)}</p> |
| <StatusBadge label={defect.severity} tone={getDecisionTone(defect.decision)} /> |
| </div> |
| <p className="mt-3 text-sm leading-7 text-slate-300"> |
| Confidence {defect.confidence ?? "--"} | Area ratio {defect.area_ratio} | Length {defect.length} |
| </p> |
| </div> |
| )) |
| ) : ( |
| <div className="rounded-3xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-4 text-sm leading-7 text-emerald-50"> |
| The latest frame is clear. The system still inspected the image and returned a PASS result with no actionable defects detected. |
| </div> |
| )} |
| </div> |
| </div> |
| ) : null} |
| </div> |
| |
| <div className="surface-card p-6"> |
| <p className="eyebrow">Live Monitoring Notes</p> |
| <h3 className="section-title mt-2">What improves real-time stability</h3> |
| <div className="mt-5 grid gap-4 text-sm leading-7 text-slate-300"> |
| <div className="rounded-3xl border border-white/10 bg-white/5 p-4"> |
| Mount or position the camera so the steel surface is visible in a stable top view. |
| </div> |
| <div className="rounded-3xl border border-white/10 bg-white/5 p-4"> |
| Keep the surface centered and avoid hands, faces, tools, or other objects crossing into the frame. |
| </div> |
| <div className="rounded-3xl border border-white/10 bg-white/5 p-4"> |
| Use deep AI analysis for manual review, not for every live frame, when you want the fastest monitoring loop. |
| </div> |
| </div> |
| </div> |
| </div> |
| </section> |
| </div> |
| ) |
| } |
|
|