Building a Face Detection
Attendance System
in Oracle APEX
A real story of trial, error, and finally making face-api.js actually work inside an APEX app. No fluff — just what happened and what finally worked.
So here is how it all started
I was tasked with building an attendance system for our office. The usual punch-card and swipe-card systems felt old and honestly a bit boring. I thought — why not use face recognition? I had seen a few demos online and it looked simple enough.
I work mainly with Oracle APEX, so naturally I wanted to build this whole thing inside APEX itself. I started looking for JavaScript libraries that could handle face detection right in the browser, without sending data to any external server. That's when I found face-api.js.
I tried a few things that just did not work
My first approach was to load face-api.js from the @vladmandic/face-api CDN. I added the URL to the Page JavaScript File URLs field in APEX. The page loaded, but every time I checked the console, faceapi was undefined.
@vladmandic/face-api CDN was not loading inside APEX pages. The library object was simply not available by the time my code ran, and the face detection never started.
I also tried loading the models from a local APEX static files folder. That gave me CORS errors in the console. Then I tried using a different script tag approach inside the HTML region — that caused timing issues where the script ran before the DOM was fully ready.
Using @vladmandic/face-api CDN — the library would not load in APEX at all. Console kept showing faceapi is not defined no matter what I tried.
Switching to the jsDelivr CDN for face-api.js@0.22.2 and using GitHub raw URLs for the AI model weights. Clean, reliable, no CORS issues.
The real root cause was two things: the CDN URL was wrong, and I had no fallback for when the library hadn't finished loading.
waitForFaceApi polling function that checks every 200ms whether faceapi is available, and only then loads the models. Combined with the correct CDN, this fixed everything.
How to set this up, step by step
The system has two pages — one for registering employee faces, and one for marking attendance using the camera. Both pages need the same CDN loaded first.
Add the CDN to both APEX pages
Go to each page properties and find the JavaScript section. In the File URLs field, add the face-api.js CDN link. This must be done on both the Register page and the Attendance page.
Set up browser security settings
Go to Page Properties → Security → Browser Security. Set the Referrer Policy to origin. This helps the model files load from GitHub without getting blocked.
Create the HTML structure
Add a Static Content Region to each page and put the HTML for the video feed, buttons, and status messages. The canvas element overlays the video to draw the face detection box.
Set up the AJAX Callback processes
You need four AJAX processes: SAVE_FACE, GET_FACES, MARK_ATT, and GET_TODAY_ATT. These handle the database operations from PL/SQL.
Add JavaScript in Execute when Page Loads
The full JavaScript goes inside the Execute when Page Loads section. Wrap everything in an IIFE so it does not create variable conflicts with APEX globals.
Step 1: The CDN URL for both pages
This goes inside Page Properties → JavaScript → File URLs on both pages. One issue: I had accidentally left the old vladmandic URL still in the field. Make sure you completely remove whatever was there before adding this.
https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js
To verify it loaded, open the browser console and run this. If you get "object", the library is ready. If you get "undefined", the CDN URL is not saved correctly.
console.log(typeof faceapi);
Step 2: Register page HTML and styles
The register page lets you add an employee by entering their name, code, and department. The camera shows a live feed and draws a green box when a face is detected. Once detected, you click Capture to save the face descriptor to the database.
HTML Header — CSS Styles
Add this in Page → HTML Header on the Register page:
<style>
#faceRegWrap * { box-sizing: border-box; }
#faceRegWrap { max-width:680px; margin:auto; font-family:Arial,sans-serif; }
.frm-box { background:#1a1a2e; padding:22px; border-radius:14px; }
.frm-box h2 { color:#e94560; text-align:center; margin:0 0 16px; }
.inp-row { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.inp-row input { flex:1; min-width:130px; padding:10px; border-radius:8px;
border:1.5px solid #e94560; background:#16213e; color:#fff; font-size:14px; }
.vid-wrap { position:relative; width:100%; background:#000;
border-radius:10px; overflow:hidden; }
.vid-wrap video { width:100%; display:block; border-radius:10px; }
.vid-wrap canvas { position:absolute; top:0; left:0; width:100%; height:100%; }
.btn-row { display:flex; gap:10px; justify-content:center;
margin-top:14px; flex-wrap:wrap; }
.btn-primary { background:#e94560; color:#fff; padding:11px 26px;
border:none; border-radius:8px; cursor:pointer; font-size:15px; font-weight:bold; }
.btn-secondary { background:#0f3460; color:#fff; padding:11px 26px;
border:none; border-radius:8px; cursor:pointer; font-size:15px; font-weight:bold; }
.btn-primary:disabled,.btn-secondary:disabled { opacity:0.5; cursor:not-allowed; }
#regStatus { margin-top:12px; text-align:center; font-size:14px;
color:#a0ffa0; padding:8px; min-height:28px; }
</style>Static Content Region HTML
<div id="faceRegWrap">
<div class="frm-box">
<h2>Register Employee Face</h2>
<div class="inp-row">
<input id="regEmpName" type="text" placeholder="Employee Name *">
<input id="regEmpCode" type="text" placeholder="Employee Code *">
<input id="regEmpDept" type="text" placeholder="Department">
</div>
<div class="vid-wrap">
<video id="regVideo" autoplay muted playsinline></video>
<canvas id="regCanvas"></canvas>
</div>
<div class="btn-row">
<button id="btnStartCam" class="btn-primary">Start Camera</button>
<button id="btnCapture" class="btn-secondary" disabled>
Capture & Register
</button>
</div>
<div id="regStatus">Initialising...</div>
</div>
</div>Step 3: Full JavaScript for the register page
Add this in Page → JavaScript → Execute when Page Loads. The key part is the waitForFaceApi function — it keeps checking every 200ms whether faceapi is available, and only then proceeds. Without this, everything was breaking.
(function () {
var MODEL_URL = 'https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights';
var stream = null;
var detectionTimer = null;
var currentDescriptor = null;
var modelsReady = false;
var video = document.getElementById('regVideo');
var canvas = document.getElementById('regCanvas');
var btnStart = document.getElementById('btnStartCam');
var btnCapture = document.getElementById('btnCapture');
var statusDiv = document.getElementById('regStatus');
function setStatus(msg, color) {
statusDiv.innerHTML = msg;
statusDiv.style.color = color || '#a0ffa0';
}
function waitForFaceApi(cb) {
if (typeof faceapi !== 'undefined') { cb(); return; }
var t = 0;
var iv = setInterval(function () {
t += 200;
if (typeof faceapi !== 'undefined') { clearInterval(iv); cb(); }
else if (t > 10000) {
clearInterval(iv);
setStatus('face-api.js failed to load. Check File URLs setting.', '#ff6666');
}
}, 200);
}
function loadModels() {
setStatus('Loading AI models...');
waitForFaceApi(function () {
Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL)
]).then(function () {
modelsReady = true;
btnStart.disabled = false;
setStatus('Ready. Click Start Camera.');
}).catch(function (err) {
setStatus('Model error: ' + err.message, '#ff6666');
console.error('Model load error', err);
});
});
}
function startCamera() {
if (!modelsReady) { setStatus('Models still loading...', '#ffcc00'); return; }
btnStart.disabled = true;
setStatus('Requesting camera...', '#ffcc00');
navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } })
.then(function (s) {
stream = s;
video.srcObject = s;
video.onloadedmetadata = function () {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
video.play();
btnCapture.disabled = false;
setStatus('Camera live. Position your face in the box.');
startDetection();
};
})
.catch(function (err) {
btnStart.disabled = false;
setStatus('Camera denied: ' + err.message, '#ff6666');
});
}
function startDetection() {
var ctx = canvas.getContext('2d');
detectionTimer = setInterval(function () {
if (video.readyState !== 4) return;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
faceapi.detectSingleFace(video, new faceapi.TinyFaceDetectorOptions())
.withFaceLandmarks()
.withFaceDescriptor()
.then(function (det) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (det) {
currentDescriptor = det.descriptor;
var b = det.detection.box;
ctx.strokeStyle = '#00ff88';
ctx.lineWidth = 3;
ctx.strokeRect(b.x, b.y, b.width, b.height);
ctx.fillStyle = '#00ff88';
ctx.font = 'bold 13px Arial';
ctx.fillText('Face Detected', b.x + 2, b.y > 18 ? b.y - 6 : b.y + 18);
} else {
currentDescriptor = null;
}
});
}, 400);
}
function captureFace() {
var name = document.getElementById('regEmpName').value.trim();
var code = document.getElementById('regEmpCode').value.trim();
var dept = document.getElementById('regEmpDept').value.trim();
if (!name || !code) { setStatus('Name and Code are required', '#ffcc00'); return; }
if (!currentDescriptor) { setStatus('No face detected.', '#ffcc00'); return; }
btnCapture.disabled = true;
setStatus('Saving to database...', '#ffcc00');
apex.server.process('SAVE_FACE', {
x01: name, x02: code, x03: dept,
x04: JSON.stringify(Array.from(currentDescriptor))
}, {
success: function (data) {
setStatus(name + ' registered successfully!');
document.getElementById('regEmpName').value = '';
document.getElementById('regEmpCode').value = '';
document.getElementById('regEmpDept').value = '';
btnCapture.disabled = false;
},
error: function (xhr, status, err) {
setStatus('DB save failed: ' + (err || status), '#ff6666');
btnCapture.disabled = false;
}
});
}
btnStart.disabled = true;
btnStart.addEventListener('click', startCamera);
btnCapture.addEventListener('click', captureFace);
loadModels();
}());Step 4: The attendance marking page
The attendance page loads all registered face descriptors from the database, builds a FaceMatcher object, and continuously looks for known faces through the camera. When the user clicks Mark IN or Mark OUT, the next recognized face gets recorded.
Attendance Page CSS
<style>
#attWrap { max-width:720px; margin:auto; font-family:Arial,sans-serif; }
.att-box { background:#0d0d1a; padding:22px; border-radius:14px; color:#fff; }
.att-box h2 { color:#00d4ff; text-align:center; margin:0 0 16px; }
.vid-wrap { position:relative; width:100%; background:#000; border-radius:10px; overflow:hidden; }
.vid-wrap video { width:100%; display:block; }
.vid-wrap canvas { position:absolute; top:0; left:0; width:100%; height:100%; }
.att-btns { display:flex; gap:10px; justify-content:center; margin-top:14px; flex-wrap:wrap; }
.btn-cam { background:#00d4ff; color:#000; padding:11px 22px; border:none;
border-radius:8px; cursor:pointer; font-weight:bold; font-size:14px; }
.btn-in { background:#00c853; color:#fff; padding:11px 22px; border:none;
border-radius:8px; cursor:pointer; font-weight:bold; font-size:14px; }
.btn-out { background:#ff5722; color:#fff; padding:11px 22px; border:none;
border-radius:8px; cursor:pointer; font-weight:bold; font-size:14px; }
.btn-cam:disabled,.btn-in:disabled,.btn-out:disabled { opacity:0.5; cursor:not-allowed; }
#attStatus { margin-top:12px; text-align:center; font-size:14px; padding:8px; min-height:28px; }
#attLog table { width:100%; border-collapse:collapse; font-size:13px; margin-top:10px; }
#attLog th { background:#1a1a3a; padding:7px; color:#00d4ff; }
#attLog td { padding:6px 7px; border-bottom:1px solid #222; }
</style>Attendance Page HTML Region
<div id="attWrap">
<div class="att-box">
<h2>Face Attendance System</h2>
<div class="vid-wrap">
<video id="attVideo" autoplay muted playsinline></video>
<canvas id="attCanvas"></canvas>
</div>
<div class="att-btns">
<button id="btnAttCam" class="btn-cam" disabled>Start Camera</button>
<button id="btnAttIn" class="btn-in" disabled>Mark IN</button>
<button id="btnAttOut" class="btn-out" disabled>Mark OUT</button>
</div>
<div id="attStatus">Loading models...</div>
<div id="attLog"></div>
</div>
</div>Step 5: Full attendance page JavaScript
This goes in Execute when Page Loads on the Attendance page. It loads registered faces from the database, builds the face matcher, and marks attendance when a known face is recognized after pressing a button.
(function () {
var MODEL_URL = 'https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights';
var stream = null;
var faceMatcher = null;
var modelsReady = false;
var pendingAttType = null;
var processing = false;
var video = document.getElementById('attVideo');
var canvas = document.getElementById('attCanvas');
var btnCam = document.getElementById('btnAttCam');
var btnIn = document.getElementById('btnAttIn');
var btnOut = document.getElementById('btnAttOut');
var statusDiv = document.getElementById('attStatus');
function setStatus(msg, color) {
statusDiv.innerHTML = msg;
statusDiv.style.color = color || '#a0ffa0';
}
function waitForFaceApi(cb) {
if (typeof faceapi !== 'undefined') { cb(); return; }
var t = 0;
var iv = setInterval(function () {
t += 200;
if (typeof faceapi !== 'undefined') { clearInterval(iv); cb(); }
else if (t > 10000) {
clearInterval(iv);
setStatus('face-api.js not loaded. Check File URLs.', '#ff6666');
}
}, 200);
}
function loadModels() {
setStatus('Loading AI models...');
waitForFaceApi(function () {
Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL)
]).then(function () {
modelsReady = true;
return loadRegisteredFaces();
}).catch(function (err) {
setStatus('Model load failed: ' + err.message, '#ff6666');
console.error(err);
});
});
}
function loadRegisteredFaces() {
setStatus('Loading registered faces from DB...', '#ffcc00');
apex.server.process('GET_FACES', {}, {
success: function (data) {
var faces;
try { faces = (typeof data === 'string') ? JSON.parse(data) : data; }
catch (e) { setStatus('Bad JSON from GET_FACES', '#ff6666'); return; }
if (!faces || faces.length === 0) {
setStatus('No registered faces. Please register first.', '#ffcc00');
btnCam.disabled = false;
return;
}
var labeled = faces.map(function (f) {
var arr = (typeof f.FACE_DESCRIPTOR === 'string')
? JSON.parse(f.FACE_DESCRIPTOR) : f.FACE_DESCRIPTOR;
return new faceapi.LabeledFaceDescriptors(
f.EMP_ID + '|' + f.EMP_CODE + '|' + f.EMP_NAME,
[new Float32Array(arr)]
);
});
faceMatcher = new faceapi.FaceMatcher(labeled, 0.5);
btnCam.disabled = false;
setStatus(faces.length + ' face(s) loaded. Click Start Camera.');
loadTodayLog();
},
error: function (xhr, status, err) {
setStatus('GET_FACES failed: ' + (err || status), '#ff6666');
}
});
}
function startCamera() {
btnCam.disabled = true;
setStatus('Starting camera...', '#ffcc00');
navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } })
.then(function (s) {
stream = s; video.srcObject = s;
video.onloadedmetadata = function () {
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
video.play();
btnIn.disabled = false; btnOut.disabled = false;
setStatus('Camera live. Click Mark IN or Mark OUT then look at camera.');
startDetectionLoop();
};
})
.catch(function (err) {
btnCam.disabled = false;
setStatus('Camera error: ' + err.message, '#ff6666');
});
}
function startDetectionLoop() {
var ctx = canvas.getContext('2d');
setInterval(function () {
if (video.readyState !== 4) return;
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
faceapi.detectAllFaces(video, new faceapi.TinyFaceDetectorOptions())
.withFaceLandmarks().withFaceDescriptors()
.then(function (detections) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
detections.forEach(function (det) {
var b = det.detection.box;
var match = faceMatcher ? faceMatcher.findBestMatch(det.descriptor) : null;
var known = match && match.label !== 'unknown';
var conf = match ? ((1 - match.distance) * 100).toFixed(1) : 0;
ctx.strokeStyle = known ? '#00ff88' : '#ff4444';
ctx.lineWidth = 3;
ctx.strokeRect(b.x, b.y, b.width, b.height);
ctx.fillStyle = known ? '#00ff88' : '#ff4444';
ctx.font = 'bold 13px Arial';
var label = known ? (match.label.split('|')[2] + ' (' + conf + '%)') : 'Unknown';
ctx.fillText(label, b.x, b.y > 18 ? b.y - 6 : b.y + 18);
if (known && pendingAttType && !processing) {
processing = true;
var parts = match.label.split('|');
saveAttendance(parts[0], parts[1], parts[2], conf, pendingAttType);
pendingAttType = null;
}
});
});
}, 500);
}
function saveAttendance(empId, empCode, empName, conf, attType) {
setStatus('Marking ' + attType + ' for ' + empName + '...', '#ffcc00');
btnIn.disabled = true; btnOut.disabled = true;
apex.server.process('MARK_ATT', {
x01: empId, x02: empCode, x03: empName, x04: attType, x05: conf
}, {
success: function () {
setStatus(empName + ' marked ' + attType + ' (' + conf + '% match)');
btnIn.disabled = false; btnOut.disabled = false;
processing = false; loadTodayLog();
},
error: function (xhr, status, err) {
setStatus('Save error: ' + (err || status), '#ff6666');
btnIn.disabled = false; btnOut.disabled = false; processing = false;
}
});
}
function loadTodayLog() {
apex.server.process('GET_TODAY_ATT', {}, {
success: function (data) {
var rows;
try { rows = (typeof data === 'string') ? JSON.parse(data) : data; }
catch (e) { return; }
if (!rows || rows.length === 0) {
document.getElementById('attLog').innerHTML =
'<p style="color:#666;text-align:center;margin-top:10px;">No attendance recorded today.</p>';
return;
}
var html = '<table><tr><th>Name</th><th>Code</th><th>Time</th><th>Type</th><th>Confidence</th></tr>';
rows.forEach(function (r) {
html += '<tr><td>' + r.EMP_NAME + '</td><td>' + r.EMP_CODE +
'</td><td>' + r.ATT_TIME + '</td><td style="color:' +
(r.ATT_TYPE==='IN' ? '#00ff88' : '#ff5555') + ';font-weight:bold;">' +
r.ATT_TYPE + '</td><td>' + r.CONFIDENCE + '%</td></tr>';
});
html += '</table>';
document.getElementById('attLog').innerHTML = html;
}
});
}
btnCam.addEventListener('click', startCamera);
btnIn.addEventListener('click', function () { pendingAttType = 'IN'; setStatus('Look at camera for IN mark...', '#ffcc00'); });
btnOut.addEventListener('click', function () { pendingAttType = 'OUT'; setStatus('Look at camera for OUT mark...', '#ffcc00'); });
loadModels();
}());Step 6: The AJAX Callback processes you need
Without these, the JavaScript calls to apex.server.process will fail silently or return errors. Go to the Processing tab on each page and verify these exist as Ajax Callback type processes.
| Page | Process Name | Type | Purpose |
|---|---|---|---|
| Register Page | SAVE_FACE | Ajax Callback | Saves employee name, code, dept, and face descriptor JSON to the database |
| Attendance Page | GET_FACES | Ajax Callback | Returns all registered face records as JSON for the FaceMatcher to load |
| Attendance Page | MARK_ATT | Ajax Callback | Inserts a new attendance record with employee ID, type, confidence, and timestamp |
| Attendance Page | GET_TODAY_ATT | Ajax Callback | Returns today's attendance records to show in the table below the camera |
What I learned from all this
Honestly, this project took me longer than I expected — mostly because of the CDN issue. The moment I switched to the correct jsDelivr URL and added the waitForFaceApi polling function, everything started working. The models load from GitHub raw URLs which is reliable and free.
The face matching accuracy is pretty good with proper lighting. I found that fluorescent office lighting actually works better than natural window light for this. Avoid backlighting — do not sit with a window behind you.
If you build something similar or run into issues, the GitHub repo below has the full code. Feel free to raise an issue there. The live demo is running on my APEX workspace so you can actually test the face registration flow yourself.
Ready to try it yourself?
The live demo is fully functional — test face registration right in your browser.
Comments
Post a Comment