extractEmbeddings integration

This commit is contained in:
Kostas Drakontidis
2026-05-30 17:56:52 +03:00
parent a788ea2642
commit c7a0d921ea
6 changed files with 221 additions and 19 deletions
+3 -1
View File
@@ -2,4 +2,6 @@ node_modules
# Keep environment variables out of version control # Keep environment variables out of version control
.env .env
**/generated/prisma /generated/prisma
/generated/prisma
-14
View File
@@ -1,14 +0,0 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -8,4 +8,6 @@ export const componentLoader = new ComponentLoader()
export const Components = { export const Components = {
Dashboard: componentLoader.add('Dashboard', path.join(__dirname, './components/Dashboard.tsx'), 'components'), Dashboard: componentLoader.add('Dashboard', path.join(__dirname, './components/Dashboard.tsx'), 'components'),
UploadFace: componentLoader.add('UploadFace', path.join(__dirname, './components/UploadFace.tsx'), 'components'),
FaceEmbeddingField: componentLoader.add('FaceEmbeddingField', path.join(__dirname, './components/FaceEmbeddingField.tsx'), 'components'),
} }
@@ -0,0 +1,26 @@
import React from 'react'
import { BasePropertyProps } from 'adminjs'
import { Label } from '@adminjs/design-system'
const FaceEmbeddingField: React.FC<BasePropertyProps> = ({ record, property }) => {
const has = !!record?.params?.faceEmbedding
return (
<div style={{ marginBottom: '16px' }}>
<Label>{property.label}</Label>
<div style={{ marginTop: 6 }}>
<span style={{
fontSize: '12px',
fontWeight: 600,
padding: '3px 10px',
borderRadius: '4px',
background: has ? '#dcfce7' : '#fee2e2',
color: has ? '#16a34a' : '#dc2626',
}}>
{has ? 'SET' : 'UNSET'}
</span>
</div>
</div>
)
}
export default FaceEmbeddingField
@@ -0,0 +1,96 @@
import React, { useState, useRef } from 'react'
import { Box, H3, Text, Button, MessageBox } from '@adminjs/design-system'
import { ActionProps, useRecord } from 'adminjs'
const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
const [preview, setPreview] = useState<string | null>(null)
const [base64, setBase64] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [notice, setNotice] = useState<{ message: string, type: 'success' | 'error' } | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
setPreview(result)
setBase64(result.split(',')[1]) // strip data:image/jpeg;base64,
}
reader.readAsDataURL(file)
}
const handleSubmit = async () => {
if (!base64) return
setLoading(true)
setNotice(null)
try {
const res = await fetch(
`/admin/api/resources/User/records/${record?.params.id}/uploadFace`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ base64 }),
}
)
const data = await res.json()
setNotice(data.notice)
} catch (e: any) {
setNotice({ message: e.message, type: 'error' })
} finally {
setLoading(false)
}
}
return (
<Box padding="xl">
<H3 marginBottom="md">Upload face image</H3>
<Text color="grey60" marginBottom="lg">
Upload a JPEG photo of the user to generate and store their face embedding.
</Text>
{notice && (
<MessageBox
message={notice.message}
variant={notice.type === 'success' ? 'success' : 'danger'}
marginBottom="lg"
/>
)}
<Box marginBottom="lg">
<input
ref={inputRef}
type="file"
accept="image/jpeg"
onChange={handleFile}
style={{ display: 'none' }}
/>
<Button onClick={() => inputRef.current?.click()} variant="outlined">
Choose JPEG
</Button>
</Box>
{preview && (
<Box marginBottom="lg">
<img
src={preview}
alt="Preview"
style={{ width: 160, height: 160, objectFit: 'cover', borderRadius: 8, border: '1px solid #e5e7eb' }}
/>
</Box>
)}
<Button
onClick={handleSubmit}
disabled={!base64 || loading}
variant="contained"
>
{loading ? 'Processing…' : 'Save embedding'}
</Button>
</Box>
)
}
export default UploadFace
@@ -1,5 +1,53 @@
import { prisma } from "../../../lib/prisma.js"; import { prisma } from "../../../lib/prisma.js";
import { getModelByName } from "@adminjs/prisma"; import { getModelByName } from "@adminjs/prisma";
import { spawn } from "child_process";
import { Components } from '../components.js'
import { fileURLToPath } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const execPath = '../../../../bin';
const binaryPath = path.join(__dirname, execPath, 'extractEmbeddings')
async function extractEmbedding(base64: string): Promise<string> {
console.log(base64);
return new Promise((resolve, reject) => {
const proc = spawn(binaryPath, [], {
cwd: path.join(__dirname, execPath)
})
let stdout = ''
let stderr = ''
proc.stdout.on('data', (d) => stdout += d)
proc.stderr.on('data', (d) => stderr += d)
proc.stdin.on('error', (err) => {
// ignore EPIPE here, we'll catch it in close handler
})
proc.on('error', (err) => {
reject(new Error(`Failed to start binary: ${err.message}`))
})
proc.on('close', (code) => {
console.log('extractEmbeddings exited with code', code)
console.log('stdout:', stdout)
console.log('stderr:', stderr)
if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`))
try {
const result = JSON.parse(stdout)
if (!result.success) return reject(new Error(result.error))
resolve(JSON.stringify(result.descriptor))
} catch (e) {
reject(new Error(`Failed to parse output: ${stdout}`))
}
})
proc.stdin.write(base64)
proc.stdin.end()
})
}
export default { export default {
resource: { resource: {
@@ -10,14 +58,56 @@ export default {
titleProperty: "id", titleProperty: "id",
navigation: { icon: "User" }, navigation: { icon: "User" },
listProperties: ["id", "firstName", "lastName"], listProperties: ["id", "firstName", "lastName"],
showProperties: ["id", "firstName", "lastName"], showProperties: ["id", "firstName", "lastName", "faceEmbedding"],
editProperties: ["firstName", "lastName"], editProperties: ["firstName", "lastName"],
filterProperties: ["firstName", "lastName"], filterProperties: ["firstName", "lastName"],
properties: { properties: {
faceEmbedding: { faceEmbedding: {
isVisible: false, isVisible: { list: false, show: true, edit: false, filter: false },
type: "string" type: "string",
components: {
list: Components.FaceEmbeddingField,
show: Components.FaceEmbeddingField,
},
}, },
} },
actions: {
uploadFace: {
actionType: 'record',
icon: 'Camera',
label: 'Upload face',
showInDrawer: true,
handler: async (request: any, response: any, context: any) => {
const { record, currentAdmin } = context
if (request.method === 'post') {
const { base64 } = request.payload
if (!base64) {
return {
record: record.toJSON(currentAdmin),
notice: { message: 'No image provided', type: 'error' },
}
}
try {
const embedding = await extractEmbedding(base64)
await prisma.user.update({
where: { id: record.params.id },
data: { faceEmbedding: embedding },
})
return {
record: record.toJSON(currentAdmin),
notice: { message: 'Face embedding saved!', type: 'success' },
}
} catch (e: any) {
return {
record: record.toJSON(currentAdmin),
notice: { message: `Failed: ${e.message}`, type: 'error' },
}
}
}
return { record: record.toJSON(currentAdmin) }
},
component: Components.UploadFace,
},
},
}, },
} }