Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: ZoomableAsyncImage Ignores Transformations #131

Open
tomveich opened this issue Feb 16, 2025 · 6 comments
Open

Bug: ZoomableAsyncImage Ignores Transformations #131

tomveich opened this issue Feb 16, 2025 · 6 comments

Comments

@tomveich
Copy link

When using ZoomableAsyncImage, image transformations applied via ImageRequest.Builder().transformations() are ignored, and the original image is displayed instead. However, using the same request with Coil3 AsyncImage correctly applies the transformations.

Expected Behavior
ZoomableAsyncImage should respect and apply transformations set in ImageRequest, just like AsyncImage does.

Steps to Reproduce

  1. Create an ImageRequest with a transformation
  2. Pass this request to ZoomableAsyncImage
  3. Also try displaying the same request in AsyncImage
// 1
val request = ImageRequest.Builder(context)
    .data(imageUri)
    .transformations(MyVintageEffect(effectType, cacheKey)) // class MyVintageEffect inherits from coil3.transform.Transformation and applies an effect to the image
    .crossfade(true)
    .build()

// 2
ZoomableAsyncImage(
    modifier = Modifier.fillMaxSize(),
    model = request,
    contentDescription = "User image",
    contentScale = ContentScale.Crop
)

// 3
AsyncImage(
    modifier = Modifier.fillMaxSize(),
    model = request,
    contentDescription = "User image",
    contentScale = ContentScale.Crop
)

Also, a simplified version of MyVintageEffect for testing:

// effectType is unused in this simplified example.
class MyVintageEffect(
    private val effectType: Int, override val cacheKey: String
) : Transformation() {

    override suspend fun transform(
        input: Bitmap,
        size: Size
    ): Bitmap {

        val config = input.config ?: Bitmap.Config.ARGB_8888
        val vintageBitmap = input.copy(config, true)
        val canvas = Canvas(vintageBitmap)
        
        // For the sake of this example, there's just a Color Filter which should make the image Black & White
        val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            colorFilter = ColorMatrixColorFilter(ColorMatrix(
                floatArrayOf(
                    0.33f, 0.33f, 0.33f, 0f, 0f,
                    0.33f, 0.33f, 0.33f, 0f, 0f,
                    0.33f, 0.33f, 0.33f, 0f, 0f,
                    0f,    0f,    0f,    1f, 0f
                )
            )
            )
        }
        canvas.drawBitmap(vintageBitmap, 0f, 0f, paint)

         return vintageBitmap
    }
    
}

Actual Behavior
ZoomableAsyncImage displays the original image, ignoring transformations.
AsyncImage correctly applies the transformation.

Environment
Telephoto version: 0.15.0
Android version: 14

Additional Context
Since ZoomableAsyncImage internally integrates with Coil, it should respect transformations like AsyncImage does. Let me know if this behavior is intentional or if there’s a workaround to ensure transformations apply correctly.

@saket
Copy link
Owner

saket commented Feb 16, 2025

Supporting bitmap transformations is sadly going to be impossible because telephoto uses tiling for rendering images. Full images are divided into smaller regions and selectively loaded based on the visible viewport (better explanation here).

What kind of transformations are you using? Your MyVintageEffect example looks simple enough to be applied directly to the canvas using a draw modifier.

Since ZoomableAsyncImage internally integrates with Coil, it should respect transformations like AsyncImage does

You're right that telephoto should handle this case more explicitly. I'm considering throwing an exception in debug builds when transformations are present.

@tomveich
Copy link
Author

Hi Saket,
Thanks for the quick response!
I understand that Telephoto uses tiling to efficiently render large images at different zoom levels.

What kind of transformations are you using? Your MyVintageEffect example looks simple enough to be applied directly to the canvas using a draw modifier.

The MyVintageEffect function was intentionally simplified to avoid distracting from the core issue. In reality, my transformation applies a detailed film simulation, including fine grain, a color matrix, halation, and other effects to replicate the look of analog film. This process is computationally intensive—applying it to a full 4K image can take over 10 seconds, but applying it to a preview-sized image (or a tile) is nearly real-time.

My goal is to apply the effect per tile so that the same level of detail is preserved as the user zooms in. I also attempted this using SubSamplingImage, but I couldn’t get it to work. Enabling transformations on individual tiles would be extremely useful in this case.

Thanks you!

@saket
Copy link
Owner

saket commented Feb 17, 2025

Got it. telephoto does not offer any API for intercepting bitmap loads, but I think you could try implementing your own image region decoder that can process bitmaps with your effects. Here's what I would do:

  1. Create a ZoomableImageSource decorator that you'll use with ZoomableImage():
@Composable
fun ZoomableImageSource.foo(): ZoomableImageSource {
  val delegateSource = this
  return remember(delegateSource) {
    object : ZoomableImageSource {
      @Composable
      override fun resolve(canvasSize: Flow<Size>): ZoomableImageSource.ResolveResult {
        val result = delegateSource.resolve(canvasSize)
        return result.copy(
          delegate = when (val delegate = result.delegate) {
            is ZoomableImageSource.SubSamplingDelegate -> {
              ZoomableImageSource.SubSamplingDelegate(
                source = Foo(delegate.source),
                imageOptions = delegate.imageOptions,
              )
            }
            else -> delegate
          }
        )
      }
    }
  }
}
  1. When a sub-sampled image is detected, intercept it with your own custom SubSamplingImageSource:
class Foo(
  private val delegate: SubSamplingImageSource,
) : SubSamplingImageSource by delegate {

  override suspend fun decoder(): ImageRegionDecoder.Factory {
    return ImageRegionDecoder.Factory { params ->
      FooImageDecoder(
        delegate = delegate.decoder().create(params),
      )
    }
  }
}
  1. Your SubSamplingImageSource will use its own image decoder that can apply effects to the bitmaps:
class FooImageDecoder(
  private val delegate: ImageRegionDecoder,
) : ImageRegionDecoder by delegate {

  override suspend fun decodeRegion(region: IntRect, sampleSize: Int): ImageRegionDecoder.DecodeResult {
    val result = delegate.decodeRegion(region, sampleSize)
    return ImageRegionDecoder.DecodeResult(
      painter = TODO("decorate result.painter and apply effects"),
      hasUltraHdrContent = result.hasUltraHdrContent,
    )
  }
}

Does this make sense? You can skip step 1 if you are using SubSamplingImage() in your layout instead of ZoomableAsyncImage().

@tomveich
Copy link
Author

I tried implementing a custom image region decoder, however I couldn't get it to work. It is difficult to even extract the original bitmap from the painter. Is it possible to intercept the tile bitmap, so effects could be applied to it before it is rendered?
If so, would you be able to provide a full example?

Thank you

@saket
Copy link
Owner

saket commented Feb 18, 2025

Ah right, you can't extract bitmaps out of Painter objects.

Is it possible to intercept the tile bitmap, so effects could be applied to it before it is rendered?

Sadly no because telephoto works with painters as the lowest abstraction -- it never directly handles bitmaps. (source).

What kind of images are you displaying? Can you create your own ImageRegionDecoder?

@tomveich
Copy link
Author

The app is displaying pictures from user's device, usually normal pictures from camera. I tried to come up with a custom ImageRegionDecoder, but for some reason it still displays the original picture without the desired effect.

import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.saket.telephoto.subsamplingimage.ImageBitmapOptions
import me.saket.telephoto.subsamplingimage.internal.ImageRegionDecoder
import java.io.InputStream
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.flow.Flow
import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
import me.saket.telephoto.zoomable.ZoomableImage
import me.saket.telephoto.zoomable.ZoomableImageSource
import me.saket.telephoto.zoomable.coil3.coil
import me.saket.telephoto.zoomable.copy


/**
 * A [SubSamplingImageSource] that always returns custom FilmSimulatedImageDecoder.Factory.
 */
class FilmSimulatedSubSamplingImageSource(
    private val delegate: SubSamplingImageSource,
    private val factory: FilmSimulatedImageDecoder.Factory
) : SubSamplingImageSource by delegate {
    override suspend fun decoder(): ImageRegionDecoder.Factory = factory
}


/**
 * A custom ImageRegionDecoder that intercepts tile decoding and applies film simulation.
 */
class FilmSimulatedImageDecoder(
    private val delegate: BitmapRegionDecoder
) : ImageRegionDecoder {

    // Provide the full image size as required.
    override val imageSize: IntSize = IntSize(delegate.width, delegate.height)

    /**
     * Decodes a given region. Converts [IntRect] (Compose) to [Rect] (Android),
     * decodes the bitmap region with the provided sample size, applies the film simulation,
     * and returns a [DecodeResult] wrapping a Compose [Painter].
     */
    override suspend fun decodeRegion(region: IntRect, sampleSize: Int): ImageRegionDecoder.DecodeResult {
        // Convert Compose IntRect to Android Rect.
        val androidRect = Rect(region.left, region.top, region.right, region.bottom)
        val bitmap = withContext(Dispatchers.IO) {
            delegate.decodeRegion(
                androidRect,
                BitmapFactory.Options().apply { inSampleSize = sampleSize }
            )
        }
        // Apply the effect.
        val simulatedBitmap = myVintageEffect(
            bitmap = bitmap
        )
        val painter = BitmapPainter(simulatedBitmap.asImageBitmap())
        return ImageRegionDecoder.DecodeResult(
            painter = painter,
            hasUltraHdrContent = false
        )
    }

    /**
     * Releases resources.
     */
    override fun close() {
        delegate.recycle()
    }


    class Factory(
        private val inputStreamProvider: (ImageBitmapOptions) -> InputStream
    ) : ImageRegionDecoder.Factory {
        override suspend fun create(params: ImageRegionDecoder.FactoryParams): ImageRegionDecoder {
            val inputStream = inputStreamProvider(params.imageOptions)
            val regionDecoder = withContext(Dispatchers.IO) {
                BitmapRegionDecoder.newInstance(inputStream, false)
            }
            return FilmSimulatedImageDecoder(
                requireNotNull(regionDecoder) { "Unable to create BitmapRegionDecoder" }
            )
        }
    }
}

@Composable
fun ZoomableImageSource.filmSimulated(
    factory: FilmSimulatedImageDecoder.Factory
): ZoomableImageSource {
    val delegateSource = this
    return remember(delegateSource) {
        object : ZoomableImageSource {
            @Composable
            override fun resolve(canvasSize: Flow<Size>): ZoomableImageSource.ResolveResult {
                val result = delegateSource.resolve(canvasSize)
                return result.copy(
                    delegate = when (val delegate = result.delegate) {
                        is ZoomableImageSource.SubSamplingDelegate -> {
                            // Wrap the original SubSamplingImageSource with the film simulation decorator.
                            ZoomableImageSource.SubSamplingDelegate(
                                source = FilmSimulatedSubSamplingImageSource(delegate.source, factory),
                                imageOptions = delegate.imageOptions
                            )
                        }
                        else -> delegate
                    }
                )
            }
        }
    }
}


@Composable
fun FilmSimulatedZoomableImage(
    uri: Uri,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current

    // Create Telephoto's default image source from the URI.
    val originalSource = ZoomableImageSource.coil(model = uri)

    // Create the custom decoder factory.
    val filmFactory = FilmSimulatedImageDecoder.Factory { options ->
        // Open an InputStream from the URI.
        context.contentResolver.openInputStream(uri)
            ?: throw IllegalArgumentException("Unable to open stream for uri: $uri")
    }

    val filmSimulatedSource = originalSource.filmSimulated(filmFactory)

    ZoomableImage(
        image = filmSimulatedSource,
        modifier = modifier,
        contentDescription = "Test"
    )
}

fun myVintageEffect(bitmap: Bitmap): Bitmap {
    // For demonstration, perform a simple grayscale conversion.
    val width = bitmap.width
    val height = bitmap.height
    val config = bitmap.config ?: Bitmap.Config.ARGB_8888
    val output = Bitmap.createBitmap(width, height, config)
    for (x in 0 until width) {
        for (y in 0 until height) {
            val pixel = bitmap.getPixel(x, y)
            val r = (pixel shr 16) and 0xff
            val g = (pixel shr 8) and 0xff
            val b = pixel and 0xff
            val gray = ((r + g + b) / 3)
            val newPixel = (0xff shl 24) or (gray shl 16) or (gray shl 8) or gray
            output.setPixel(x, y, newPixel)
        }
    }
    return output
}


/*
How to test it:

FilmSimulatedZoomableImage(
    uri = imageUri,
    modifier = Modifier.fillMaxSize()
)
 */

Do you think it is achievable with the current state of Telephoto? If not, do you have any suggestions?
Thank you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants