Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I handle gamma correction and colors in a PNG file?

I'm working on an image drawing program and I'm getting really confused about colorspaces. The more I read about gamma, the less I know (this wasn't much help).

Internally, the drawing program will store images as 8-bit sRGB and then convert to 16-bit linear for compositing and filtering operations. Perhaps I should store 16-bit linear and then convert to 8-bit sRGB when exporting? I'd love some advice on this. Currently, I'm not really sure what colorspace the RGB pixels are being interpreted as by Qt!

As I understand it, sRGB is close to the colorspace that most monitors use so converting from sRGB to the monitor colorspace doesn't change the image data much. Looking at the sRGB data as if it were in the right colorspace will probably be pretty close.

Goals

The drawing program displays sRGB images. I want to save these sRGB images to a PNG file. When I open this PNG file (with Preview on a Mac) on the same computer that the image was created on, it should look exactly the same as what the artist sees in the drawing program and have the same color values (checked with Digital Color Meter). Different monitors and different operating systems use different colorspaces. These colorspaces might not "fit" the colorspace of the system used to create the image. When I open the PNG on a different monitor or even a different computer, the image should look as similar as possible to the original but probably have different color values.

Experiments

The drawing program seems to be displaying the images correctly (I think). The problem is the PNG. I'm using Qt and saving the image with QImage::save. I'm willing to use libPNG if I need more control.

For testing, I'm drawing a 5x5 image with the color values 0 63 127 191 255 for red and green.

Drawing program screenshots

When I sample the image rendered by the drawing program with Digital Color Meter, the pixel values are unchanged. The pixel at 3,3 sampled with DCM is 191 191 0 as it should be. There is a clear contrast between each of the pixels.

When I take a screenshot, the pixel values in the screenshot file are different. The pixel at 3,3 sampled with DCM when viewing in Preview is 192 191 0. The pixel at 3,3 stored in the file is 140 126 4. I should note that the screenshot file has an sRGB chunk with a rendering intent perceptual.

When I crop the screenshot to a 5x5 image using Preview, the sRGB chunk is replaced with the gAMA and cHRM chunks that correspond to sRGB (I used pngcheck).

gAMA
    0.45455
cHRM
    White x = 0.3127 y = 0.329,  Red x = 0.64 y = 0.33
    Green x = 0.3 y = 0.6,       Blue x = 0.15 y = 0.06

Both versions have the same pixel values stored in the file. They also have the same pixel values when sampled with DCM when viewing in Preview.

Below is the cropped screenshot (it's really tiny!).

Cropped screenshot

The best way of saving a drawing seems to be taking a screenshot but even that's not perfect.

Test programs

Qt program

#include <QtGui/qimage.h>

int main() {
  QImage image{5, 5, QImage::Format_RGB32};
  const int values[5] = {0, 63, 127, 191, 255};
  for (int r = 0; r != 5; ++r) {
    for (int g = 0; g != 5; ++g) {
      image.setPixel(g, r, qRgb(values[r], values[g], 0));
    }
  }
  image.save("qt.png");
}

This program produces the same output as the libpng program except that Qt adds a pHYs chunk. The output looks similar to the desired output but there is less contrast between the pixels and the pixel values are significantly off.

Qt output

Libpng program

#include <cmath>
#include <iostream>
#include <libpng16/png.h>

png_byte srgb_lut[256];

void initLut(const double exponent) {
  for (int i = 0; i != 256; ++i) {
    srgb_lut[i] = std::round(std::pow(i / 255.0, exponent) * 255.0);
  }
}

int main() {
  std::FILE *file = std::fopen("libpng.png", "wb");
  if (!file) {
    std::cerr << "Failed to open file\n";
    return 1;
  }

  png_structp pngPtr = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
  if (!pngPtr) {
    std::fclose(file);
    std::cout << "Failed to initialize png write struct\n";
    return 1;
  }

  png_infop infoPtr = png_create_info_struct(pngPtr);
  if (!infoPtr) {
    png_destroy_write_struct(&pngPtr, nullptr);
    std::fclose(file);
    std::cout << "Failed to initialize png info struct\n";
    return 1;
  }

  if (setjmp(png_jmpbuf(pngPtr))) {
    png_destroy_write_struct(&pngPtr, &infoPtr);
    std::fclose(file);
    std::cout << "Failed to set jmp buf\n";
    return 1;
  }

  png_init_io(pngPtr, file);

  png_set_IHDR(
    pngPtr,
    infoPtr,
    5,
    5,
    8,
    PNG_COLOR_TYPE_RGB,
    PNG_INTERLACE_NONE,
    PNG_COMPRESSION_TYPE_DEFAULT,
    PNG_FILTER_TYPE_DEFAULT
  );

  //png_set_gAMA_fixed(pngPtr, infoPtr, 100000);
  //png_set_sRGB_gAMA_and_cHRM(pngPtr, infoPtr, PNG_sRGB_INTENT_PERCEPTUAL);
  //png_set_sRGB(pngPtr, infoPtr, PNG_sRGB_INTENT_PERCEPTUAL);

  //initLut(2.2);
  //initLut(1.0/2.2);
  initLut(1.0);

  png_bytep rows[5];
  png_color imageData[5][5];
  const png_byte values[5] = {0, 63, 127, 191, 255};
  for (int r = 0; r != 5; ++r) {
    for (int g = 0; g != 5; ++g) {
      imageData[r][g] = {srgb_lut[values[r]], srgb_lut[values[g]], 0};
    }
    rows[r] = reinterpret_cast<png_bytep>(&imageData[r][0]);
  }

  png_set_rows(pngPtr, infoPtr, rows);
  png_write_png(pngPtr, infoPtr, PNG_TRANSFORM_IDENTITY, nullptr);

  png_destroy_write_struct(&pngPtr, &infoPtr);
  std::fclose(file);
}

As I said the previous section, the output is similar to the desired output but there is reduced contrast. The pixel at 3,3 sampled with DCM when viewing in Preview is 186 198 0 which is way off.

Libpng output

I'm really glad that Qt is producing the same output as libpng even though the output isn't what I want it to be. It means that I could switch to libpng if I needed to.

Sample program

This program samples a pixel from a PNG. I'm pretty sure it doesn't do any color space conversion and just gives me the value stored in the file.

#include <iostream>
#include <libpng16/png.h>

int main(int argc, char **argv) {
  if (argc != 4) {
    std::cout << "sample <file> <x> <y>\n";
    return 1;
  }
  const int x = std::atoi(argv[2]);
  const int y = std::atoi(argv[3]);
  if (x < 0 || y < 0) {
    std::cerr << "Coordinates out of range\n";
    return 1;
  }

  std::FILE *file = std::fopen(argv[1], "rb");
  if (!file) {
    std::cerr << "Failed to open file\n";
    return 1;
  }

  png_structp pngPtr = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
  if (!pngPtr) {
    std::fclose(file);
    std::cerr << "Failed to initialize read struct\n";
    return 1;
  }

  png_infop infoPtr = png_create_info_struct(pngPtr);
  if (!infoPtr) {
    png_destroy_read_struct(&pngPtr, nullptr, nullptr);
    std::fclose(file);
    std::cerr << "Failed to initialize info struct\n";
    return 1;
  }

  if (setjmp(png_jmpbuf(pngPtr))) {
    // Pssh, who needs exceptions anyway?
    png_destroy_read_struct(&pngPtr, &infoPtr, nullptr);
    std::fclose(file);
    std::cerr << "Failed to set jmp buf\n";
    return 1;
  }

  png_init_io(pngPtr, file);

  // Does this prevent libpng from changing the color values?
  png_set_gamma(pngPtr, PNG_GAMMA_LINEAR, PNG_GAMMA_LINEAR);

  png_read_png(pngPtr, infoPtr, PNG_TRANSFORM_STRIP_ALPHA, nullptr);
  png_bytepp rows = png_get_rows(pngPtr, infoPtr);
  const int width = png_get_image_width(pngPtr, infoPtr);
  const int height = png_get_image_height(pngPtr, infoPtr);

  if (x >= width || y >= height) {
    // Pssh, who needs RAII anyway?
    png_destroy_read_struct(&pngPtr, &infoPtr, nullptr);
    std::fclose(file);
    std::cerr << "Coordinates out of range\n";
    return 1;
  }

  png_bytep row = rows[y];
  for (int c = 0; c != 3; ++c) {
    std::cout << static_cast<int>(row[x * 3 + c]) << ' ';
  }
  std::cout << '\n';

  png_destroy_read_struct(&pngPtr, &infoPtr, nullptr);
  std::fclose(file);
}

What I'm really trying to do

I need to modify the libpng test program so that the pixel values are 0 63 127 191 255 when opened with Preview and sampled with Digital Color Meter. Sounds like a simple task but it most definitely isn't. There's some commented code in the libpng test program of things I've tried. None of them produces the desired output. What's really frustrating is that Chrome and Preview produce different results. I have no idea which is correct or closest to correct or what "correct" even means.

The more I read about this, the more I think I should just settle for "oh well, it's noticeably wrong but I guess it's good enough *sigh*".

Viewing Experiments

I wrote two identical programs for viewing PNGs. They both produce the desired output (sampling with DCM returns 0 63 127 191 255).

Qt Viewer

#include <iostream>
#include <QtWidgets/qlabel.h>
#include <QtWidgets/qmainwindow.h>
#include <QtWidgets/qapplication.h>

int main(int argc, char **argv) {
  if (argc != 2) {
    std::cerr << "qt_render <file>\n";
    return EXIT_FAILURE;
  }
  QImage image{argv[1]};
  if (image.isNull()) {
    std::cerr << "Failed to load image\n";
    return EXIT_FAILURE;
  }
  image = image.scaled(image.size() * 64);

  QApplication app{argc, argv};
  QMainWindow window;
  window.setWindowTitle(argv[1]);
  window.setFixedSize(image.size());
  QLabel label{&window};
  QPixmap pixmap;
  if (!pixmap.convertFromImage(image)) {
    std::cerr << "Failed to convert surface to texture\n";
    return EXIT_FAILURE;
  }
  label.setPixmap(pixmap);
  label.setFixedSize(image.size());
  window.show();

  return app.exec();
}

SDL2 Libpng Viewer

#include <iostream>
#include <SDL2/SDL.h>
#include <libpng16/png.h>

template <typename... Args>
[[noreturn]] void fatalError(Args &&... args) {
  (std::cerr << ... << args) << '\n';
  throw std::exception{};
}

void checkErr(const int errorCode) {
  if (errorCode != 0) {
    fatalError("Error: ", SDL_GetError());
  }
}

template <typename T>
T *checkNull(T *ptr) {
  if (ptr == nullptr) {
    fatalError("Error: ", SDL_GetError());
  } else {
    return ptr;
  }
}

struct FileCloser {
  void operator()(std::FILE *file) const noexcept {
    std::fclose(file);
  }
};

using File = std::unique_ptr<std::FILE, FileCloser>;

File openFile(const char *path, const char *mode) {
  std::FILE *file = std::fopen(path, mode);
  if (!file) {
    fatalError("Failed to open file");
  } else {
    return File{file};
  }
}

struct WindowDestroyer {
  void operator()(SDL_Window *window) const noexcept {
    SDL_DestroyWindow(window);
  }
};

using Window = std::unique_ptr<SDL_Window, WindowDestroyer>;

struct SurfaceDestroyer {
  void operator()(SDL_Surface *surface) const noexcept {
    SDL_FreeSurface(surface);
  }
};

using Surface = std::unique_ptr<SDL_Surface, SurfaceDestroyer>;

struct TextureDestroyer {
  void operator()(SDL_Texture *texture) const noexcept {
    SDL_DestroyTexture(texture);
  }
};

using Texture = std::unique_ptr<SDL_Texture, TextureDestroyer>;

struct RendererDestroyer {
  void operator()(SDL_Renderer *renderer) const noexcept {
    SDL_DestroyRenderer(renderer);
  }
};

using Renderer = std::unique_ptr<SDL_Renderer, RendererDestroyer>;

class SurfaceLock {
public:
  explicit SurfaceLock(SDL_Surface *surface)
    : surface{surface} {
    SDL_LockSurface(surface);
  }
  ~SurfaceLock() {
    SDL_UnlockSurface(surface);
  }

private:
  SDL_Surface *surface;
};

Surface createSurface(png_structp pngPtr, png_infop infoPtr) {
  const png_bytepp rows = png_get_rows(pngPtr, infoPtr);
  const int width = png_get_image_width(pngPtr, infoPtr);
  const int height = png_get_image_height(pngPtr, infoPtr);

  Surface surface = Surface{checkNull(SDL_CreateRGBSurfaceWithFormat(
    0, width, height, 24, SDL_PIXELFORMAT_RGB24
  ))};
  {
    SurfaceLock lock{surface.get()};
    for (int y = 0; y != height; ++y) {
      uint8_t *dst = static_cast<uint8_t *>(surface->pixels);
      dst += y * surface->pitch;
      std::memcpy(dst, rows[y], width * 3);
    }
  }

  return surface;
}

void doMain(int argc, char **argv) {
  if (argc != 2) {
    fatalError("sdl_render <file>");
  }

  File file = openFile(argv[1], "rb");

  png_structp pngPtr = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
  if (!pngPtr) {
    fatalError("Failed to initialize read struct\n");
  }

  png_infop infoPtr = png_create_info_struct(pngPtr);
  if (!infoPtr) {
    png_destroy_read_struct(&pngPtr, nullptr, nullptr);
    fatalError("Failed to initialize info struct\n");
  }

  if (setjmp(png_jmpbuf(pngPtr))) {
    png_destroy_read_struct(&pngPtr, &infoPtr, nullptr);
    fatalError("Failed to set jmp buf");
  }

  png_init_io(pngPtr, file.get());
  png_read_png(pngPtr, infoPtr, PNG_TRANSFORM_STRIP_ALPHA, nullptr);
  Surface surface = createSurface(pngPtr, infoPtr);
  png_destroy_read_struct(&pngPtr, &infoPtr, nullptr);

  checkErr(SDL_Init(SDL_INIT_VIDEO));
  std::atexit(SDL_Quit);
  Window window = Window{checkNull(SDL_CreateWindow(
    argv[1], SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, surface->w * 64, surface->h * 64, 0
  ))};
  Renderer renderer = Renderer{checkNull(SDL_CreateRenderer(
    window.get(), -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC
  ))};
  Texture texture = Texture{checkNull(SDL_CreateTextureFromSurface(
    renderer.get(), surface.get()
  ))};
  surface.reset();

  while (true) {
    SDL_Event event;
    while (SDL_PollEvent(&event)) {
      if (event.type == SDL_QUIT) {
        return;
      }
    }
    SDL_RenderCopy(renderer.get(), texture.get(), nullptr, nullptr);
    SDL_RenderPresent(renderer.get());
  }
}

int main(int argc, char **argv) {
  try {
    doMain(argc, argv);
  } catch (...) {
    return EXIT_FAILURE;
  }
  return EXIT_SUCCESS;
}

I'm tempted to write an SDL2, OpenGL, Libpng viewer just to be sure but OpenGL is kind of a hassle. My application is aimed at making sprites and textures for games so if it works with the SDL2 render API and OpenGL then I guess everything is fine. I haven't done any experiments with external monitors yet. Putting sRGB, gAMA and cHRM chunks in the PNG doesn't have any effect on the output of either viewer. I'm not sure if this is good or bad. On the surface, it looks like my problem has just disappeared. I'd still like someone to explain my observations.

ColorSync Utility

I've discovered a new tool and now I think I know what's going on...

enter image description here

like image 991
Indiana Kernick Avatar asked Nov 07 '22 16:11

Indiana Kernick


1 Answers

This can be accomplished with ImageMagick.

FYI different photo editing programs manage PNG metadata differently, so be aware of this. For example, Mac's Preview app automatically attaches a sRGB IEC61966-2.1 color profile to edited PNG files that do not have an existing color profile. This behavior not only changes the colors, but also impacts the file size.

To make image file comparisons, I use the following ImageMagick command (for each file):

magick identify -verbose insert/image/filepath/here.png

Also, if you want to strip all PNG metadata, use the following ImageMagick command:

convert -strip insert/original/image/filepath/here.png insert/NEW/image/filepath/here2.png
like image 159
jberg7777 Avatar answered Nov 14 '22 04:11

jberg7777