Implement 33_FIX_GRAIN.md: add FilmGrainView custom view for dynamic 60 FPS grain preview, sync intensity, and optimize Gaussian noise generation for saved photos

This commit is contained in:
2026-07-06 21:51:51 +07:00
parent 3058032d01
commit 2cf5a91557
3 changed files with 138 additions and 33 deletions
@@ -0,0 +1,60 @@
package com.photobooth.app
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import java.util.Random
class FilmGrainView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val random = Random()
private val paint = Paint().apply {
style = Paint.Style.FILL
}
var intensity: Float = 0f
set(value) {
field = value.coerceIn(0f, 1f)
if (field > 0f) {
visibility = VISIBLE
postInvalidateOnAnimation()
} else {
visibility = GONE
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (intensity <= 0f) return
val w = width.toFloat()
val h = height.toFloat()
if (w <= 0 || h <= 0) return
// Calculate count based on width, height and intensity
val grainCount = (w * h * 0.005f * intensity).toInt().coerceIn(200, 5000)
val maxAlpha = (intensity * 110).toInt().coerceIn(1, 255)
for (i in 0 until grainCount) {
val x = random.nextFloat() * w
val y = random.nextFloat() * h
val gray = random.nextInt(55) + 100 // [100 - 155] range for cinema film grain look
val alpha = random.nextInt(maxAlpha)
paint.color = Color.argb(alpha, gray, gray, gray)
// Draw tiny rectangular grain shapes
canvas.drawRect(x, y, x + 2.5f, y + 2.5f, paint)
}
// Repaint continuously at screen refresh rate to simulate dynamic analog noise
postInvalidateOnAnimation()
}
}
@@ -590,11 +590,13 @@ class MainActivity : AppCompatActivity() {
if (preset.id == "DEFAULT") {
binding.imgFilterOverlay.visibility = android.view.View.GONE
binding.viewFinder.setLayerType(android.view.View.LAYER_TYPE_NONE, null)
binding.viewFilmGrain.intensity = 0f
} else {
binding.imgFilterOverlay.visibility = android.view.View.VISIBLE
val filterColor = getFilterColorForPreset(preset)
binding.imgFilterOverlay.setBackgroundColor(filterColor)
applyColorMatrixToViewFinder(preset)
binding.viewFilmGrain.intensity = preset.grain.grainAmount
}
}
@@ -1108,7 +1110,10 @@ class MainActivity : AppCompatActivity() {
EditAttribute.SHADOWS_TINT_R -> preset.toneCurve.shadowsTintR = value.coerceIn(0f, 1f)
EditAttribute.SHADOWS_TINT_G -> preset.toneCurve.shadowsTintG = value.coerceIn(0f, 1f)
EditAttribute.SHADOWS_TINT_B -> preset.toneCurve.shadowsTintB = value.coerceIn(0f, 1f)
EditAttribute.GRAIN_AMOUNT -> preset.grain.grainAmount = value.coerceIn(0f, 1f)
EditAttribute.GRAIN_AMOUNT -> {
preset.grain.grainAmount = value.coerceIn(0f, 1f)
binding.viewFilmGrain.intensity = preset.grain.grainAmount
}
EditAttribute.GRAIN_SIZE -> preset.grain.grainSize = value.coerceIn(0f, 1f)
else -> {}
}
@@ -1551,40 +1556,11 @@ class MainActivity : AppCompatActivity() {
)
}
// 4. Film Grain effect
if (preset.grain.grainAmount > 0f) {
val grainAmount = preset.grain.grainAmount
val grainSize = preset.grain.grainSize.coerceAtLeast(0.01f)
val width = filteredBitmap.width
val height = filteredBitmap.height
val pixels = IntArray(width * height)
filteredBitmap.getPixels(pixels, 0, width, 0, 0, width, height)
val random = java.util.Random()
val sizeFactor = (grainSize * 12).toInt().coerceAtLeast(1)
for (y in 0 until height step sizeFactor) {
for (x in 0 until width step sizeFactor) {
val noise = (random.nextGaussian() * 127 * grainAmount).toInt()
for (ny in 0 until sizeFactor) {
if (y + ny >= height) break
for (nx in 0 until sizeFactor) {
if (x + nx >= width) break
val index = (y + ny) * width + (x + nx)
val color = pixels[index]
val r = (android.graphics.Color.red(color) + noise).coerceIn(0, 255)
val g = (android.graphics.Color.green(color) + noise).coerceIn(0, 255)
val b = (android.graphics.Color.blue(color) + noise).coerceIn(0, 255)
pixels[index] = android.graphics.Color.rgb(r, g, b)
}
}
}
}
filteredBitmap.setPixels(pixels, 0, width, 0, 0, width, height)
}
// 4. Film Grain effect (High-Quality Film Grain Noise Engine)
val grainBitmap = applyHighQualityFilmGrain(filteredBitmap, preset.grain.grainAmount, preset.grain.grainSize)
// 5. Merge Instax Frame Overlay
var finalBitmap = filteredBitmap
var finalBitmap = grainBitmap
val framePath = currentSelectedFramePath
if (framePath != null && framePath.isNotEmpty()) {
try {
@@ -2291,6 +2267,68 @@ class MainActivity : AppCompatActivity() {
return outputBitmap
}
private fun applyHighQualityFilmGrain(inputBitmap: android.graphics.Bitmap, grainIntensity: Float, grainSize: Float): android.graphics.Bitmap {
if (grainIntensity <= 0.0f) return inputBitmap
val w = inputBitmap.width
val h = inputBitmap.height
val outputBitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
val random = java.util.Random()
// Amplify grain strength
val grainStrength = grainIntensity * 45.0f
val sizeFactor = (grainSize * 10).toInt().coerceIn(1, 20)
val pixels = IntArray(w * h)
inputBitmap.getPixels(pixels, 0, w, 0, 0, w, h)
if (sizeFactor <= 1) {
for (i in 0 until (w * h)) {
val pixel = pixels[i]
val r = (pixel shr 16) and 0xFF
val g = (pixel shr 8) and 0xFF
val b = pixel and 0xFF
val noise = (random.nextGaussian() * grainStrength).toInt()
val finalR = (r + noise).coerceIn(0, 255)
val finalG = (g + noise).coerceIn(0, 255)
val finalB = (b + noise).coerceIn(0, 255)
pixels[i] = (0xFF shl 24) or (finalR shl 16) or (finalG shl 8) or finalB
}
} else {
// Apply grouped sizing factor for larger vintage analog grain look
for (y in 0 until h step sizeFactor) {
for (x in 0 until w step sizeFactor) {
val noise = (random.nextGaussian() * grainStrength).toInt()
for (ny in 0 until sizeFactor) {
if (y + ny >= h) break
val rowOffset = (y + ny) * w
for (nx in 0 until sizeFactor) {
if (x + nx >= w) break
val index = rowOffset + (x + nx)
val pixel = pixels[index]
val r = (pixel shr 16) and 0xFF
val g = (pixel shr 8) and 0xFF
val b = pixel and 0xFF
val finalR = (r + noise).coerceIn(0, 255)
val finalG = (g + noise).coerceIn(0, 255)
val finalB = (b + noise).coerceIn(0, 255)
pixels[index] = (0xFF shl 24) or (finalR shl 16) or (finalG shl 8) or finalB
}
}
}
}
}
outputBitmap.setPixels(pixels, 0, w, 0, 0, w, h)
inputBitmap.recycle()
return outputBitmap
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
@@ -111,6 +111,13 @@
android:visibility="gone"
android:contentDescription="Filter Overlay" />
<!-- Lớp phủ tạo nhiễu hạt phim thời gian thực (Dynamic Film Grain Viewfinder Overlay) -->
<com.photobooth.app.FilmGrainView
android:id="@+id/viewFilmGrain"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Lớp đè (Overlay): Hiển thị Khung hình PNG do người dùng chọn -->
<ImageView
android:id="@+id/imgFrameOverlay"