private BufferedImage convertGreyscaleToArgb(byte[] data, BufferedImage bi)
    {
        // we use an optimised greyscale colour conversion, as with scanned
        // greyscale/mono documents consisting of nothing but page-size
        // images, using the ICC converter is perhaps 15 times slower than this
        // method. Using an example scanned, mainly monochrome document, on this
        // developer's machine pages took an average of 3s to render using the
        // ICC converter filter, and around 115ms using this method. We use
        // pre-calculated tables generated using the ICC converter to map between
        // each possible greyscale value and its desired value in sRGB.
        // We also try to avoid going through SampleModels, WritableRasters or
        // BufferedImages as that takes about 3 times as long.
        final int[] convertedPixels = new int[getWidth() * getHeight()];
        final WritableRaster r = bi.getRaster();
        int i = 0;
        final int[] greyToArgbMap = getGreyToArgbMap(bpc);
        if (bpc == 1) {
            int calculatedLineBytes = (getWidth() + 7) / 8;
            int rowStartByteIndex;
            // avoid hitting the WritableRaster for the common 1 bpc case
            if (greyToArgbMap[0] == 0 && greyToArgbMap[1] == 0xFFFFFFFF) {
                // optimisation for common case of a direct map to full white
                // and black, using bit twiddling instead of consulting the
                // greyToArgb map
                for (int y = 0; y < getHeight(); ++y) {
                    // each row is byte-aligned
                    rowStartByteIndex = y * calculatedLineBytes;
                    for (int x = 0; x < getWidth(); ++x) {
                        final byte b = data[rowStartByteIndex + x / 8];
                        final int white = b >> (7 - (x & 7)) & 1;
                        // if white == 0, white - 1 will be 0xFFFFFFFF,
                        //  which when xored with 0xFFFFFF will produce 0
                        // if white == 1, white - 1 will be 0,
                        //  which when xored with 0xFFFFFF will produce 0xFFFFFF
                        //  (ignoring the top two bytes, which are always set high anyway)
                        convertedPixels[i] = 0xFF000000 | ((white - 1) ^ 0xFFFFFF);
                        ++i;
                    }
                }
            } else {
                // 1 bpc case where we can't bit-twiddle and need to consult
                // the map
                for (int y = 0; y < getHeight(); ++y) {
                    rowStartByteIndex = y * calculatedLineBytes;
                    for (int x = 0; x < getWidth(); ++x) {
                        final byte b = data[rowStartByteIndex + x / 8];
                        final int val = b >> (7 - (x & 7)) & 1;
                        convertedPixels[i] = greyToArgbMap[val];
                        ++i;
                    }
                }
            }
        } else {
            for (int y = 0; y < getHeight(); ++y) {
                for (int x = 0; x < getWidth(); ++x) {
                    final int greyscale = r.getSample(x, y, 0);
                    convertedPixels[i] = greyToArgbMap[greyscale];
                    ++i;
                }
            }
        }

        final ColorModel ccm = ColorModel.getRGBdefault();
        return new BufferedImage(
                ccm,
                Raster.createPackedRaster(
                        new DataBufferInt(
                                convertedPixels,
                                convertedPixels.length),
                        getWidth(), getHeight(),
                        getWidth(), ((PackedColorModel)ccm).getMasks(),
                        null),
                false,
                null);
    }