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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user