import { useEffect, useRef, useState } from 'react' import { anonymizeImage, deidentifyImage, detect, redact, type DeidStrategy, type EntityTaxonomy, type PIIEntity, type PseudonymMapping, type RedactMode, } from '../lib/api' import { l1Rgb } from '../lib/colors' import Legend from './Legend' import MappingTable from './MappingTable' type Action = 'redact' | 'deidentify' | 'anonymize' const ACTION_BUTTON_LABELS: Record = { redact: 'Detect & Redact', deidentify: 'Detect & De-identify', anonymize: 'Detect & Anonymize', } const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/bmp', 'image/tiff', 'image/webp'] // Bundled sample documents (no real PII) under web/public/samples/ — add files // with these exact names to enable the "try a sample" shortcuts. const SAMPLE_IMAGES = [ { name: 'Sample 1', src: '/samples/sample-1.jpg' }, { name: 'Sample 2', src: '/samples/sample-2.jpg' }, { name: 'Sample 3', src: '/samples/sample-3.jpg' }, ] async function urlToFile(url: string, name: string): Promise { const res = await fetch(url) const blob = await res.blob() return new File([blob], name, { type: blob.type }) } export default function ImageTab({ entities: taxonomy, ready, }: { entities: EntityTaxonomy | null ready: boolean }) { const [file, setFile] = useState(null) const [previewUrl, setPreviewUrl] = useState(null) const [action, setAction] = useState('redact') const [mode, setMode] = useState('blur') const [color, setColor] = useState('#000000') const [strategy, setStrategy] = useState('counter') const [selected, setSelected] = useState>(new Set()) // All categories selected by default, once the taxonomy arrives. useEffect(() => { if (taxonomy) setSelected(new Set([...taxonomy.text, ...taxonomy.visual])) }, [taxonomy]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [detectedEntities, setDetectedEntities] = useState([]) const [redactedUrl, setRedactedUrl] = useState(null) const [resultTitle, setResultTitle] = useState('Redacted') const [mapping, setMapping] = useState(null) const canvasRef = useRef(null) useEffect(() => { if (!previewUrl || !canvasRef.current) return const img = new Image() img.onload = () => { const canvas = canvasRef.current! canvas.width = img.naturalWidth canvas.height = img.naturalHeight const ctx = canvas.getContext('2d')! ctx.drawImage(img, 0, 0) for (const e of detectedEntities) { const [x0, y0, x1, y1] = e.bbox ctx.strokeStyle = l1Rgb(e.l1) ctx.lineWidth = Math.max(2, img.naturalWidth / 400) ctx.strokeRect(x0, y0, x1 - x0, y1 - y0) ctx.fillStyle = l1Rgb(e.l1) const fontSize = Math.max(12, img.naturalWidth / 90) ctx.font = `${fontSize}px sans-serif` const label = e.category const tw = ctx.measureText(label).width ctx.fillRect(x0, Math.max(0, y0 - fontSize - 4), tw + 6, fontSize + 4) ctx.fillStyle = '#fff' ctx.fillText(label, x0 + 3, Math.max(fontSize, y0 - 4)) } } img.src = previewUrl }, [previewUrl, detectedEntities]) function pickFile(f: File) { setFile(f) setPreviewUrl(URL.createObjectURL(f)) setDetectedEntities([]) setRedactedUrl(null) setMapping(null) setError(null) } async function pickSample(src: string, name: string) { try { pickFile(await urlToFile(src, name)) } catch { setError(`Couldn't load ${name} — is it present under web/public/samples/?`) } } function onFileInput(e: React.ChangeEvent) { const f = e.target.files?.[0] if (!f) return if (!ACCEPTED_TYPES.includes(f.type)) { setError(`Unsupported file type: ${f.type || 'unknown'}`) return } pickFile(f) } function toggle(value: string) { const next = new Set(selected) if (next.has(value)) next.delete(value) else next.add(value) setSelected(next) } const totalCategories = taxonomy ? taxonomy.text.length + taxonomy.visual.length : 0 const allSelected = taxonomy != null && selected.size === totalCategories // Omit the param entirely when everything is selected (= server default). const categories = allSelected ? undefined : [...selected] async function run() { if (!file || selected.size === 0) return setLoading(true) setError(null) setMapping(null) try { const detecting = detect(file, categories) if (action === 'redact') { const rgb = [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16)) as [ number, number, number, ] const [ents, blob] = await Promise.all([detecting, redact(file, mode, rgb, categories)]) setDetectedEntities(ents) setRedactedUrl(URL.createObjectURL(blob)) setResultTitle(`Redacted · ${mode}`) } else if (action === 'deidentify') { const [ents, result] = await Promise.all([detecting, deidentifyImage(file, categories, strategy)]) setDetectedEntities(ents) setRedactedUrl(result.imageUrl) setMapping(result.mapping) setResultTitle('De-identified') } else { const [ents, blob] = await Promise.all([detecting, anonymizeImage(file, categories)]) setDetectedEntities(ents) setRedactedUrl(URL.createObjectURL(blob)) setResultTitle('Anonymized') } } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) } } const groups = [...new Set(detectedEntities.map((e) => e.l1))].sort() return (

Upload a document image — it will be detected and redacted.

Source

{SAMPLE_IMAGES.map((s) => ( ))}

Options

{action === 'deidentify' && ( )} {action === 'redact' && ( <> )}

{action === 'redact' && 'Destroys the PII regions — nothing is kept, nothing is recoverable.'} {action === 'deidentify' && 'Replaces text PII with consistent pseudonyms (Person_1) rendered in place, faces/signatures with placeholders, and returns the mapping for authorized re-linking.'} {action === 'anonymize' && 'One-way: generalizes ages/dates/geography, collapses names and IDs to tokens, blacks out faces/signatures. No mapping exists.'}

{taxonomy && (
Categories to detect ({selected.size}/{totalCategories} selected)

Text

{taxonomy.text.map((c) => ( ))}

Visual

{taxonomy.visual.map((c) => ( ))}
)}
{selected.size === 0 && (

Select at least one category to run.

)} {error &&
{error}
} {detectedEntities.length > 0 && } {previewUrl && (

Detections

{redactedUrl && (

{resultTitle}

{`${resultTitle} Download PNG
)}
)} {mapping && } {detectedEntities.length > 0 && (

Detected entities

{detectedEntities.map((e, i) => ( ))}
kind category l1 text score bbox
{e.kind} {e.category} {e.l1} {e.text ?? ''} {e.score != null ? e.score.toFixed(3) : ''} {e.bbox.join(', ')}
)}
) }