'use strict'; /* ═══════════════════════════════════════════════════════════════════════════ JPEG-ЛАБОРАТОРИЯ - интерактив для 11 класса ───────────────────────────────────────────────────────────────────────── Что делает: показывает сжатие изображения алгоритмом JPEG в реальном времени. Пользователь рисует/загружает картинку, двигает ползунок качества и наблюдает, как меняется размер файла, PSNR, гистограмма и каналы. Три источника изображения: • нарисовать самому (кисть + палитра) • загрузить файл с компьютера • готовый пример (5 пресетов) Все вычисления реальные: используется canvas.toDataURL('image/jpeg', q) - это встроенный JPEG-энкодер браузера. ═══════════════════════════════════════════════════════════════════════════ */ (function () { /* ─── КОНСТАНТА: размер холста в пикселях ─────────────────────────────── */ /* Все изображения приводятся к квадрату 320×320, чтобы расчёты и графики были предсказуемыми и одинаковыми для любых источников. */ var SIZE = 320; /* ─── СОСТОЯНИЕ ПРИЛОЖЕНИЯ ───────────────────────────────────────────── Единый объект, хранящий всё, что меняется во время работы. Все функции читают и пишут только сюда - глобальных переменных нет. */ var state = { mode: null, // 'draw' | 'upload' | 'preset' - текущий источник quality: 0.75, // качество JPEG (0..1), соответствует Q=75 scheme: '444', // схема прореживания цвета: '444' | '422' | '420' color: '#1a1a1a', // текущий цвет кисти brushSize: 12, // толщина кисти в пикселях drawing: false, // сейчас идёт рисование мышью? lastX: 0, lastY: 0, // последняя точка линии (для плавного рисования) showDiff: false, // включён режим "карта различий"? originalImageData: null, // пиксели оригинала (ImageData) compressedImageData: null, // пиксели JPEG-версии (ImageData) pngBytes: 0, // размер PNG в байтах jpegBytes: 0, // размер JPEG в байтах psnr: null, // метрика PSNR (дБ) updateTimer: null // таймер отложенного сжатия (debounce) }; /* ─── КОРОТКИЕ УТИЛИТЫ ──────────────────────────────────────────────── */ /* Быстрый доступ к элементу по id - короче, чем document.getElementById */ function q(id) { return document.getElementById(id); } /* Вычисляет размер dataURL в байтах. dataURL - это base64-строка, где каждые 4 символа кодируют 3 байта. Учитываем padding в конце ('=' или '=='). */ function dataURLSize(url) { var i = url.indexOf(','); if (i < 0) return 0; var b64 = url.slice(i + 1); var pad = (b64.match(/=+$/) || [''])[0].length; return Math.floor(b64.length * 3 / 4) - pad; } /* Красиво форматирует размер: 512 → "512 Б", 10240 → "10.0 КБ" */ function formatBytes(n) { if (n < 1024) return n + ' Б'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' КБ'; return (n / 1024 / 1024).toFixed(2) + ' МБ'; } /* Ссылки на левый и правый холст: оригинал и JPEG-версия */ function drawCanvas() { return q('jlDrawCanvas'); } function resultCanvas() { return q('jlResultCanvas'); } /* Контексты для рисования. willReadFrequently: true - оптимизация для частого getImageData (используется при расчёте PSNR и гистограмм). */ function drawCtx() { return drawCanvas().getContext('2d', { willReadFrequently: true }); } function resultCtx() { return resultCanvas().getContext('2d', { willReadFrequently: true }); } /* ═══════════════════════════════════════════════════════════════════════ БЛОК 1. СТАРТОВЫЙ ЭКРАН И ПЕРЕКЛЮЧЕНИЕ РЕЖИМОВ ═══════════════════════════════════════════════════════════════════════ Показывает три тайла выбора источника и переключает рабочую область. В режиме "preset" кнопка "Скачать JPEG" скрывается (чужой контент). */ /* Возврат к стартовому экрану с тремя тайлами */ function showStart() { q('jlStart').classList.remove('jl-hidden'); q('jlWorkspace').classList.remove('active'); q('jlDrawTools').classList.remove('active'); q('jlPresets').classList.remove('active'); q('jlModeLabel').textContent = ''; state.mode = null; } /* Открывает рабочую область для выбранного режима. Показывает/прячет панели: кисти - только для 'draw', пресеты - только для 'preset'. */ function showWorkspace(mode) { state.mode = mode; q('jlStart').classList.add('jl-hidden'); q('jlWorkspace').classList.add('active'); q('jlDrawTools').classList.toggle('active', mode === 'draw'); q('jlPresets').classList.toggle('active', mode === 'preset'); var dl = q('jlDownload'); if (dl) dl.style.display = (mode === 'preset') ? 'none' : ''; var labels = { draw: 'Режим: рисование', upload: 'Режим: загруженный файл', preset: 'Режим: готовый пример' }; q('jlModeLabel').textContent = labels[mode] || ''; drawCanvas().style.cursor = (mode === 'draw') ? 'crosshair' : 'default'; } /* ═══════════════════════════════════════════════════════════════════════ БЛОК 2. РИСОВАНИЕ МЫШЬЮ / ПАЛЬЦЕМ ═══════════════════════════════════════════════════════════════════════ Позволяет пользователю нарисовать картинку прямо на холсте. После каждого штриха (mouseup) сжатие запускается заново. */ /* Заливает холст белым - "чистый лист" */ function clearCanvas() { var ctx = drawCtx(); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, SIZE, SIZE); } /* Переводит координаты курсора в координаты холста. Учитывает масштаб: canvas может быть показан с CSS-растяжением. */ function canvasCoords(evt) { var canvas = drawCanvas(); var rect = canvas.getBoundingClientRect(); var sx = canvas.width / rect.width; var sy = canvas.height / rect.height; return { x: (evt.clientX - rect.left) * sx, y: (evt.clientY - rect.top) * sy }; } /* Начало штриха: запоминаем точку и ставим первую "каплю" */ function startStroke(x, y) { if (state.mode !== 'draw') return; state.drawing = true; state.lastX = x; state.lastY = y; drawDot(x, y); } /* Продолжение штриха: тянем линию от предыдущей точки к новой */ function moveStroke(x, y) { if (!state.drawing) return; drawLine(state.lastX, state.lastY, x, y); state.lastX = x; state.lastY = y; } /* Конец штриха: отпустили мышь - запускаем сжатие с задержкой */ function endStroke() { if (!state.drawing) return; state.drawing = false; scheduleCompress(); } /* Рисует одну круглую точку (применяется при клике) */ function drawDot(x, y) { var ctx = drawCtx(); ctx.fillStyle = state.color; ctx.beginPath(); ctx.arc(x, y, state.brushSize / 2, 0, Math.PI * 2); ctx.fill(); } /* Рисует линию между двумя точками (при движении мыши) */ function drawLine(x1, y1, x2, y2) { var ctx = drawCtx(); ctx.strokeStyle = state.color; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = state.brushSize; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } /* ═══════════════════════════════════════════════════════════════════════ БЛОК 3. ПРЕСЕТЫ - ГОТОВЫЕ ПРИМЕРЫ ИЗОБРАЖЕНИЙ ═══════════════════════════════════════════════════════════════════════ Пять картинок, каждая показывает свою сторону JPEG: gradient - плавные переходы (JPEG справляется хорошо) portrait - лицо, кожа (типичный сюжет фотографии) text - мелкий текст (JPEG "размазывает" буквы) checker - шахматка (худший случай для JPEG) landscape - смешанный сюжет с градиентами и деталями */ var PRESETS = { /* Плавный цветовой градиент - JPEG сохраняет почти идеально */ gradient: function (ctx) { var g = ctx.createLinearGradient(0, 0, SIZE, SIZE); g.addColorStop(0, '#1a3a6a'); g.addColorStop(0.5, '#c76a3a'); g.addColorStop(1, '#f0e0a0'); ctx.fillStyle = g; ctx.fillRect(0, 0, SIZE, SIZE); var g2 = ctx.createRadialGradient(SIZE * 0.3, SIZE * 0.3, 10, SIZE * 0.3, SIZE * 0.3, SIZE * 0.7); g2.addColorStop(0, 'rgba(255,255,255,.35)'); g2.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = g2; ctx.fillRect(0, 0, SIZE, SIZE); }, /* Схематичный портрет: волосы, лицо, глаза, улыбка */ portrait: function (ctx) { var bg = ctx.createRadialGradient(SIZE / 2, SIZE / 2, 20, SIZE / 2, SIZE / 2, SIZE * 0.7); bg.addColorStop(0, '#e8d9c0'); bg.addColorStop(1, '#b39a7a'); ctx.fillStyle = bg; ctx.fillRect(0, 0, SIZE, SIZE); ctx.fillStyle = '#3a2a1a'; ctx.beginPath(); ctx.ellipse(SIZE / 2, SIZE * 0.38, SIZE * 0.28, SIZE * 0.30, 0, 0, Math.PI * 2); ctx.fill(); var face = ctx.createRadialGradient(SIZE / 2, SIZE * 0.45, 10, SIZE / 2, SIZE * 0.55, SIZE * 0.28); face.addColorStop(0, '#f5d5a8'); face.addColorStop(1, '#d8a878'); ctx.fillStyle = face; ctx.beginPath(); ctx.ellipse(SIZE / 2, SIZE * 0.52, SIZE * 0.22, SIZE * 0.28, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.ellipse(SIZE * 0.42, SIZE * 0.48, SIZE * 0.045, SIZE * 0.03, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(SIZE * 0.58, SIZE * 0.48, SIZE * 0.045, SIZE * 0.03, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#1a1a1a'; ctx.beginPath(); ctx.arc(SIZE * 0.42, SIZE * 0.48, SIZE * 0.02, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(SIZE * 0.58, SIZE * 0.48, SIZE * 0.02, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#a87858'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(SIZE / 2, SIZE * 0.52); ctx.lineTo(SIZE / 2, SIZE * 0.58); ctx.stroke(); ctx.strokeStyle = '#8a2828'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(SIZE / 2, SIZE * 0.62, SIZE * 0.08, 0.2, Math.PI - 0.2); ctx.stroke(); }, /* Страница мелкого текста - JPEG плохо справляется с острыми буквами */ text: function (ctx) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, SIZE, SIZE); ctx.fillStyle = '#1a1a1a'; ctx.font = 'bold 22px Arial, sans-serif'; ctx.fillText('JPEG', 20, 40); ctx.font = '16px Arial, sans-serif'; ctx.fillText('сжатие с потерями', 20, 70); ctx.font = '14px Arial, sans-serif'; for (var i = 0; i < 8; i++) { ctx.fillText('Строка текста ' + (i + 1) + ': маленькие буквы.', 20, 110 + i * 26); } ctx.font = '12px Arial, sans-serif'; ctx.fillStyle = '#555'; ctx.fillText('Мелкий шрифт поддается сжатию хуже всего.', 20, SIZE - 20); }, /* Шахматка 16×16: худший случай для JPEG из-за резких границ */ checker: function (ctx) { var n = 16, c = SIZE / n; for (var y = 0; y < n; y++) { for (var x = 0; x < n; x++) { ctx.fillStyle = ((x + y) % 2 === 0) ? '#ff4040' : '#4040ff'; ctx.fillRect(x * c, y * c, c, c); } } }, /* Пейзаж: смешанный сюжет - градиенты, детали, контуры */ landscape: function (ctx) { var sky = ctx.createLinearGradient(0, 0, 0, SIZE * 0.55); sky.addColorStop(0, '#4a90c8'); sky.addColorStop(0.7, '#c8e0f0'); sky.addColorStop(1, '#f0d8a8'); ctx.fillStyle = sky; ctx.fillRect(0, 0, SIZE, SIZE * 0.55); var sun = ctx.createRadialGradient(SIZE * 0.75, SIZE * 0.28, 4, SIZE * 0.75, SIZE * 0.28, 44); sun.addColorStop(0, '#fff8d0'); sun.addColorStop(1, 'rgba(255,240,180,0)'); ctx.fillStyle = sun; ctx.fillRect(0, 0, SIZE, SIZE * 0.6); ctx.fillStyle = '#fff0b0'; ctx.beginPath(); ctx.arc(SIZE * 0.75, SIZE * 0.28, 22, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#6a7a90'; ctx.beginPath(); ctx.moveTo(0, SIZE * 0.55); ctx.lineTo(SIZE * 0.20, SIZE * 0.32); ctx.lineTo(SIZE * 0.40, SIZE * 0.50); ctx.lineTo(SIZE * 0.55, SIZE * 0.30); ctx.lineTo(SIZE * 0.75, SIZE * 0.48); ctx.lineTo(SIZE * 0.90, SIZE * 0.35); ctx.lineTo(SIZE, SIZE * 0.50); ctx.lineTo(SIZE, SIZE * 0.55); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#3a4a58'; ctx.beginPath(); ctx.moveTo(0, SIZE * 0.55); ctx.lineTo(SIZE * 0.28, SIZE * 0.42); ctx.lineTo(SIZE * 0.55, SIZE * 0.55); ctx.lineTo(SIZE * 0.75, SIZE * 0.45); ctx.lineTo(SIZE, SIZE * 0.55); ctx.lineTo(SIZE, SIZE); ctx.lineTo(0, SIZE); ctx.closePath(); ctx.fill(); var ground = ctx.createLinearGradient(0, SIZE * 0.55, 0, SIZE); ground.addColorStop(0, '#4a6a3a'); ground.addColorStop(1, '#20301a'); ctx.fillStyle = ground; ctx.beginPath(); ctx.moveTo(0, SIZE * 0.62); ctx.quadraticCurveTo(SIZE * 0.4, SIZE * 0.55, SIZE, SIZE * 0.68); ctx.lineTo(SIZE, SIZE); ctx.lineTo(0, SIZE); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#3a2010'; ctx.fillRect(SIZE * 0.15, SIZE * 0.65, 8, SIZE * 0.20); ctx.fillStyle = '#2a4a2a'; ctx.beginPath(); ctx.arc(SIZE * 0.155 + 4, SIZE * 0.60, 26, 0, Math.PI * 2); ctx.fill(); } }; /* Применяет выбранный пресет к холсту и запускает сжатие */ function applyPreset(name) { var preset = PRESETS[name]; if (!preset) return; var ctx = drawCtx(); ctx.clearRect(0, 0, SIZE, SIZE); preset(ctx); scheduleCompress(); } /* Рисует случайные цветные пятна - "быстрая картинка" */ function randomDraw() { var ctx = drawCtx(); clearCanvas(); var palette = ['#1a1a1a', '#6aa9ff', '#ff5d5d', '#fde68a', '#4ade80']; for (var i = 0; i < 60; i++) { ctx.fillStyle = palette[Math.floor(Math.random() * palette.length)]; ctx.globalAlpha = 0.7; ctx.beginPath(); ctx.arc(Math.random() * SIZE, Math.random() * SIZE, 8 + Math.random() * 30, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; scheduleCompress(); } /* ═══════════════════════════════════════════════════════════════════════ БЛОК 4. ЗАГРУЗКА ФАЙЛА С КОМПЬЮТЕРА ═══════════════════════════════════════════════════════════════════════ Пользователь выбирает картинку, она уменьшается до 320×320 с сохранением пропорций и вписывается в квадрат по центру. */ /* Открывает диалог выбора файла */ function openFile() { var input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = function (e) { var file = e.target.files && e.target.files[0]; if (file) loadFile(file); }; input.click(); } /* Читает файл, создаёт из него картинку, вписывает в холст */ function loadFile(file) { var reader = new FileReader(); reader.onload = function (e) { var img = new Image(); img.onload = function () { var ctx = drawCtx(); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, SIZE, SIZE); var scale = Math.min(SIZE / img.width, SIZE / img.height); var w = img.width * scale, h = img.height * scale; var x = (SIZE - w) / 2, y = (SIZE - h) / 2; ctx.drawImage(img, x, y, w, h); showWorkspace('upload'); scheduleCompress(); }; img.onerror = function () { alert('Не удалось прочитать файл.'); }; img.src = e.target.result; }; reader.onerror = function () { alert('Ошибка чтения файла.'); }; reader.readAsDataURL(file); } /* ═══════════════════════════════════════════════════════════════════════ БЛОК 5. ПРОРЕЖИВАНИЕ ЦВЕТНОСТИ YCbCr (chroma subsampling) ═══════════════════════════════════════════════════════════════════════ Эмулирует то, что делает JPEG до сжатия: 1. Переводит пиксели из RGB в YCbCr (яркость + две цветности) 2. Прореживает цветность - усредняет Cb и Cr по группам пикселей 3. Переводит обратно в RGB Схемы: 4:4:4 - без прореживания (данные о цвете сохраняются для каждого пикселя) 4:2:2 - по горизонтали в 2 раза (экономия 33%) 4:2:0 - по горизонтали и вертикали в 2 раза (экономия 50%, стандарт JPEG) Яркость Y сохраняется для каждого пикселя, потому что глаз к ней гораздо чувствительнее, чем к оттенкам цвета. */ function applyChromaSubsampling(sourceCanvas, scheme) { if (scheme === '444') return sourceCanvas; var w = sourceCanvas.width, h = sourceCanvas.height; var src = sourceCanvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, w, h); var temp = document.createElement('canvas'); temp.width = w; temp.height = h; var tctx = temp.getContext('2d'); var N = w * h; /* Шаг 1. RGB → YCbCr. Y - яркость, Cb/Cr - цветность (со сдвигом +128) */ var Y = new Float32Array(N), Cb = new Float32Array(N), Cr = new Float32Array(N); var d = src.data; for (var i = 0, p = 0; i < d.length; i += 4, p++) { var r = d[i], g = d[i + 1], b = d[i + 2]; Y[p] = 0.299 * r + 0.587 * g + 0.114 * b; Cb[p] = -0.1687 * r - 0.3313 * g + 0.5 * b + 128; Cr[p] = 0.5 * r - 0.4187 * g - 0.0813 * b + 128; } /* Шаг 2. Прореживание: усредняем Cb и Cr по блокам stepX × stepY */ var stepX = 2, stepY = (scheme === '420') ? 2 : 1; for (var y = 0; y < h; y += stepY) { for (var x = 0; x < w; x += stepX) { var sumCb = 0, sumCr = 0, count = 0; /* Сумма по блоку */ for (var dy = 0; dy < stepY; dy++) { for (var dx = 0; dx < stepX; dx++) { var px = x + dx, py = y + dy; if (px < w && py < h) { var pp = py * w + px; sumCb += Cb[pp]; sumCr += Cr[pp]; count++; } } } var avgCb = sumCb / count, avgCr = sumCr / count; /* Записываем усреднённое значение всем пикселям блока */ for (var dy2 = 0; dy2 < stepY; dy2++) { for (var dx2 = 0; dx2 < stepX; dx2++) { var px2 = x + dx2, py2 = y + dy2; if (px2 < w && py2 < h) { var pp2 = py2 * w + px2; Cb[pp2] = avgCb; Cr[pp2] = avgCr; } } } } } /* Шаг 3. YCbCr → RGB (обратное преобразование) */ var out = tctx.createImageData(w, h); var od = out.data; for (var p2 = 0; p2 < N; p2++) { var yv = Y[p2], cb = Cb[p2] - 128, cr = Cr[p2] - 128; var rr = yv + 1.402 * cr; var gg = yv - 0.34414 * cb - 0.71414 * cr; var bb = yv + 1.772 * cb; rr = Math.max(0, Math.min(255, rr)); gg = Math.max(0, Math.min(255, gg)); bb = Math.max(0, Math.min(255, bb)); var ii = p2 * 4; od[ii] = rr; od[ii + 1] = gg; od[ii + 2] = bb; od[ii + 3] = 255; } tctx.putImageData(out, 0, 0); return temp; } /* ═══════════════════════════════════════════════════════════════════════ БЛОК 6. СЖАТИЕ И МЕТРИКИ ═══════════════════════════════════════════════════════════════════════ Главная функция compress() выполняет всё по шагам: 1. Считает размер PNG (сжатие без потерь для сравнения) 2. Прореживает цветность по выбранной схеме 3. Кодирует в JPEG с выбранным качеством 4. Замеряет размер JPEG 5. Рисует результат на правом холсте 6. Считает PSNR между оригиналом и JPEG 7. Обновляет статистику и графики */ /* Отложенный вызов compress: не чаще раза в 30 мс. Защищает от лагов при быстром движении ползунка качества. */ function scheduleCompress() { clearTimeout(state.updateTimer); state.updateTimer = setTimeout(compress, 30); } /* Основная функция сжатия - делает всю работу */ function compress() { var canvas = drawCanvas(); if (!canvas) return; /* 1. Размер PNG (эталон без потерь) */ var pngURL = canvas.toDataURL('image/png'); state.pngBytes = dataURLSize(pngURL); /* 2. Прореживание цвета + JPEG */ var sub = applyChromaSubsampling(canvas, state.scheme); var jpegURL = sub.toDataURL('image/jpeg', state.quality); state.jpegBytes = dataURLSize(jpegURL); /* 3. Распаковываем JPEG обратно и рисуем на правом холсте */ var img = new Image(); img.onload = function () { var rctx = resultCtx(); rctx.clearRect(0, 0, SIZE, SIZE); rctx.drawImage(img, 0, 0, SIZE, SIZE); /* 4. Достаём пиксели и считаем PSNR */ state.originalImageData = drawCtx().getImageData(0, 0, SIZE, SIZE); state.compressedImageData = rctx.getImageData(0, 0, SIZE, SIZE); state.psnr = computePSNR(state.originalImageData, state.compressedImageData); /* 5. Если включён режим разницы - перерисовать холст как карту ошибок */ if (state.showDiff) drawDiff(); /* 6. Обновить все панели */ updateStats(); updateExtraPanels(); }; img.src = jpegURL; } /* PSNR (Peak Signal-to-Noise Ratio) - метрика близости двух изображений. Чем выше значение, тем меньше потерь. Формула: 10 · log₁₀(255² / MSE) */ function computePSNR(a, b) { var mse = 0, n = a.data.length; for (var i = 0; i < n; i += 4) { var dr = a.data[i] - b.data[i]; var dg = a.data[i + 1] - b.data[i + 1]; var db = a.data[i + 2] - b.data[i + 2]; mse += dr * dr + dg * dg + db * db; } mse /= (n / 4) * 3; if (mse === 0) return Infinity; return 10 * Math.log10(255 * 255 / mse); } /* Карта различий: там где ошибка маленькая - чёрный, где большая - яркие пиксели (усиление в 5 раз для наглядности) */ function drawDiff() { if (!state.originalImageData || !state.compressedImageData) return; var rctx = resultCtx(); var out = rctx.createImageData(SIZE, SIZE); var a = state.originalImageData.data, b = state.compressedImageData.data; for (var i = 0; i < a.length; i += 4) { out.data[i] = Math.min(255, Math.abs(a[i] - b[i]) * 5); out.data[i + 1] = Math.min(255, Math.abs(a[i + 1] - b[i + 1]) * 5); out.data[i + 2] = Math.min(255, Math.abs(a[i + 2] - b[i + 2]) * 5); out.data[i + 3] = 255; } rctx.putImageData(out, 0, 0); } /* Обновляет четыре плитки статистики: PNG, JPEG, коэффициент, PSNR */ function updateStats() { var q100 = Math.round(state.quality * 100); q('jlQualityValue').textContent = q100; q('jlQualityLabel').textContent = 'Q=' + q100; q('jlOriginalSize').textContent = formatBytes(state.pngBytes); q('jlCompressedSize').textContent = formatBytes(state.jpegBytes); q('jlStatPng').textContent = formatBytes(state.pngBytes); q('jlStatJpeg').textContent = formatBytes(state.jpegBytes); /* Коэффициент сжатия PNG/JPEG */ var ratio = state.jpegBytes > 0 ? state.pngBytes / state.jpegBytes : 0; var box = q('jlStatRatioBox'), val = q('jlStatRatio'); box.classList.remove('hi', 'warn'); if (ratio > 1.05) { box.classList.add('hi'); val.textContent = ratio.toFixed(1) + '×'; } else if (ratio < 0.95) { box.classList.add('warn'); val.textContent = ratio.toFixed(2) + '×'; } else { val.textContent = '1.0×'; } /* PSNR с цветовой интерпретацией */ var pb = q('jlStatPsnrBox'), pv = q('jlStatPsnr'), ps = q('jlStatPsnrSub'); pb.classList.remove('hi', 'warn'); if (state.psnr === null || !isFinite(state.psnr)) { pv.textContent = '∞'; ps.textContent = 'без потерь'; } else if (state.psnr > 40) { pv.textContent = state.psnr.toFixed(1); pb.classList.add('hi'); ps.textContent = 'дБ - отличий не видно'; } else if (state.psnr > 30) { pv.textContent = state.psnr.toFixed(1); ps.textContent = 'дБ - качество хорошее'; } else if (state.psnr > 25) { pv.textContent = state.psnr.toFixed(1); ps.textContent = 'дБ - заметные артефакты'; } else { pv.textContent = state.psnr.toFixed(1); pb.classList.add('warn'); ps.textContent = 'дБ - сильные потери'; } } /* Обновляет подсказку под кнопками YCbCr */ function updateSubsampleNote() { var note = q('jlSubsampleNote'); if (!note) return; var texts = { '444': 'Цвет сохраняется для каждого пикселя. Файл больше, качество максимальное.', '422': 'Цвет усредняется по парам пикселей по горизонтали. Экономия 33 %.', '420': 'Цвет усредняется по блокам 2×2. Экономия 50 %. Стандарт JPEG.' }; note.textContent = texts[state.scheme] || ''; } /* Скачивание текущего результата как JPEG-файла */ function download() { var url = drawCanvas().toDataURL('image/jpeg', state.quality); var a = document.createElement('a'); a.href = url; a.download = 'jpeg-q' + Math.round(state.quality * 100) + '.jpg'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } /* ═══════════════════════════════════════════════════════════════════════ БЛОК 7. ГРАФИКИ: КАЧЕСТВО → РАЗМЕР и КАЧЕСТВО → PSNR ═══════════════════════════════════════════════════════════════════════ При загрузке картинки прогоняем её через 16 значений Q и запоминаем размер + PSNR для каждого. Получаются две кривые, показывающие компромисс "качество ↔ размер". */ var CHART_QS = [100, 95, 90, 85, 80, 75, 70, 60, 50, 40, 30, 20, 15, 10, 5, 1]; var chartData = { sizes: [], psnrs: [], ready: false }; /* Прогоняет текущее изображение через все Q из CHART_QS */ function buildCharts() { chartData.ready = false; chartData.sizes = []; chartData.psnrs = []; var canvas = drawCanvas(); if (!canvas) return; var originalData = drawCtx().getImageData(0, 0, SIZE, SIZE); /* Для каждого Q - асинхронно кодируем JPEG и замеряем метрики */ var promises = CHART_QS.map(function (qv) { return new Promise(function (resolve) { var url = canvas.toDataURL('image/jpeg', qv / 100); var bytes = dataURLSize(url); var img = new Image(); img.onload = function () { var tmp = document.createElement('canvas'); tmp.width = SIZE; tmp.height = SIZE; var tctx = tmp.getContext('2d'); tctx.drawImage(img, 0, 0, SIZE, SIZE); var data = tctx.getImageData(0, 0, SIZE, SIZE); resolve({ q: qv, bytes: bytes, psnr: computePSNR(originalData, data) }); }; img.onerror = function () { resolve({ q: qv, bytes: bytes, psnr: 0 }); }; img.src = url; }); }); /* Когда все готовы - сортируем по Q и рисуем графики */ Promise.all(promises).then(function (results) { results.sort(function (a, b) { return a.q - b.q; }); chartData.sizes = results.map(function (r) { return { x: r.q, y: r.bytes / 1024 }; }); chartData.psnrs = results.map(function (r) { return { x: r.q, y: isFinite(r.psnr) ? r.psnr : 60 }; }); chartData.ready = true; var curQ = Math.round(state.quality * 100); drawSizeChart(curQ); drawPsnrChart(curQ); }); } /* График "Качество → Размер". Синяя кривая, зелёная точка - текущий Q. */ function drawSizeChart(currentQ) { var cv = q('jlChartSize'); if (!cv) return; var ctx = cv.getContext('2d'); var W = cv.width, H = cv.height; var PAD = { l: 46, r: 12, t: 14, b: 28 }; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0d0f14'; ctx.fillRect(0, 0, W, H); if (!chartData.ready) { ctx.fillStyle = '#6a7285'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Считаем…', W / 2, H / 2); return; } var pts = chartData.sizes; var maxY = Math.max.apply(null, pts.map(function (p) { return p.y; })); function X(v) { return PAD.l + v / 100 * (W - PAD.l - PAD.r); } function Y(v) { return H - PAD.b - v / maxY * (H - PAD.t - PAD.b); } drawAxes(ctx, W, H, PAD, 'Q', 'КБ'); /* Кривая */ ctx.strokeStyle = '#6aa9ff'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.beginPath(); for (var i = 0; i < pts.length; i++) { var px = X(pts[i].x), py = Y(pts[i].y); if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.stroke(); /* Текущий Q - зелёная точка */ var cur = pts.reduce(function (best, p) { return Math.abs(p.x - currentQ) < Math.abs(best.x - currentQ) ? p : best; }, pts[0]); ctx.fillStyle = '#4ade80'; ctx.beginPath(); ctx.arc(X(cur.x), Y(cur.y), 5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#4ade80'; ctx.font = 'bold 12px monospace'; ctx.textAlign = 'left'; ctx.fillText(cur.y.toFixed(1) + ' КБ', X(cur.x) + 8, Y(cur.y) - 4); } /* График "Качество → PSNR". Жёлтая кривая, пунктир на 40 дБ. */ function drawPsnrChart(currentQ) { var cv = q('jlChartPsnr'); if (!cv) return; var ctx = cv.getContext('2d'); var W = cv.width, H = cv.height; var PAD = { l: 46, r: 12, t: 14, b: 28 }; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0d0f14'; ctx.fillRect(0, 0, W, H); if (!chartData.ready) { ctx.fillStyle = '#6a7285'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Считаем…', W / 2, H / 2); return; } var pts = chartData.psnrs; var maxY = 55, minY = 20; function X(v) { return PAD.l + v / 100 * (W - PAD.l - PAD.r); } function Y(v) { return H - PAD.b - (v - minY) / (maxY - minY) * (H - PAD.t - PAD.b); } drawAxes(ctx, W, H, PAD, 'Q', 'дБ'); /* Пунктир на 40 дБ - граница "отличий не видно" */ ctx.strokeStyle = 'rgba(74,222,128,.3)'; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(PAD.l, Y(40)); ctx.lineTo(W - PAD.r, Y(40)); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(74,222,128,.7)'; ctx.font = '10px monospace'; ctx.textAlign = 'left'; ctx.fillText('40 дБ - отличий не видно', PAD.l + 4, Y(40) - 4); /* Кривая PSNR */ ctx.strokeStyle = '#ffd866'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.beginPath(); for (var i = 0; i < pts.length; i++) { var px = X(pts[i].x), py = Y(Math.min(pts[i].y, maxY)); if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.stroke(); /* Текущая точка */ var cur = pts.reduce(function (best, p) { return Math.abs(p.x - currentQ) < Math.abs(best.x - currentQ) ? p : best; }, pts[0]); ctx.fillStyle = '#4ade80'; ctx.beginPath(); ctx.arc(X(cur.x), Y(Math.min(cur.y, maxY)), 5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#4ade80'; ctx.font = 'bold 12px monospace'; ctx.textAlign = 'left'; ctx.fillText(cur.y.toFixed(1) + ' дБ', X(cur.x) + 8, Y(Math.min(cur.y, maxY)) - 4); } /* Рисует оси координат с подписями и делениями */ function drawAxes(ctx, W, H, PAD, xLabel, yLabel) { ctx.strokeStyle = '#262b36'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(PAD.l, PAD.t); ctx.lineTo(PAD.l, H - PAD.b); ctx.lineTo(W - PAD.r, H - PAD.b); ctx.stroke(); ctx.fillStyle = '#6a7285'; ctx.font = '10px monospace'; ctx.textAlign = 'center'; for (var qv = 0; qv <= 100; qv += 25) { var x = PAD.l + qv / 100 * (W - PAD.l - PAD.r); ctx.fillText(qv, x, H - PAD.b + 14); } ctx.fillText(xLabel, W - PAD.r - 10, H - PAD.b + 14); } /* ═══════════════════════════════════════════════════════════════════════ БЛОК 8. ГИСТОГРАММА ЯРКОСТИ ═══════════════════════════════════════════════════════════════════════ Сравнивает распределение яркости в оригинале и JPEG-версии. Синие столбики - оригинал, оранжевые поверх - JPEG. Видно, что JPEG "сглаживает" редкие значения яркости. */ function drawHistogram(a, b) { var cv = q('jlHistogram'); if (!cv) return; var ctx = cv.getContext('2d'); var W = cv.width, H = cv.height; var PAD = { l: 34, r: 12, t: 12, b: 22 }; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0d0f14'; ctx.fillRect(0, 0, W, H); /* Считаем два массива: яркость оригинала и JPEG-версии. 64 бина вместо 256 - чтобы столбики были крупнее. */ var binsO = new Array(64).fill(0); var binsC = new Array(64).fill(0); var d1 = a.data, d2 = b.data; for (var i = 0; i < d1.length; i += 4) { var y1 = 0.299 * d1[i] + 0.587 * d1[i + 1] + 0.114 * d1[i + 2]; var y2 = 0.299 * d2[i] + 0.587 * d2[i + 1] + 0.114 * d2[i + 2]; binsO[Math.min(63, Math.floor(y1 / 4))]++; binsC[Math.min(63, Math.floor(y2 / 4))]++; } var maxBin = Math.max(Math.max.apply(null, binsO), Math.max.apply(null, binsC)); var bw = (W - PAD.l - PAD.r) / 64; /* Оригинал */ ctx.fillStyle = 'rgba(106,169,255,.55)'; for (var b1 = 0; b1 < 64; b1++) { var h = binsO[b1] / maxBin * (H - PAD.t - PAD.b); ctx.fillRect(PAD.l + b1 * bw, H - PAD.b - h, bw - 1, h); } /* JPEG поверх */ ctx.fillStyle = 'rgba(251,146,60,.5)'; for (var b2 = 0; b2 < 64; b2++) { var h2 = binsC[b2] / maxBin * (H - PAD.t - PAD.b); ctx.fillRect(PAD.l + b2 * bw, H - PAD.b - h2, bw - 1, h2); } /* Оси */ ctx.strokeStyle = '#262b36'; ctx.beginPath(); ctx.moveTo(PAD.l, PAD.t); ctx.lineTo(PAD.l, H - PAD.b); ctx.lineTo(W - PAD.r, H - PAD.b); ctx.stroke(); ctx.fillStyle = '#6a7285'; ctx.font = '10px monospace'; ctx.textAlign = 'center'; ctx.fillText('0', PAD.l, H - PAD.b + 14); ctx.fillText('128', PAD.l + (W - PAD.l - PAD.r) / 2, H - PAD.b + 14); ctx.fillText('255', W - PAD.r, H - PAD.b + 14); } /* ═══════════════════════════════════════════════════════════════════════ БЛОК 9. ПОКАНАЛЬНАЯ СТАТИСТИКА ОШИБОК ═══════════════════════════════════════════════════════════════════════ Считает MSE отдельно по каждому каналу: R, G, B (из RGB) и Y, Cb, Cr (из YCbCr). Показывает, что синий канал и Cb теряются сильнее всего - из-за меньшего вклада в яркость. */ function renderChannelStats(a, b) { var el = q('jlChannelStats'); if (!el) return; /* MSE для одного канала RGB */ function mseCh(off) { var sum = 0, n = 0; for (var i = 0; i < a.data.length; i += 4) { var d = a.data[i + off] - b.data[i + off]; sum += d * d; n++; } return sum / n; } var channels = [ { name: 'R', cls: 'r', v: mseCh(0) }, { name: 'G', cls: 'g', v: mseCh(1) }, { name: 'B', cls: 'b', v: mseCh(2) } ]; /* MSE для Y, Cb, Cr - считаем из RGB через формулы перевода */ var yS = 0, cbS = 0, crS = 0, n = 0; for (var i = 0; i < a.data.length; i += 4) { var r1 = a.data[i], g1 = a.data[i + 1], b1 = a.data[i + 2]; var r2 = b.data[i], g2 = b.data[i + 1], b2 = b.data[i + 2]; var y1 = 0.299 * r1 + 0.587 * g1 + 0.114 * b1; var y2 = 0.299 * r2 + 0.587 * g2 + 0.114 * b2; var cb1 = -0.1687 * r1 - 0.3313 * g1 + 0.5 * b1; var cb2 = -0.1687 * r2 - 0.3313 * g2 + 0.5 * b2; var cr1 = 0.5 * r1 - 0.4187 * g1 - 0.0813 * b1; var cr2 = 0.5 * r2 - 0.4187 * g2 - 0.0813 * b2; yS += (y1 - y2) * (y1 - y2); cbS += (cb1 - cb2) * (cb1 - cb2); crS += (cr1 - cr2) * (cr1 - cr2); n++; } channels.push({ name: 'Y', cls: 'y', v: yS / n }); channels.push({ name: 'Cb', cls: 'cb', v: cbS / n }); channels.push({ name: 'Cr', cls: 'cr', v: crS / n }); /* Нормируем полоски относительно максимальной MSE */ var maxMse = Math.max.apply(null, channels.map(function (c) { return c.v; })); el.innerHTML = channels.map(function (c) { var pct = maxMse > 0 ? (c.v / maxMse * 100) : 0; return '