// ============================================ // Synthetic Generator — 2-Phase Single Screen // ============================================ const SyntheticGenerator = { currentPhase: 1, currentTool: 'polygon', brushSize: 10, maskOpacity: 0.5, zoomLevel: 1, // Phase 1: Source & Mask sourceImage: null, sourceImageData: null, maskCanvas: null, maskCtx: null, currentMask: null, isDrawing: false, polygonPoints: [], undoStack: [], redoStack: [], // Phase 1: Templates templates: [], // Phase 2: Target images targetImages: [], selectedTargetIndices: new Set(), activeSource: null, // Phase 2: ROI markers roiMarkers: [], selectedRoiIndex: null, // Phase 2: Target canvas targetCanvas: null, targetCtx: null, targetImage: null, targetImageData: null, targetZoomLevel: 1, // Phase 2: Transform settings transforms: { colorShift: 0, brightness: 0, rotation: 0, scale: 1.0, blur: 0, opacity: 1.0, randomColor: true, randomRotation: true, randomScale: true }, // Generation state generatedResults: [], init() { this.cacheElements(); this.populateSourceSelect(); this.populateTargetGallery(); this.bindEvents(); this.loadTemplates(); // Initialize mask canvas setTimeout(() => this.initMaskCanvas(), 100); }, cacheElements() { // Phase tabs this.phaseTabs = document.querySelectorAll('.synth-phase-tab'); this.phase1 = document.getElementById('synth-phase-1'); this.phase2 = document.getElementById('synth-phase-2'); // Phase 1 elements this.sourceSelect = document.getElementById('synth-source-select'); this.sourceCanvas = document.getElementById('synth-source-canvas'); this.sourceWrapper = document.getElementById('synth-source-wrapper'); this.sourceHint = document.getElementById('synth-source-hint'); this.sourceContainer = document.getElementById('synth-source-container'); this.templatesList = document.getElementById('synth-templates-list'); this.maskCanvas = document.getElementById('synth-mask-canvas'); this.templateName = document.getElementById('synth-template-name'); this.defectType = document.getElementById('synth-defect-type'); // Phase 2 elements this.targetGallery = document.getElementById('synth-target-gallery'); this.activeSource = document.getElementById('synth-active-source'); this.targetCanvas = document.getElementById('synth-target-canvas'); this.targetWrapper = document.getElementById('synth-target-wrapper'); this.targetHint = document.getElementById('synth-target-hint'); this.targetContainer = document.getElementById('synth-target-container'); this.roiCount = document.getElementById('synth-roi-count'); this.totalCount = document.getElementById('synth-total-count'); this.resultsGrid = document.getElementById('synth-results-grid'); this.progressOverlay = document.getElementById('synth-progress-overlay'); this.progressFill = document.getElementById('synth-progress-fill'); this.progressCurrent = document.getElementById('synth-progress-current'); this.progressTotal = document.getElementById('synth-progress-total'); }, bindEvents() { // Phase tabs this.phaseTabs.forEach(tab => { tab.addEventListener('click', () => this.switchPhase(parseInt(tab.dataset.phase))); }); // Source image select this.sourceSelect?.addEventListener('change', () => this.loadSourceImage()); // Zoom controls document.getElementById('synth-zoom-in')?.addEventListener('click', () => this.adjustZoom('source', 0.25)); document.getElementById('synth-zoom-out')?.addEventListener('click', () => this.adjustZoom('source', -0.25)); document.getElementById('synth-zoom-fit')?.addEventListener('click', () => this.zoomFit('source')); document.getElementById('synth-target-zoom-in')?.addEventListener('click', () => this.adjustZoom('target', 0.25)); document.getElementById('synth-target-zoom-out')?.addEventListener('click', () => this.adjustZoom('target', -0.25)); document.getElementById('synth-target-zoom-fit')?.addEventListener('click', () => this.zoomFit('target')); // Tool buttons document.querySelectorAll('.synth-tool-btn[data-tool]').forEach(btn => { btn.addEventListener('click', () => this.selectTool(btn.dataset.tool)); }); // Undo/Redo document.getElementById('synth-btn-undo')?.addEventListener('click', () => this.undo()); document.getElementById('synth-btn-redo')?.addEventListener('click', () => this.redo()); // Brush size & opacity sliders document.getElementById('synth-brush-size')?.addEventListener('input', (e) => { this.brushSize = parseInt(e.target.value); document.getElementById('synth-brush-size-val').textContent = this.brushSize + 'px'; }); document.getElementById('synth-mask-opacity')?.addEventListener('input', (e) => { this.maskOpacity = parseInt(e.target.value) / 100; document.getElementById('synth-opacity-val').textContent = Math.round(this.maskOpacity * 100) + '%'; }); // Template actions document.getElementById('synth-btn-clear-mask')?.addEventListener('click', () => this.clearMask()); document.getElementById('synth-btn-save-template')?.addEventListener('click', () => this.saveTemplate()); // Phase 1 → 2 button document.getElementById('synth-btn-to-phase2')?.addEventListener('click', () => this.switchToPhase2()); // Phase 2: Target gallery selection document.getElementById('synth-btn-select-good')?.addEventListener('click', () => this.selectAllGood()); // Phase 2: ROI actions document.getElementById('synth-btn-add-roi')?.addEventListener('click', () => this.addRoi()); document.getElementById('synth-btn-clear-rois')?.addEventListener('click', () => this.clearRois()); // Transform sliders ['color-shift', 'brightness', 'rotation', 'scale', 'blur', 'opacity-transform'].forEach(id => { document.getElementById('synth-' + id)?.addEventListener('input', (e) => { const value = parseFloat(e.target.value); const displayId = id === 'color-shift' ? 'synth-color-val' : id === 'opacity-transform' ? 'synth-opacity-transform-val' : 'synth-' + id.replace('-', '-') + '-val'; if (id === 'scale') { this.transforms.scale = value / 100; document.getElementById('synth-scale-val').textContent = (value / 100).toFixed(1); } else if (id === 'opacity-transform') { this.transforms.opacity = value / 100; document.getElementById('synth-opacity-transform-val').textContent = value + '%'; } else { this.transforms[id.replace('-transform', '')] = value; const valEl = document.getElementById(displayId); if (valEl) valEl.textContent = value; } }); }); // Copies per ROI document.getElementById('synth-copies-per-roi')?.addEventListener('input', () => this.updateTotalCount()); // Back to Phase 1 document.getElementById('synth-btn-back-to-phase1')?.addEventListener('click', () => this.switchPhase(1)); // Generate button document.getElementById('synth-btn-generate')?.addEventListener('click', () => this.generateSynthetic()); // Download / Add to Dataset document.getElementById('synth-btn-download-all')?.addEventListener('click', () => this.downloadAll()); document.getElementById('synth-btn-add-to-dataset')?.addEventListener('click', () => this.addToDataset()); }, // ==================== PHASE MANAGEMENT ==================== switchPhase(phase) { this.currentPhase = phase; this.phaseTabs.forEach(tab => { const tabPhase = parseInt(tab.dataset.phase); tab.classList.toggle('active', tabPhase === phase); }); this.phase1.classList.toggle('hidden', phase !== 1); this.phase2.classList.toggle('hidden', phase !== 2); if (phase === 2) { this.initTargetCanvas(); this.updateTotalCount(); } }, // ==================== PHASE 1: SOURCE & MASK ==================== populateSourceSelect() { if (!this.sourceSelect) return; // Get defect images from mock data const defectImages = MockData.images.filter(img => img.category.toLowerCase().includes('d02') || img.category.toLowerCase().includes('d07') || img.category.toLowerCase().includes('d08') ); this.sourceSelect.innerHTML = '' + defectImages.map(img => ``).join(''); // Store preview URLs for later use this.imagePreviewUrls = {}; MockData.images.forEach(img => { this.imagePreviewUrls[img.id] = Helpers.getImagePreviewUrl(img.id); }); }, loadSourceImage() { const imageId = this.sourceSelect?.value; if (!imageId) return; const image = MockData.images.find(img => img.id === imageId); if (!image) return; this.sourceImage = image; // Load image const img = new Image(); img.onload = () => { this.sourceImageData = img; this.sourceHint.style.display = 'none'; // Calculate canvas size for side-by-side view const container = this.sourceWrapper; const maxWidth = Math.min(container?.clientWidth || 300, 500); const maxHeight = Math.min(container?.clientHeight || 300, 400); let width = img.width; let height = img.height; // Scale down to fit container if (width > maxWidth || height > maxHeight) { const ratio = Math.min(maxWidth / width, maxHeight / height); width = width * ratio; height = height * ratio; } // Set minimum size width = Math.max(width, 150); height = Math.max(height, 150); // Set source canvas this.sourceCanvas.width = width; this.sourceCanvas.height = height; this.sourceCanvas.style.width = width + 'px'; this.sourceCanvas.style.height = height + 'px'; this.sourceCanvas.style.display = 'block'; this.sourceCanvas.style.position = 'absolute'; this.sourceCanvas.style.top = '0'; this.sourceCanvas.style.left = '0'; const ctx = this.sourceCanvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); // Initialize mask canvas (for the annotated view) this.initMaskCanvas(); // Initialize annotated canvas (composite view) this.initAnnotatedCanvas(); }; img.onerror = () => { // Create a placeholder image on error const canvas = document.createElement('canvas'); canvas.width = 200; canvas.height = 200; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ddd'; ctx.fillRect(0, 0, 200, 200); ctx.fillStyle = '#999'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Image not found', 100, 100); const dataUrl = canvas.toDataURL(); const img = new Image(); img.onload = () => { this.sourceImageData = img; this.sourceCanvas.width = 200; this.sourceCanvas.height = 200; this.sourceCanvas.style.display = 'block'; const ctx = this.sourceCanvas.getContext('2d'); ctx.drawImage(img, 0, 0); this.initMaskCanvas(); this.initAnnotatedCanvas(); }; img.src = dataUrl; }; // Try to load from preview folder const previewPath = this.imagePreviewUrls?.[image.id] || Helpers.getImagePreviewUrl(image.id); img.src = previewPath || ''; }, initMaskCanvas() { if (!this.maskCanvas || !this.sourceCanvas) return; this.maskCanvas.width = this.sourceCanvas.width || 300; this.maskCanvas.height = this.sourceCanvas.height || 300; this.maskCanvas.style.width = this.sourceCanvas.style.width || '300px'; this.maskCanvas.style.height = this.sourceCanvas.style.height || '300px'; // Use addEventListener for better compatibility this.maskCanvas.addEventListener('mousedown', (e) => this.startDrawing(e)); this.maskCanvas.addEventListener('mousemove', (e) => this.draw(e)); this.maskCanvas.addEventListener('mouseup', () => this.stopDrawing()); this.maskCanvas.addEventListener('mouseleave', () => this.stopDrawing()); this.maskCanvas.addEventListener('dblclick', () => this.completePolygon()); // Ensure canvas is visible this.maskCanvas.style.display = 'block'; this.maskCanvas.style.position = 'absolute'; this.maskCanvas.style.top = '0'; this.maskCanvas.style.left = '0'; this.maskCanvas.style.pointerEvents = 'auto'; this.maskCanvas.style.cursor = 'crosshair'; }, initAnnotatedCanvas() { const annotatedCanvas = document.getElementById('synth-annotated-canvas'); const annotatedWrapper = document.getElementById('synth-annotated-wrapper'); if (!annotatedCanvas || !this.sourceCanvas) return; // Set same size as source canvas const width = this.sourceCanvas.width; const height = this.sourceCanvas.height; annotatedCanvas.width = width; annotatedCanvas.height = height; annotatedCanvas.style.width = width + 'px'; annotatedCanvas.style.height = height + 'px'; // Position the canvas annotatedCanvas.style.display = 'block'; annotatedCanvas.style.position = 'absolute'; annotatedCanvas.style.top = '0'; annotatedCanvas.style.left = '0'; // Draw the composite view this.updateAnnotatedView(); }, updateAnnotatedView() { const annotatedCanvas = document.getElementById('synth-annotated-canvas'); if (!annotatedCanvas || !this.sourceImageData) return; const ctx = annotatedCanvas.getContext('2d'); const width = this.sourceCanvas.width; const height = this.sourceCanvas.height; // Clear ctx.clearRect(0, 0, width, height); // Draw original image ctx.drawImage(this.sourceImageData, 0, 0, width, height); // Draw mask overlay if any if (this.maskCanvas && this.currentTool !== 'polygon') { ctx.globalAlpha = this.maskOpacity * 0.7; ctx.drawImage(this.maskCanvas, 0, 0); ctx.globalAlpha = 1; } }, completePolygon() { if (this.polygonPoints.length < 3) return; const ctx = this.maskCanvas.getContext('2d'); ctx.beginPath(); ctx.moveTo(this.polygonPoints[0].x, this.polygonPoints[0].y); for (let i = 1; i < this.polygonPoints.length; i++) { ctx.lineTo(this.polygonPoints[i].x, this.polygonPoints[i].y); } ctx.closePath(); ctx.fillStyle = `rgba(255,0,0,${this.maskOpacity * 0.5})`; ctx.fill(); this.polygonPoints = []; this.isDrawing = false; }, selectTool(tool) { if (tool === 'erase') { this.currentTool = 'erase'; } else if (tool === 'clear') { this.clearMask(); return; } else { this.currentTool = tool; } document.querySelectorAll('.synth-tool-btn[data-tool]').forEach(btn => { btn.classList.toggle('active', btn.dataset.tool === tool); }); }, saveState() { if (!this.maskCanvas) return; const imageData = this.maskCanvas.getContext('2d').getImageData( 0, 0, this.maskCanvas.width, this.maskCanvas.height ); this.undoStack.push(imageData); if (this.undoStack.length > 20) this.undoStack.shift(); this.redoStack = []; }, undo() { if (this.undoStack.length === 0) return; const state = this.undoStack.pop(); this.redoStack.push(state); const ctx = this.maskCanvas.getContext('2d'); ctx.putImageData(state, 0, 0); }, redo() { if (this.redoStack.length === 0) return; const state = this.redoStack.pop(); this.undoStack.push(state); const ctx = this.maskCanvas.getContext('2d'); ctx.putImageData(state, 0, 0); }, startDrawing(e) { e.preventDefault(); this.isDrawing = true; this.saveState(); if (this.currentTool === 'polygon') { const rect = this.maskCanvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; this.polygonPoints.push({ x, y }); this.drawPolygon(); } else { const rect = this.maskCanvas.getBoundingClientRect(); this.lastX = e.clientX - rect.left; this.lastY = e.clientY - rect.top; } }, draw(e) { if (!this.isDrawing) return; const ctx = this.maskCanvas.getContext('2d'); const rect = this.maskCanvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; if (this.currentTool === 'polygon') { // Just update last point for polygon this.lastX = x; this.lastY = y; return; } // Draw solid strokes (pencil/brush mode) ctx.beginPath(); ctx.lineWidth = this.brushSize; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (this.currentTool === 'erase') { ctx.globalCompositeOperation = 'destination-out'; ctx.strokeStyle = 'rgba(0,0,0,1)'; } else { ctx.globalCompositeOperation = 'source-over'; ctx.strokeStyle = `rgba(255, 0, 0, ${this.maskOpacity})`; } ctx.moveTo(this.lastX, this.lastY); ctx.lineTo(x, y); ctx.stroke(); this.lastX = x; this.lastY = y; // Update the annotated view after drawing this.updateAnnotatedView(); }, stopDrawing() { this.isDrawing = false; if (this.currentTool === 'polygon' && this.polygonPoints.length > 2) { // Complete polygon on double-click or when clicking near start } }, drawPolygon() { if (this.polygonPoints.length < 2) return; const ctx = this.maskCanvas.getContext('2d'); ctx.beginPath(); ctx.moveTo(this.polygonPoints[0].x, this.polygonPoints[0].y); for (let i = 1; i < this.polygonPoints.length; i++) { ctx.lineTo(this.polygonPoints[i].x, this.polygonPoints[i].y); } ctx.strokeStyle = `rgba(255,0,0,${this.maskOpacity})`; ctx.lineWidth = this.brushSize; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); }, clearMask() { if (!this.maskCanvas) return; this.saveState(); const ctx = this.maskCanvas.getContext('2d'); ctx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); this.polygonPoints = []; }, // ==================== TEMPLATES ==================== loadTemplates() { // Load from localStorage or use default const saved = localStorage.getItem('synth-templates'); this.templates = saved ? JSON.parse(saved) : []; this.renderTemplates(); }, saveTemplate() { if (!this.maskCanvas || !this.sourceImage) { Toast.show('Draw a mask first', 'warning'); return; } const name = this.templateName?.value?.trim() || 'Unnamed Template'; const type = this.defectType?.value || 'custom'; try { // Capture mask as data URL const maskDataUrl = this.maskCanvas.toDataURL('image/png'); // Capture source image as data URL (may fail if image is external) let sourceDataUrl = ''; try { sourceDataUrl = this.sourceCanvas.toDataURL('image/png'); } catch (e) { // If source canvas is tainted, create a placeholder sourceDataUrl = ''; } const template = { id: Date.now(), name: name, type: type, maskDataUrl: maskDataUrl, sourceDataUrl: sourceDataUrl, createdAt: new Date().toISOString() }; this.templates.unshift(template); localStorage.setItem('synth-templates', JSON.stringify(this.templates)); this.renderTemplates(); Toast.show('Template saved!', 'success'); } catch (e) { Toast.show('Could not save template (CORS issue)', 'warning'); } }, useTemplate(templateId) { const template = this.templates.find(t => t.id === templateId); if (!template) return; this.activeSource = template; // Create source canvas placeholder (use mask which is always safe) this.sourceCanvas.width = 300; this.sourceCanvas.height = 300; this.sourceCanvas.style.width = '300px'; this.sourceCanvas.style.height = '300px'; this.sourceCanvas.style.display = 'block'; const ctx = this.sourceCanvas.getContext('2d'); ctx.fillStyle = '#555'; ctx.fillRect(0, 0, 300, 300); ctx.fillStyle = '#fff'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Source from template', 150, 150); // Create mask canvas this.maskCanvas.width = 300; this.maskCanvas.height = 300; this.maskCanvas.style.width = '300px'; this.maskCanvas.style.height = '300px'; this.maskCanvas.style.display = 'block'; this.maskCanvas.style.position = 'absolute'; this.maskCanvas.style.top = '0'; this.maskCanvas.style.left = '0'; this.maskCanvas.style.pointerEvents = 'auto'; // Load mask from template (always safe - it's from localStorage) const img = new Image(); img.onload = () => { const maskCtx = this.maskCanvas.getContext('2d'); maskCtx.drawImage(img, 0, 0, 300, 300); this.sourceHint.style.display = 'none'; Toast.show('Template applied!', 'success'); }; img.onerror = () => { Toast.show('Could not load template mask', 'warning'); }; img.src = template.maskDataUrl; this.sourceImageData = { width: 300, height: 300 }; }, renderTemplates() { if (!this.templatesList) return; if (this.templates.length === 0) { this.templatesList.innerHTML = `
No templates saved yet
Generate synthetic images