JayVDiyk
JayVDiyk

Reputation: 4487

Perform 'Transparency Vignette' in Flutter

I would like to perform something similar to Vignette, but instead of Darken the edges, I would like to make a transparent gradient of the edges. I am not looking for a solution using widget.

ILLUSTRATION

Any idea how should I modify the code below? Or is it even the same thing at all?? I am sorry but I really badly need this.

Thanks a lot

Note: The code below is from the Image library https://github.com/brendan-duncan/image

Image vignette(Image src, {num start = 0.3, num end = 0.75, num amount = 0.8}) {
  final h = src.height - 1;
  final w = src.width - 1;
  final num invAmt = 1.0 - amount;
  final p = src.getBytes();
  for (var y = 0, i = 0; y <= h; ++y) {
    final num dy = 0.5 - (y / h);
    for (var x = 0; x <= w; ++x, i += 4) {
      final num dx = 0.5 - (x / w);

      num d = sqrt(dx * dx + dy * dy);
      d = _smoothStep(end, start, d);

      p[i] = clamp255((amount * p[i] * d + invAmt * p[i]).toInt());
      p[i + 1] = clamp255((amount * p[i + 1] * d + invAmt * p[i + 1]).toInt());
      p[i + 2] = clamp255((amount * p[i + 2] * d + invAmt * p[i + 2]).toInt());
    }
  }

  return src;
}


num _smoothStep(num edge0, num edge1, num x) {
  x = ((x - edge0) / (edge1 - edge0));
  if (x < 0.0) {
    x = 0.0;
  }
  if (x > 1.0) {
    x = 1.0;
  }
  return x * x * (3.0 - 2.0 * x);
}

Upvotes: 4

Views: 733

Answers (3)

TripleNine
TripleNine

Reputation: 1932

Solution

This code works without any widgets. Actually it doesn't use any of the flutter libraries and is solely based on dart and the image package you introduced in your question.

The code contains comments which may not make a lot of sense until you read the explanation. The code is as following:

vignette.dart

import 'dart:isolate';
import 'dart:typed_data';
import 'package:image/image.dart';
import 'dart:math' as math;

class VignetteParam {
  final Uint8List file;
  final SendPort sendPort;
  final double fraction;
  VignetteParam(this.file, this.sendPort, this.fraction);
}

void decodeIsolate(VignetteParam param) {
  Image image = decodeImage(param.file.buffer.asUint8List())!;
  Image crop = copyCropCircle(image);          // crop the image with builtin function
  int r = crop.height~/2;                      // radius is half the height
  int rs = r*r;                                // radius squared
  int tr = (param.fraction * r).floor();       // we apply the fraction to the radius to get tr
  int ors = (r-tr)*(r-tr);                     // ors from diagram
  x: for (int x = -r; x <= r; x++) {           // iterate across all columns of pixels after shifting x by -r
    for (int y = -r; y <= r; y++) {            // iterate across all rows of pixels after shifting y by -r
      int pos = x*x + y*y;                     // which is (r')² (diagram)
      if (pos <= rs) {                         // if we are inside the outer circle, then ...
        if (pos > ors) {                       // if we are outside the inner circle, then ...
          double f = (rs-pos) / (rs-ors);      // calculate the fraction of the alpha value
          int c = setAlpha(
            crop.getPixelSafe(x+r, y+r), 
            (0xFF * f).floor()
          );                                   // calculate the new color by changing the alpha
          crop.setPixelSafe(x+r, y+r, c);      // set the new color
        } else {                               // if we reach the inner circle from the top then jump down
          y = y*-1;
        }
      } else {                                 // if we are outside the outer circle and ...
        if (y<0) {                             // above it and jump down to the outer circle
          y = -(math.sin(math.acos(x/r)) * r).floor()-1;
        }                                      
        else continue x;                       // or if beneath it then jump to the next column
      }
    }
  }
  param.sendPort.send(crop);
}

Future<Uint8List> getImage(Uint8List bytes, double radiusFraction) async {
  var receivePort = ReceivePort();
  await Isolate.spawn(
    decodeIsolate, 
    VignetteParam(bytes, receivePort.sendPort, radiusFraction)
  );
  Image image = await receivePort.first;
  return encodePng(image) as Uint8List;
}

main.dart (example app)

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_playground/vignette.dart';
import 'package:flutter/services.dart' show ByteData, rootBundle;

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Material App',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Material App Bar'),
        ),
        body: Center(
          child: FutureBuilder<Uint8List>(
            future: imageFuture(),
            builder: (context, snapshot) {
              switch (snapshot.connectionState) {
                case ConnectionState.done:
                  return Image.memory(
                    snapshot.data!,
                  );
                default: 
                  return CircularProgressIndicator();
              }
            }
          ),
        ),
      ),
    );
  }

  Future<Uint8List> imageFuture() async {
    // Load your file here and call getImage
    ByteData byteData = await rootBundle.load("assets/image.jpeg");
    return getImage(byteData.buffer.asUint8List(), 0.3);
  }
}

Explanation

The math behind this algorithm is very simple. It's only based on the equation of a circle. But first of all, have a look at this diagram:

Diagram

Diagram

The diagram contains the square which is our image. Inside the square we can see the circle which is the visible part of the image. The opaque area is fully opaque while the transparent are gets more transparent (= less alpha) if we get closer to the outer circle. r (radius) is the radius of the outer circle, tr (transparent radius) is the part of the radius which is in the transparent area. That's why r-tr is the radius of the inner circle.

In order to apply this diagram to our needs we have to shift our x-Axis and y-Axis. The image package has a grid which starts with (0,0) at the top left corner. But we need (0,0) at the center of the image, hence we shift it by the radius r. If you look close you may notice that our diagram doesn't look as usual. The y-Axis usually goes up, but in our case it really doesn't matter and makes things easier.

Calculation of the position

We need to iterate across all pixels inside the transparent area and change the alpha value. The equation of a circle, which is x'²+y'²=r'², helps us to figure out whether the pixel is in the transparent area. (x',y') is the coordinate of the pixel and r' is the distance between the pixel and the origin. Is the distance behind the inner circle and before the outer circle, then r' <= r and r' > r-tr must hold. In order to avoid calculating the square root, we instead compare the squared values, so pos <= rs and pos > ors must hold. (Look at the diagram to see what pos, rs, and ors are.)

Changing the alpha value

The pixel is inside the transparent area. How do we change the alpha value? We need a linear gradient transparency, so we calculate the distance between the actual position pos and the outer circle, which is rs-pos. The longer the distance, the more alpha we need for this pixel. The total distance is rs-ors, so we calculate (rs-pos) / (rs-ors) to get the fraction of our alpha value f. Finally we change our alpha value of this pixel with f.

Optimization

This algorithm actually does the whole job. But we can optimize it. I wrote that we have to iterate across all pixels inside the transparent area. So, we don't need the pixels outside of the outer circle and inside the inner circle, as their alpha value don't change. But we have two for loops iterating through all pixels from left to right and from top to bottom. Well, when we reach the inner circle from outside, we can jump down by negating the y-Position. Hence, we set y = y*-1; if we are inside the outer circle (pos <= rs) but not outside the inner circle (pos <= ors) anymore.

What if we are above the outer circle (pos > rs)? Well then we can jump to the outer circle by calculating the y-Position with sine and arccosine. I won't go much into detail here, but if you want further explanation, let me know by commenting below. The if (y<0) just determines if we are above the outer circle (y is negative) or beneath it. If above then jump down, if beneath jump to the next column of pixels. Hence we 'continue' the x for loop.

Upvotes: 4

Pavlo Ostasha
Pavlo Ostasha

Reputation: 16699

Here you go - the widgetless approach based on my previous answer:

Canvas(PictureRecorder()).drawImage(
        your_image, //here is the image you want to change
        Offset.zero,// the offset from the corner of the canvas
        Paint()
          ..shader = const RadialGradient(
            radius: needed_radius, // the radius of the result gradient - it should depend on the image dimens 
            colors: [Colors.black, Colors.transparent],
          ).createShader(
            Rect.fromLTRB(0, 0, your_image_width, your_image_height), // the portion of your image that should be influenced by the shader - in this case, I use the whole image.
          )
          ..blendMode = BlendMode.dstIn); // for the black color of the gradient to be masking one

I will add it to the previous answer, also

Upvotes: 3

ch271828n
ch271828n

Reputation: 17597

Given that you want "transparency", you need the alpha channel. The code you provide seems to only have 3 bytes per pixel, so only RGB, without alpha channel.

A solution may be:

  1. Modify the code such that it has alpha channel, i.e. 4 bytes per pixel.
  2. Instead of modifying the RGB to make it darker, i.e. p[i] = ..., p[i+1] = ..., p[i+2] = ..., leave RGB unchanged, and modifying the alpha channel to make alpha smaller. For example, say, p[i+3]=... (suppose you are RGBA format instead of ARGB).

Upvotes: 0

Related Questions