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" />
+
+
+