494 lines
24 KiB
HTML
494 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>skin Analysis</title>
|
|
<!-- Tailwind CSS for styling -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<!-- Font Awesome for icons -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<!-- JSZip for handling the mask zip file -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
|
<!-- JSEncrypt for API authentication -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsencrypt/3.3.2/jsencrypt.min.js"></script>
|
|
|
|
<style>
|
|
/* Custom animations for a smooth user experience */
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.fade-in {
|
|
animation: fadeIn 0.6s ease-out forwards;
|
|
}
|
|
@keyframes pulse {
|
|
0% { box-shadow: 0 0 0 0 rgba(147, 197, 253, 0.7); }
|
|
70% { box-shadow: 0 0 0 10px rgba(147, 197, 253, 0); }
|
|
100% { box-shadow: 0 0 0 0 rgba(147, 197, 253, 0); }
|
|
}
|
|
.pulse {
|
|
animation: pulse 2s infinite;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="bg-gradient-to-br from-blue-50 to-green-50 min-h-screen font-sans text-gray-800">
|
|
|
|
<div class="container mx-auto px-4 py-12">
|
|
<!-- Header Section -->
|
|
<header class="text-center mb-12 fade-in">
|
|
<h1 class="text-4xl md:text-5xl font-bold text-blue-800 mb-4">AI-Powered Skin Analysis</h1>
|
|
<p class="text-lg text-gray-600 max-w-2xl mx-auto">
|
|
Upload a photo or take one instantly to get your skin analysis powered by AI.
|
|
</p>
|
|
</header>
|
|
|
|
<!-- Main Content Area -->
|
|
<main class="max-w-4xl mx-auto">
|
|
<div id="appContainer" class="bg-white rounded-2xl shadow-xl overflow-hidden fade-in">
|
|
<div class="p-8 md:p-12">
|
|
|
|
<!-- Initial View: Upload/Camera Buttons -->
|
|
<div id="initialView" class="flex flex-col sm:flex-row gap-4 mb-8 justify-center">
|
|
<button id="uploadBtn" class="btn-hover flex-1 bg-blue-100 hover:bg-blue-200 text-blue-800 font-medium py-3 px-6 rounded-lg transition-all flex items-center justify-center gap-2">
|
|
<i class="fas fa-upload"></i> Upload Photo
|
|
</button>
|
|
<button id="cameraBtn" class="btn-hover flex-1 bg-green-100 hover:bg-green-200 text-green-800 font-medium py-3 px-6 rounded-lg transition-all flex items-center justify-center gap-2">
|
|
<i class="fas fa-camera"></i> Take Photo
|
|
</button>
|
|
</div>
|
|
|
|
<!-- File Input (Hidden) -->
|
|
<input type="file" id="fileInput" accept="image/*" class="hidden">
|
|
|
|
<!-- Camera Preview View -->
|
|
<div id="cameraView" class="hidden mb-8">
|
|
<div class="relative bg-gray-100 rounded-lg overflow-hidden h-64 md:h-96">
|
|
<video id="cameraPreview" class="w-full h-full object-cover"></video>
|
|
<button id="captureBtn" class="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-white rounded-full p-3 shadow-lg pulse">
|
|
<i class="fas fa-circle text-red-500 text-2xl"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image Preview View -->
|
|
<div id="previewView" class="hidden mb-8">
|
|
<h3 class="text-xl font-semibold text-gray-700 mb-4">Your Photo</h3>
|
|
<div class="image-preview bg-gray-100 rounded-lg overflow-hidden flex justify-center items-center h-64 md:h-96">
|
|
<img id="previewImage" class="w-full h-full object-contain" alt="Your skin photo" />
|
|
<canvas id="hiddenCanvas" class="hidden"></canvas>
|
|
</div>
|
|
<button id="retakeBtn" class="mt-4 text-blue-600 hover:text-blue-800 flex items-center gap-2">
|
|
<i class="fas fa-redo"></i> Retake Photo
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Submit Button & Loading Indicator -->
|
|
<div id="submitContainer" class="hidden text-center mt-8">
|
|
<button id="submitBtn" class="btn-hover bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-8 rounded-lg transition-all transform hover:scale-105">
|
|
Analyze My skin <i class="fas fa-arrow-right ml-2"></i>
|
|
</button>
|
|
<p id="loadingIndicator" class="hidden mt-4 text-gray-500">Processing...</p>
|
|
<p id="errorMsg" class="hidden mt-4 text-red-600"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Analysis Results Section (Initially Hidden) -->
|
|
<section id="resultsSection" class="hidden mt-16 max-w-5xl mx-auto fade-in">
|
|
<h2 class="text-2xl font-bold text-blue-800 mb-6">Detailed skin Scores</h2>
|
|
<div class="flex flex-col md:flex-row gap-6">
|
|
<!-- Image with Overlay -->
|
|
<div class="relative w-full md:w-1/2">
|
|
<img id="resultImage" src="#" alt="Uploaded skin" class="w-full rounded-lg border shadow"/>
|
|
<img id="maskImage" src="#" alt="Analysis mask" class="absolute top-0 left-0 w-full h-full object-contain opacity-70 hidden"/>
|
|
</div>
|
|
<!-- Score List -->
|
|
<div id="scoreList" class="flex-1 overflow-y-auto max-h-[500px] bg-white rounded-lg shadow p-4 space-y-3">
|
|
<!-- Scores will be dynamically inserted here -->
|
|
</div>
|
|
</div>
|
|
<div class="text-center mt-8">
|
|
<button id="newAnalysisBtn" class="btn-hover bg-green-600 hover:bg-green-700 text-white font-medium py-3 px-8 rounded-lg transition-all transform hover:scale-105">
|
|
Start New Analysis <i class="fas fa-plus ml-2"></i>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
// All JavaScript logic is contained here.
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// ================== DOM Elements ==================
|
|
const uploadBtn = document.getElementById('uploadBtn');
|
|
const cameraBtn = document.getElementById('cameraBtn');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const initialView = document.getElementById('initialView');
|
|
const cameraView = document.getElementById('cameraView');
|
|
const cameraPreview = document.getElementById('cameraPreview');
|
|
const captureBtn = document.getElementById('captureBtn');
|
|
const previewView = document.getElementById('previewView');
|
|
const previewImage = document.getElementById('previewImage');
|
|
const hiddenCanvas = document.getElementById('hiddenCanvas');
|
|
const retakeBtn = document.getElementById('retakeBtn');
|
|
const submitContainer = document.getElementById('submitContainer');
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
const loadingIndicator = document.getElementById('loadingIndicator');
|
|
const errorMsg = document.getElementById('errorMsg');
|
|
const resultsSection = document.getElementById('resultsSection');
|
|
const resultImage = document.getElementById('resultImage');
|
|
const maskImage = document.getElementById('maskImage');
|
|
const scoreList = document.getElementById('scoreList');
|
|
const newAnalysisBtn = document.getElementById('newAnalysisBtn');
|
|
|
|
// ================== State & Configuration ==================
|
|
let currentFile = null;
|
|
let cameraStream = null;
|
|
const clientId = 'YTiFKSfOADhKE6HOS7wPbcrxeXA1LUyF';
|
|
const publicKey = `-----BEGIN PUBLIC KEY-----
|
|
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCGdjQUhil+XNmLtjLcsAAl+tV2bg0mEZAmkr7rg4Yn13wWoM4NY/uJk1ao0sN9ZfHmp7E+53fZYwPeD4wPss7q97h+MguhtDxdJMWarlora11adFpRN+VLEYCzfOPHOR2odrQdhQ5t9DtT0KyNJ4B2vcBzVwVslMk+AoSt5AOrUwIDAQAB
|
|
-----END PUBLIC KEY-----`;
|
|
|
|
// ================== Helper Functions ==================
|
|
function setView(viewId) {
|
|
const views = ['initialView', 'cameraView', 'previewView', 'resultsSection', 'submitContainer'];
|
|
views.forEach(id => {
|
|
const element = document.getElementById(id);
|
|
if (id === viewId || (viewId === 'previewView' && id === 'submitContainer') || (viewId === 'resultsSection' && id === 'submitContainer')) {
|
|
element.classList.remove('hidden');
|
|
} else {
|
|
element.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
if (viewId === 'resultsSection') {
|
|
document.getElementById('appContainer').classList.add('hidden');
|
|
resultsSection.classList.remove('hidden');
|
|
} else {
|
|
document.getElementById('appContainer').classList.remove('hidden');
|
|
resultsSection.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// --- API & Core Logic Functions ---
|
|
async function authenticate() {
|
|
const plain = `client_id=${clientId}×tamp=${Date.now()}`;
|
|
const encrypt = new JSEncrypt();
|
|
encrypt.setPublicKey(publicKey);
|
|
const id_token = encrypt.encrypt(plain);
|
|
if (!id_token) throw new Error('Encryption failed');
|
|
const response = await fetch('https://yce-api-01.perfectcorp.com/s2s/v1.0/client/auth', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ client_id: clientId, id_token })
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(`Authentication failed: ${error.error || response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
return data.result.access_token;
|
|
}
|
|
|
|
async function createUploadSlot(token, fileName, fileSize, contentType = 'image/jpeg') {
|
|
const response = await fetch('https://yce-api-01.perfectcorp.com/s2s/v1.1/file/skin-analysis', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
files: [{
|
|
content_type: contentType,
|
|
file_name: fileName,
|
|
file_size: fileSize
|
|
}]
|
|
})
|
|
});
|
|
if (!response.ok) {
|
|
const errText = await response.text();
|
|
throw new Error('Failed to create file slot: ' + errText);
|
|
}
|
|
const data = await response.json();
|
|
return data.result.files[0];
|
|
}
|
|
|
|
async function uploadToS3(uploadRequest, file) {
|
|
const response = await fetch(uploadRequest.url, {
|
|
method: uploadRequest.method,
|
|
headers: uploadRequest.headers,
|
|
body: file
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`S3 upload failed with status ${response.status}`);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function analyzeskin(token, fileId) {
|
|
const payload = {
|
|
request_id: 0,
|
|
payload: {
|
|
file_sets: {
|
|
src_ids: [fileId.trim()]
|
|
},
|
|
actions: [{
|
|
id: 0,
|
|
params: {},
|
|
dst_actions: ["wrinkle", "pore", "texture", "acne"]
|
|
}]
|
|
}
|
|
};
|
|
const response = await fetch('https://yce-api-01.perfectcorp.com/s2s/v1.0/task/skin-analysis', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!response.ok) {
|
|
const errorDetails = await response.json();
|
|
throw new Error(`Analysis failed: ${errorDetails.error || response.statusText}`);
|
|
}
|
|
const result = await response.json();
|
|
if (!result.result || !result.result.task_id) {
|
|
throw new Error("No task_id returned from API");
|
|
}
|
|
return result.result.task_id;
|
|
}
|
|
|
|
async function getAnalysisResult(token, taskId) {
|
|
const response = await fetch(
|
|
`https://yce-api-01.perfectcorp.com/s2s/v1.0/task/skin-analysis?task_id=${encodeURIComponent(taskId)}`,
|
|
{
|
|
method: 'GET',
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
}
|
|
);
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(`Failed to get results: ${error.error || response.statusText}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function pollForAnalysisResult(token, taskId) {
|
|
while (true) {
|
|
try {
|
|
const result = await getAnalysisResult(token, taskId);
|
|
const status = result.result.status;
|
|
const pollingInterval = result.result.polling_interval || 2000;
|
|
if (status === 'success') {
|
|
if (result.result.results && result.result.results.length > 0) {
|
|
return result;
|
|
}
|
|
throw new Error('Empty results array');
|
|
}
|
|
if (status === 'error') {
|
|
const errorMessage = result.result.error_message || 'No detailed error message provided';
|
|
throw new Error(`Analysis error: ${errorMessage}`);
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, pollingInterval));
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function resizeImageFile(file, maxWidth = 2048, maxHeight = 2048, minShortSide = 1080) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
let { width, height } = img;
|
|
let scaleRatio = 1;
|
|
if (width > maxWidth || height > maxHeight) {
|
|
const widthRatio = maxWidth / width;
|
|
const heightRatio = maxHeight / height;
|
|
scaleRatio = Math.min(widthRatio, heightRatio);
|
|
}
|
|
const shortSide = Math.min(width, height);
|
|
if (shortSide * scaleRatio < minShortSide) {
|
|
const upscaleRatio = minShortSide / shortSide;
|
|
scaleRatio = Math.max(upscaleRatio, scaleRatio);
|
|
}
|
|
const targetWidth = Math.round(width * scaleRatio);
|
|
const targetHeight = Math.round(height * scaleRatio);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = targetWidth;
|
|
canvas.height = targetHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (!blob) return reject(new Error('Canvas is empty'));
|
|
const resizedFile = new File([blob], file.name.replace(/\.\w+$/, '.jpg'), { type: 'image/jpeg' });
|
|
resolve(resizedFile);
|
|
},
|
|
'image/jpeg',
|
|
0.9
|
|
);
|
|
};
|
|
img.onerror = () => reject(new Error('Failed to load image'));
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
img.src = e.target.result;
|
|
};
|
|
reader.onerror = () => reject(new Error('File reading error'));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
// ================== Event Listeners ==================
|
|
uploadBtn.addEventListener('click', () => fileInput.click());
|
|
|
|
fileInput.addEventListener('change', (event) => {
|
|
const file = event.target.files[0];
|
|
if (file) {
|
|
currentFile = file;
|
|
previewImage.src = URL.createObjectURL(file);
|
|
setView('previewView');
|
|
}
|
|
});
|
|
|
|
cameraBtn.addEventListener('click', async () => {
|
|
try {
|
|
cameraStream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: 'user' },
|
|
audio: false
|
|
});
|
|
cameraPreview.srcObject = cameraStream;
|
|
cameraPreview.play();
|
|
setView('cameraView');
|
|
} catch (err) {
|
|
errorMsg.textContent = "Could not access the camera. Please check permissions.";
|
|
errorMsg.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
captureBtn.addEventListener('click', () => {
|
|
if (!cameraStream) return;
|
|
hiddenCanvas.width = cameraPreview.videoWidth;
|
|
hiddenCanvas.height = cameraPreview.videoHeight;
|
|
const ctx = hiddenCanvas.getContext('2d');
|
|
ctx.drawImage(cameraPreview, 0, 0, hiddenCanvas.width, hiddenCanvas.height);
|
|
hiddenCanvas.toBlob((blob) => {
|
|
if (blob) {
|
|
currentFile = new File([blob], "captured-image.jpeg", { type: "image/jpeg" });
|
|
previewImage.src = URL.createObjectURL(currentFile);
|
|
setView('previewView');
|
|
}
|
|
}, 'image/jpeg', 0.9);
|
|
cameraStream.getTracks().forEach(track => track.stop());
|
|
cameraStream = null;
|
|
});
|
|
|
|
retakeBtn.addEventListener('click', () => {
|
|
currentFile = null;
|
|
previewImage.src = '';
|
|
setView('initialView');
|
|
errorMsg.classList.add('hidden');
|
|
});
|
|
|
|
newAnalysisBtn.addEventListener('click', () => {
|
|
currentFile = null;
|
|
previewImage.src = '';
|
|
resultImage.src = '';
|
|
maskImage.src = '';
|
|
scoreList.innerHTML = '';
|
|
setView('initialView');
|
|
errorMsg.classList.add('hidden');
|
|
});
|
|
|
|
submitBtn.addEventListener('click', async () => {
|
|
if (!currentFile) {
|
|
errorMsg.textContent = 'Please select or capture a photo first.';
|
|
errorMsg.classList.remove('hidden');
|
|
return;
|
|
}
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Processing...';
|
|
loadingIndicator.classList.remove('hidden');
|
|
errorMsg.classList.add('hidden');
|
|
|
|
try {
|
|
const resizedFile = await resizeImageFile(currentFile);
|
|
const token = await authenticate();
|
|
const uploadSlot = await createUploadSlot(token, resizedFile.name, resizedFile.size);
|
|
await uploadToS3(uploadSlot.requests[0], resizedFile);
|
|
const cleanFileId = uploadSlot.file_id.replace(/^\/+/,'');
|
|
const taskId = await analyzeskin(token, cleanFileId);
|
|
const result = await pollForAnalysisResult(token, taskId);
|
|
|
|
const scores = result.result.results[0].scores;
|
|
const maskZipUrl = result.result.results[0].data[0].url;
|
|
|
|
const zipBlob = await fetch(maskZipUrl).then(r => r.blob());
|
|
const zip = await JSZip.loadAsync(zipBlob);
|
|
|
|
const maskMap = {};
|
|
for (const key in scores) {
|
|
const score = scores[key];
|
|
if (score.output_mask_name && zip.file(score.output_mask_name)) {
|
|
const fileData = await zip.file(score.output_mask_name).async("blob");
|
|
maskMap[score.output_mask_name] = URL.createObjectURL(fileData);
|
|
}
|
|
}
|
|
|
|
// Display results
|
|
resultImage.src = URL.createObjectURL(currentFile);
|
|
scoreList.innerHTML = '';
|
|
|
|
Object.keys(scores).filter(k => scores[k].ui_score !== undefined).forEach(metric => {
|
|
const score = scores[metric];
|
|
const card = document.createElement('div');
|
|
card.className = 'score-card p-4 rounded-lg cursor-pointer transition-all duration-200 bg-gray-50 hover:bg-gray-100';
|
|
card.innerHTML = `<span class="capitalize">${metric.replace(/_/g, ' ')}</span><span class="font-bold text-blue-700 ml-2">${score.ui_score}</span>`;
|
|
|
|
card.addEventListener('mouseenter', () => {
|
|
if (score.output_mask_name && maskMap[score.output_mask_name]) {
|
|
maskImage.src = maskMap[score.output_mask_name];
|
|
maskImage.classList.remove('hidden');
|
|
}
|
|
card.classList.add('bg-blue-100', 'border-l-4', 'border-blue-500');
|
|
});
|
|
|
|
card.addEventListener('mouseleave', () => {
|
|
maskImage.classList.add('hidden');
|
|
card.classList.remove('bg-blue-100', 'border-l-4', 'border-blue-500');
|
|
});
|
|
|
|
scoreList.appendChild(card);
|
|
});
|
|
|
|
setView('resultsSection');
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
errorMsg.textContent = `Analysis failed: ${e.message}`;
|
|
errorMsg.classList.remove('hidden');
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Analyze My skin';
|
|
loadingIndicator.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Add animation to elements when they come into view
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add('fade-in');
|
|
}
|
|
});
|
|
}, { threshold: 0.1 });
|
|
document.querySelectorAll('section, .feature-card').forEach(section => {
|
|
observer.observe(section);
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|