Initial commit: added frontend and backend
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user