Answer from Pylyp Dukhov is correct but contains some unnecessary part.
I have a detailed answer about how ACTION_UP and onTouchEvent is implemented in Jetpack Compose here, you can check it to get familiar with touch system of Jetpack Compose which might look scary at the beginning.
In a nutshelll all touch events that has a loop like drag, detect gesturest, etc are like this.
val pointerModifier = Modifier
    .pointerInput(Unit) {
        forEachGesture {
            awaitPointerEventScope {
                
                awaitFirstDown()
               // ACTION_DOWN here
               
                do {
                    
                    //This PointerEvent contains details including
                    // event, id, position and more
                    val event: PointerEvent = awaitPointerEvent()
                    // ACTION_MOVE loop
                    // Consuming event prevents other gestures or scroll to intercept
                    event.changes.forEach { pointerInputChange: PointerInputChange ->
                        pointerInputChange.consumePositionChange()
                    }
                } while (event.changes.any { it.pressed })
                // ACTION_UP is here
            }
        }
}
When you are at the bottom of the loop which means you are already have all of your pointers up.
If you check the source code of detectTransformGestures you will see that it checks
 val canceled = event.changes.any { it.positionChangeConsumed() }
to start which means any other event consumed move/drag before this pointerInput, it works like when you want another touch to consume event when touch is for instance top right of the screen.
And while (!canceled && event.changes.any { it.pressed })
runs a loop until any is consumed or till at least one pointer is down
so just adding a callback below this while suffice.
suspend fun PointerInputScope.detectTransformGesturesAndEnd(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
    onGestureEnd: ()->Unit
) {
    forEachGesture {
        awaitPointerEventScope {
            var rotation = 0f
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var lockedToPanZoom = false
            awaitFirstDown(requireUnconsumed = false)
            do {
                val event = awaitPointerEvent()
                val canceled = event.changes.any { it.positionChangeConsumed() }
                if (!canceled) {
                    val zoomChange = event.calculateZoom()
                    val rotationChange = event.calculateRotation()
                    val panChange = event.calculatePan()
                    if (!pastTouchSlop) {
                        zoom *= zoomChange
                        rotation += rotationChange
                        pan += panChange
                        val centroidSize = event.calculateCentroidSize(useCurrent = false)
                        val zoomMotion = abs(1 - zoom) * centroidSize
                        val rotationMotion = abs(rotation * kotlin.math.PI.toFloat() * centroidSize / 180f)
                        val panMotion = pan.getDistance()
                        if (zoomMotion > touchSlop ||
                            rotationMotion > touchSlop ||
                            panMotion > touchSlop
                        ) {
                            pastTouchSlop = true
                            lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
                        }
                    }
                    if (pastTouchSlop) {
                        val centroid = event.calculateCentroid(useCurrent = false)
                        val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
                        if (effectiveRotation != 0f ||
                            zoomChange != 1f ||
                            panChange != Offset.Zero
                        ) {
                            onGesture(centroid, panChange, zoomChange, effectiveRotation)
                        }
                        event.changes.forEach {
                            if (it.positionChanged()) {
                                it.consumeAllChanges()
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.any { it.pressed })
            onGestureEnd()
        }
    }
}
And you need pointerIds in a loop when you want to track the first pointer that is pressed or if it's up the next one after that. This works for drawing apps to not draw line to second pointer's position when first one is down, also drag uses it too but it needs to be checked in a loop. adding and removing it before and after a loop doesn't do any work.
/*
   Simplified source code of drag
    suspend fun AwaitPointerEventScope.drag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
): Boolean {
    var pointer = pointerId
    while (true) {
        val change = awaitDragOrCancellation(pointer) ?: return false
        if (change.changedToUpIgnoreConsumed()) {
            return true
        }
        onDrag(change)
        pointer = change.id
    }
}
 */
usage is
@Composable
private fun TransformGesturesZoomExample() {
    val context = LocalContext.current
    var centroid by remember { mutableStateOf(Offset.Zero) }
    var zoom by remember { mutableStateOf(1f) }
    val decimalFormat = remember { DecimalFormat("0.0") }
    var transformDetailText by remember {
        mutableStateOf(
            "Use pinch gesture to zoom in or out.\n" +
                    "Centroid is position of center of touch pointers"
        )
    }
    val imageModifier = Modifier
        .fillMaxSize()
        .pointerInput(Unit) {
            detectTransformGesturesAndEnd(
                onGesture = { gestureCentroid, _, gestureZoom, _ ->
                    centroid = gestureCentroid
                    val newZoom = zoom * gestureZoom
                    zoom = newZoom.coerceIn(0.5f..5f)
                    transformDetailText = "Zoom: ${decimalFormat.format(zoom)}, centroid: $centroid"
                },
                onGestureEnd = {
                    Toast
                        .makeText(context, "Gesture End", Toast.LENGTH_SHORT)
                        .show()
                }
            )
        }
        .drawWithContent {
            drawContent()
            drawCircle(color = Color.Red, center = centroid, radius = 20f)
        }
        .graphicsLayer {
            scaleX = zoom
            scaleY = zoom
        }
    ImageBox(boxModifier, imageModifier, R.drawable.landscape1, transformDetailText, Blue400)
}
Result
