diff --git a/android-project/app/src/main/java/com/photobooth/app/FilmGrainView.kt b/android-project/app/src/main/java/com/photobooth/app/FilmGrainView.kt new file mode 100644 index 0000000..3f86081 --- /dev/null +++ b/android-project/app/src/main/java/com/photobooth/app/FilmGrainView.kt @@ -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() + } +} diff --git a/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt b/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt index ee0e38a..b9b0279 100644 --- a/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt +++ b/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt @@ -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() diff --git a/android-project/app/src/main/res/layout/activity_main.xml b/android-project/app/src/main/res/layout/activity_main.xml index ad005c5..854336e 100644 --- a/android-project/app/src/main/res/layout/activity_main.xml +++ b/android-project/app/src/main/res/layout/activity_main.xml @@ -111,6 +111,13 @@ android:visibility="gone" android:contentDescription="Filter Overlay" /> + + +