For our first image processing exercise we want something simple that loops through an image pixel by pixel.  We'll chose color conversion as our problem, which is a translation from the RGB color space of PNG to another.  It will introduce us to the Chapel concepts of domains and ranges, which is where we'll start to see the power of the language.  We'll also conclude our survey of the general language, covering expressions, statements, enumerations, and tuples.  Along the way we'll build a library (module) for color conversion, since we'll be wanting to use this in the future.  As with the previous exercise, we've gone overboard: the original plan was to extract just a greyscale version of a color image.  That is done in two versions of the first program, lab_v1.chpl and lab_v2.chpl.  We then went ahead and extended it to other color spaces, storing the conversion as real numbers and building a general translation procedure to scale it to an 8-bit 1- or 3-color channel PNG.  That is the color_convert.chpl program.

The directory for this chapter is color.  You'll be using the C routines for reading and writing PNG images in img_png_v3.h and img_png_v3.c (from the first exercise of the PNG chapter).  Programs can be built with make name, where the name is the Chapel source file without the .chpl suffix.  Executables are put in the bin sub-directory.  The build sub-directory holds object files and auto-generated dependencies for the C library.  The sample image for this exercise is bobcat.jpg.

The programs and image for this chapter are here (as zip file).  A PDF copy of this text is here.

Aside: Color Spaces

PNG color images contain three color planes corresponding to red, green, and blue, with values between 0 and 255 (inclusive).  This follows the data coming from the image sensor, where the pixels are covered by three color filters.  Each block of 2x2 pixels has a R G / G B pattern with green filters on a diagonal.  This is called a Bayer pattern or matrix.  Because every pixel has only one filter, not three, the camera will interpolate the two missing colors from the surrounding pixels in a process called de-mosaicing.  By combining the three values we can cover many colors (but not all).  For example, yellow is R=255, G=255 and cyan is G=255, B=255. The disadvantages of the RGB color model are that it does not match how we perceive color, which is a non-linear process, it depends on the physical characteristics of the display which are non-linear and change with technology, and it does not accurately reproduce color because there is no standard white reference.


HSV, or Hue Saturation Value, may be familiar to you by tools used to pick colors.  It re-maps the RGB cube into a cylinder.  As the angle changes, the hue goes from red (at 0 degrees) to yellow (60), green (120), cyan (180), blue (240), magenta (300), and back to red (360).  The saturation is the "strongness" of the color, and at low values the color approaches a grey value.  The value is related to the brightness of the color.  There are several ways to define it, for example the largest R, G, or B component as a fraction of its maximum scale (255).  Small values mean the color is dark. The disadvantages of the HSV scale are those of the RGB, since it's just a transformation of that space: brightness is on a linear scale but not perceived at such, perceived brightness depends on the hue, and the values are not well-controlled since the underlying RGB model has no standard definition.  The conversion from RGB to HSV we use is:

    H = 60 * (G - B) / (max - min)            if R is the largest value

        120 + (60 * (B - R) / (max - min))    if G is the largest

        240 + (60 * (R - G) / (max - min))    if B is the largest

    S = 1 - (min /max)

    V = max / 255

where min is the smallest of the R, G, and B values and max the largest.  H takes values between 0.0 and 360.0 (you may need to wrap the calculated value into this range), S between 0.0 and 1.0, and V from 0.0 to 1.0.


YUV or YCrCb is a broadcast standard.  The Y channel carries luminance, and is modeled after how we perceive brightness; green dominates this value.  The Cr and Cb channels are the red and blue components with luminance subtracted out and scaled as required.  In a broadcast system Y corresponds to a black-and-white television signal, with the red and blue difference channels added for color.  This is the model used by JPEG images and MPEG video.  The YCrCb calculation is supposed to use a non-linear compression, called gamma correction, of the underlying R, G, and B data.  The (linear) conversion we use is:

    y =  16 + (0.299 * R) + (0.587 * G) + (0.114 * B)     range 16 t/m 235

    u = 128 - (0.168 * R) - (0.331 * G) + (0.500 * B)     range 16 t/m 239

    v = 128 + (0.500 * R) - (0.419 * G) - (0.081 * B)     range 16 t/m 239



The LAB standard (aka CIE L*A*B*) is a device-independent color space designed to be perceptually uniform, with differences between colors independent of the colors themselves.  As with YUV, L is the lightness of a color and A and B are color difference channels.  Negative A corresponds to green and positive A to red; negative B corresponds to blue and positive B to yellow.  If A and B are 0 then we have a greyscale value.  The color space is non-linearly compressed, and can represent more colors than the RGB space. There are LAB points that do not map back to valid RGB values.  This space is more difficult to calculate, and uses the CIE XYZ space (which we don't otherwise use) as an intermediate step:

    X = ((0.49  * R) + (0.31  * G) + (0.2  * B)) / (255 * 0.17697)

    Y = ((0.177 * R) + (0.812 * G) + (0.011 *B)) / (255 * 0.17697)

    Z =               ((0.01  * G) + (0.99 * B)) / (255 * 0.17697)

    L = 116 * map(Y * 5.6507) - 16

    A = 500 * (map(X * 5.6507) - map(Y * 5.6507))

    B = 200 * (map(Y * 5.6507) - map(Z * 5.6507))

    map(t) = t ** (1/3) if t < 0.2069 or (7.787 * t) + 0.1379 else

(This mapping is a non-linear compression with a knee point at 6/29.)  L takes values between 0.0 and 100.0, A between -500.0 and 500.0, and B from -200.0 to 200.0.  In practice the ranges for A and B are much smaller.  The scaling factor of 5.6507 for X, Y, and Z is actually related to the choice of a white point, the color at which L=100.0 and A=B=0.0.  We chose the value so that (255, 255, 255) is treated as white, but remember that the RGB space is uncontrolled and this may not represent true white in the scene.


Language Description

To avoid scattering our discussions about the language amongst the programs, we'll start by talking about the basic language concepts separately.  We'll then discuss arrays, domains, and ranges with the programs as examples.

Expressions and Operators

Operators (listed in order of precedence within each group)

  arithmetic     ** * / % + (unary) - (unary) + (binary) - (binary)

  comparison     <= >= < > == !=

  logical        ! & ^ | && ||

  bit            ~ << >> & ^ |

  structure      . new

  parallel       reduce scan dmapped sync single atomic

  other          () [] : .. in if/then/else forall/do for/do

  range          .. by # align

Description and Notes

        writeln("-(1.0 - 2.0i) = ", -(1.0 - 2.0i));

        > -(1.0 - 2.0i) = -1.0 + 2.0i

        writeln("1.0i * -2.0i = ", 1.0i * -2.0i);

        > 1.0i * -2.0i = 2.0

        writeln("1.0i * 2.0 = ", 1.0i * 2.0);

        > 1.0i * 2.0 = 2.0i

        writeln("3.0i / 2.0i = ", 3.0i / 2.0i);

        > 3.0i / 2.0i = 1.5

        writeln("3.0i / 2.0 = ", 3.0i / 2.0);

        > 3.0i / 2.0 = 1.5i

        writeln("\"concat\" + true = ", "concat" + true);

        > "concat" + true = concattrue

        writeln("9 % 5 = ", 9 % 5);

        > 9 % 5 = 4

        writeln("(-9) % 5 = ", (-9) % 5);

        > (-9) % 5 = -4

        writeln("9 % (-5) = ", 9 % (-5));

        > 9 % (-5) = 4

        /* ineg == -86, ipos == 85, u == 204 */

        const ineg : int(8) = 0b10101010 : int(8);

        const ipos : int(8) = 0b01010101 : int(8);

        const u : uint(8) =   0b11001100 : uint(8);

        writef("ineg | u = %08bu (%u)\n", ineg | u, ineg | u);

        > ineg | u = 11101110 (238)

        writef("ipos | u = %08bu (%u)\n", ipos | u, ipos | u);

        > ipos | u = 11011101 (221)

        writef("ineg | ipos = %08bu (%u)\n", ineg | ipos, ineg | ipos);

        > ineg | ipos = 11111111 (255)

        writef("ineg & u = %08bu (%u)\n", ineg & u, ineg & u);

        > ineg & u = 10001000 (136)

        writef("ipos & u = %08bu (%u)\n", ipos & u, ipos & u);

        > ipos & u = 01000100 (68)

        writef("ineg & ipos = %08bi (%i)\n", ineg & ipos, ineg & ipos);

        > ineg & ipos = 00000000 (0)

        writef("ineg ^ u = %08bu (%u)\n", ineg ^ u, ineg ^ u);

        > ineg ^ u = 01100110 (102)

        writef("ipos ^ u = %08bu (%u)\n", ipos ^ u, ipos ^ u);

        > ipos ^ u = 10011001 (153)

        writef("ineg ^ ipos = %08bi (%i)\n", ineg ^ ipos, ineg ^ ipos);

        > ineg ^ ipos = 11111111 (255)

        writef("~ineg = %08bi (%i)\n", ~ineg, ~ineg);

        > ~ineg = 01010101 (85)

        writef("~ipos = %08bi (%i)\n", ~ipos, ~ipos);

        > ~ipos = 10101010 (170)

        writef("~u = %08bu (%u)\n", ~u, ~u);

        > ~u = 00110011 (51)

        writef("ineg << 2 = %08bu (%u)\n", ineg << 2, ineg);

        > ineg << 2 = 10101000 (170)

        writef("ipos << 2 = %08bu (%u)\n", ipos << 2, ipos);

        > ipos << 2 = 01010100 (85)

        writef("u << 2 = %08bu (%u)\n", u << 2, u);

        > u << 2 = 00110000 (204)

        writef("ineg >> 2 = %08bu (%u)\n", ineg >> 2, ineg);

        > ineg >> 2 = 11101010 (170)

        writef("ipos >> 2 = %08bu (%u)\n", ipos >> 2, ipos);

        > ipos >> 2 = 00010101 (85)

        writef("u >> 2 = %08bu (%u)\n", u >> 2, u);

        > u >> 2 = 00110011 (204)

        (denom != 0) && ((num / denom) < 10)

        a = if (dx == 0) then (pi / 2.0) else atan(dy / dx);

        odd = for i in 1..100 do if (0 != (i % 2)) then i;

        proc tstfn(i : int) { writeln("tstfn called with " + i) };


        > tstfn called with 2

The cast operator (:) forcibly converts between data types, raising an error if it is not possible.  Chapel will implicitly convert mixed type expressions if it can do so without losing information.  In other words, an uint(s) can cast to an uint(t) if t >= s, and to an int(t) if t>s.  Additionally, Chapel allows conversions from int(64) and uint(64) to real(64) and complex(128) even though the destination types cannot hold all the bits.  An explicit cast can lose information, for example the extra bits from a larger int will be dropped when going to a smaller, and a real may lose precision.  

    const spos : int(16) = 0xcafe : int(16);

    writef("0x%xu : int(8) = 0x%xu\n", spos, spos : int(8));

    > 0xcafe : int(8) = 0xfe

Casting from numbers to booleans follows the "0 is false, all else is true" rule.  We have seen in the example with processing the arguments to main() that a string can be cast to another type.  If the parse fails, then the program will crash with an error.  Note that the integer base prefixes 0x, 0o, 0b will not be read.

    const icast = "1234" : int;

    writef("\"1234\" : int         = 0x%xi\n", icast);

    > "1234" : int         = 0x4d2

    const ucast = "1234" : uint(8);

    writef("\"1234\" : uint(8)     = 0x%xu\n", ucast);

    > "1234" : uint(8)     = 0xd2

    const imcast = "6.626e-34i" : imag;

    writef("\"6.626e-34i\" : imag  = %m\n", imcast);

    > "6.626e-34i" : imag  = 6.626e-34i

    const bcast = "false" : bool;

    writef("\"false\" : bool       = %s\n", bcast);

    > "false" : bool       = false

But not all casts are allowed.  The language specification spends a chapter talking about the rules.

The code snippets demonstrating the operators can be found in the file ex_op.chpl.  Compile and run them with

    make ex_op



Statements may be grouped into blocks by putting them inside braces.  Semicolons terminate statements but are not needed after braces.


Assignment statements set the value of the left hand side to the right, or copy a reference if appropriate.  Several operators may be combined with the assignment to form a compound assignment.  

    a <op>= b       means  a = a <op> b

For example, if a = 0b11011;   a ^= 0b01101 means a will end up with the value 0b10110.  The compound assignments are +=, -=, *=, /=, **=, &=, |=, ^=, &&=, ||=, <<=, >>=.


The swap statement transfers the values between two variables.

    a <=> b;

is equivalent to  

    const t = b; b = a; a = t;

If .. then .. else

We've already seen the if..then..else statement in the rw_png_v4.chpl example in the previous section.  Use the keyword then only if it's followed by a single statement, otherwise a block is needed.  Else, if desired, must be written and followed by a single statement or a block.  If the conditional expression can be evaluated at compile time, then the test will be deleted and the appropriate result will be kept.  Each block, or even single statement, following an if generates its own scope so that variables can be locally declared.

    if (a < b) then writeln("a smaller"); else writeln("b smaller");

    if (max < min) {

      writeln("max smaller than min, swapping");

      min <=> max;

    } else {

      var midpt = (min + max) / 2;

      writeln("midpoint of ", min, " and ", max," is ", midpt);



The select statement is used to chose between alternatives.  It takes an expression that is compared to each values in each when, where there may be many comma-separated values/expressions in the list.  If there is a match, then the statements(s) afterwards are executed.  As with if..then, you can place the keyword do before a single statement, or use braces to surround a block.  There can be one otherwise clause that captures any value not matching a when.  Each block, or single statement, after a when is in its own scope.

    select imgname {

      when "fisher" do writeln("haven't got a picture of a fisher yet");

      when "bobcat" {

        var rgb : rgbimage;

        PNG_read("bobcat.png", rgb);

        PNG_write("copy_bobcat.png", png);



      otherwise writeln("don't know about an image named ", imgname);


While .. do, do .. while

The while statement can have either a or do..while form. The first evaluates its condition before entering its loop, so that if the condition is initially false the do statements will never execute.  The second form evaluates its condition after the first pass and guarantees that the do statements will execute at least once.  In the first form the do keyword is only needed if it is followed by a single statement, otherwise it can be replaced by a block.  The do is required in the second form.  The block, or even single statement, will have its own scope, and in the do..while form this scope extends to the test condition.  This means that variables (or constants) declared in the body of the loop can be used in the test.

    i = -1;

    while ((0 <= i) && (i < 5)) { writef("  %i", i) ; i += 1; }

does not execute, but

    i = -1;

    do {

      const j = i + 1;

      writef("  %i", i);

      i += 1;

    } while ((0 <= j) && (j < 5))


    >   -1  0  1  2  3  4

Here j is a constant that is created locally for each pass of the loop.


The for statement is a general loop over any data type, including classes, that provides an iterator called these.  For built-in types this includes ranges, domains, arrays, and iterators, none of which we've covered yet. The loop variable is declared by the statement and is valid for the block. If the iterator provides multiple values, then you can provide multiple variables that will automatically match.  For example, the iterator for a 2D domain will produce a tuple with the x and y coordinates, and we can put a tuple with two variables that will bind to these coordinates.  If there is no need for variables then they can be omitted.  The format of a for statement is

    for [param] <loop vars> in <iterator> do <single statement>

    for [param] <loop vars> in <iterator> { <block> }

    for <iterable> do <single statement>

    for <iterable> { <block> }

The loop will execute serially once for each value produced by the iterator, and the block, or even single statement, will have its own scope.  If the param keyword is provided, then the iterator must be a range formed by parameters, ie. known at compile-time.  The loop is then unrolled and replaced in the compiled code by the block evaluated for each possible value of the range.  There is no loop in this case.

break, continue, label

The flow of execution within the while and for loops may be changed with the break and continue statements.  A continue causes the flow to immediately skip to the end of the block.  A break causes the flow to exit the loop.  They are not permitted in parallel loops.  You can also apply a label to a loop and give the label's name to a continue or break to exit an inner loop.

    label outer

    for y in 1..5 {

      for x in 1..5 {

        if (x == y) {


          continue outer;

        } else {

          writef("  (%i,%i)", x,y);






    >   (1,2)

    >   (1,3)  (2,3)

    >   (1,4)  (2,4)  (3,4)

    >   (1,5)  (2,5)  (3,5)  (4,5)


use, require

The use statement imports a module's symbols, making them available without qualification (you don't need to prefix the symbol with the module name and '.').  Multiple modules may be listed in one use statement, separated by commas.  It may be placed within a block, and only the block will see the import (essentially the module is placed between the parent's and the block's scope).  The require statement lists C header, source, and object files and libraries to compile with the Chapel program.


The I/O statement <~> sends a value on the right side to an output (Writer) on the left, or puts a value from an input (Reader) on the right into a variable on the left.

The code snippets for this section are found in ex_stmt.chpl.  Compile and run them with

    make ex_stmt



An enumeration is a list of identifiers backed by integer values.  Unlike C, they are not a synonym for an integer but a separate type.  To use an enum, you must type its name, a dot, and then the member's name.  As an example, consider a list of the color spaces we'll support:

    enum clrspace {

      HSV, LAB, YUV, RGB



    config const space : clrspace;

    if (clrspace.YUV == space) {

      rgb_to_yuv(r, g, b, c1, c2, c3);

    } else if (clrspace.HSV == space) {

      rgb_to_hsv(r, g, b, c1, c2, c3);

    } else if (clrspace.LAB == space) {

      rgb_to_lab(r, g, b, c1, c2, c3);

    } else {

      writeln("unsupported color space " + space);


Although the final else isn't really necessary, our practice here is to include it in case we should ever change the contents of the enum and forget to add a new value.  You could also write this with a select

    select space {

      when clrspace.YUV do rgb_to_yuv(r, g, b, c1, c2, c3);

      when clrspace.HSV do rgb_to_hsv(r, g, b, c1, c2, c3);

      when clrspace.LAB do rgb_to_lab(r, g, b, c1, c2, c3);

      otherwise writeln("unsupported color space " + space);


The otherwise is needed here too because the select does not check that all values are covered.

The first identifier in the enum is assigned 1, with the value increasing for each member thereafter.  You can also assign a number to one and the value will begin to increment from the next.  The default value of an enum variable is the first member.  You can force two identifiers to have the same value, in which case they will be considered equal.

    /* HSV=1, LAB=2, YUV=1, RGB=2 */

    enum clrspace_v2 {

      HSV, LAB, YUV=1, RGB



    if (clrspace_v2.HSV == clrspace_v2.YUV) then writeln("HSV, YUV identical");

    > HSV, YUV identical

    if (clrspace_v2.LAB == clrspace_v2.RGB) then writeln("LAB, RGB identical");

    > LAB, RGB identical

    if (clrspace_v2.HSV == clrspace_v2.LAB) then writeln("HSV, LAB identical");



    proc print_space(space : clrspace_v2) {

      writeln("picked color space " + space);



    > picked color space HSV


    > picked color space HSV

You can make a configuration variable an enum.  In this case pass the member's name on the command line, not its value.

    config const space : clrspace_v2;

    writef("From the command line you ");



    > From the command line you picked color space HSV

    ./ex_enum --space=LAB

    > From the command line you picked color space LAB

    ./ex_enum --space=YUV

    > From the command line you picked color space HSV

    ./ex_enum --space=2

    > <command line setting of 'space'>: error: halt reached - illegal

    > conversion of string "2" to clrspace_2

Here again we see the aliasing of the enum names from the underlying values.

You can use the enum as a constant directly, if Chapel can figure out the type to give the value.  If it can't, cast it appropriately.

    enum clrmasks {

      RED = 0xff0000, GREEN = 0x00ff00, BLUE = 0x0000ff


    writef("enum vals   R 0x%06xu  G 0x%06xu  B 0x%06xu\n",

           clrmasks.RED, clrmasks.GREEN, clrmasks,BLUE);

    > enum vals   R 0xff0000  G 0x00ff00  B 0x0000ff

    writef("decompose 0x123456    R 0x%06xu  G 0x%06xu  B 0x%06xu\n",

       0x123456 & clrmasks.RED : uint), 0x123456 & (clrmasks.GREEN : uint),

       0x123456 & (clrmasks.BLUE : uint));

    > decompose 0x123456    R 0x120000  G 0x003400  B 0x000056

Without the explicit cast this will not compile because the type needed to pass to the & function isn't clear.  The %xu format string, by the way, is for a hexadecimal uint.  Similarly you can go the other way, casting an int to an enum constant.  

    writef("convert int to enum   ", 0xff0000 : clrmasks);

    > convert int to   RED

Compilation will fail if the integer is not a value in the enum.

The code snippets for enums are in ex_enum.chpl.  Compile and run them with

    make ex_enum



A tuple is a collection of unnamed elements, not necessarily all the same type. To construct a tuple put a list of types between parentheses.  If the types are all the same you can use the <number>*<type> notation, which creates that many elements of the given type.

    var coord3D_1 : (int, int, int);

    var coord3D_2 : 3*int;

Both define two triples where all three members are integers.  If you want to create a tuple with just one element, you must use the * notation, as Chapel's grammar resolves (<type>) as an expression with a type inside parentheses.

    var coord1D_1 : 1*int;

If you assign a tuple to a new variable, the type of the elements on the right hand side determine the type of the tuple on the left.

    var coord3D_3 = (10, 20, 30);

This version is also a triple of three integers.  Again there's confusion between a single number between parentheses representing an expression or a tuple.  To create such a tuple, leave a trailing comma.

    var coord1D_2 = (100,);

You can retrieve the n'th value in the tuple, starting at 1, by putting the index in parentheses after the name.

    writeln("y = ", coord3D_3(2));

    > y = 20

You can iterate directly over the elements with a for loop.

    for x in coord3D_3 do writeln("coord = " + x);

    > coord = 10

    > coord = 20

    > coord = 30

Alternately, the method size is defined.

    for i in 1..coord3D_3.size do writeln("coord " + i + " = " + coord3D_3(i));

    > coord 1 = 10

    > coord 2 = 20

    > coord 3 = 30

You cannot loop like this, with either approach, if the tuple has multiple types.

Destructuring is a form of pattern matching where elements in a structure (a tuple in this case) are set position-by-position to elements in a matching structure.  The tuples can be nested.  If you want to skip a value, put an underscore at that position on the left side.  Both sides must have the same number of elements.

    var x, y, z : int;

    (x, _, z) = coord3D_3;

    writeln("x = ", x, "  y = ", y, "  z = ", z);

    > x = 10  y = 0  z = 30

The second member of coord3D_3 does not get assigned to anything; y keeps its default initialization value.  Destructuring also works if tuples are declared as function arguments by wrapping them in an extra set of parentheses.

Tuples support some operators.  The unary operators +, -, ~, and ! are applied to each element, returning the results in a tuple of the same size.

    coord3D_2 = -coord3D_3;

    for x in coord3D_2 do writeln("- coord = " + x);

    > - coord = -10

    > - coord = -20

    > - coord = -30

Binary operators combine the elements at their corresponding position.

    coord3D_1 = coord3D_3 * coord3D_3;

    for x in coord3D_1 do writeln("coord*coord = " + x);

    > coord*coord = 100

    > coord*coord = 400

    > coord*coord = 900

The operators that work for tuples are +, -, *, /, %, &, |, ^, <<, and >>.  If the tuple has a mixed type, then the operator must be valid for all the types. Comparisons can also be made.  For == all element pairs must meet the condition, for != one pair must differ.

    writeln("coord3D_3 == -coord3D_2 ? ", coord3D_3 == -coord3D_2);

    > coord3D_3 == -coord3D_2 ? true

    writeln("(1,1,1,0,1,1,1) != (1,1,1,1,1,1,1) ? ",

            (1,1,1,0,1,1,1) != (1,1,1,1,1,1,1));

    > (1,1,1,0,1,1,1) != (1,1,1,1,1,1,1) ? true

For >, >=, <, and <= elements are scanned from left to right.  If the pair is equal it is skipped, otherwise the relationship evaluates to true or false depending on if the condition is satisfied.  If all elements are checked, then the result if true if using >= or <=, else false.  In other words, common elements at the start of the tuple are ignored, and >= or <= only apply if the two are completely equal, with > and < failing if they are.

    writeln("fail 1st index: (1, 2, 3) >  (2, 2, 2) ? ", (1, 2, 3) > (2, 2, 2));

    > fail 1st index: (1, 2, 3) >  (2, 2, 2) ? false

    writeln("pass 1st index: (3, 2, 1) >  (2, 2, 2) ? ", (3, 2, 1) > (2, 2, 2));

    > pass 1st index: (3, 2, 1) >  (2, 2, 2) ? true

    writeln("fail 2nd index: (2, 1, 3) >  (2, 2, 2) ? ", (2, 1, 3) > (2, 2, 2));

    > fail 2nd index: (2, 1, 3) >  (2, 2, 2) ? false

    writeln("pass 2nd index: (2, 3, 1) >  (2, 2, 2) ? ", (2, 3, 1) > (2, 2, 2));

    > pass 2nd index: (2, 3, 1) >  (2, 2, 2) ? true

    writeln("fail 3rd index: (2, 2, 1) >  (2, 2, 2) ? ", (2, 2, 1) > (2, 2, 2));

    > fail 3rd index: (2, 2, 1) >  (2, 2, 2) ? false

    writeln("pass 3rd index: (2, 2, 3) >  (2, 2, 2) ? ", (2, 2, 3) > (2, 2, 2));

    > pass 3rd index: (2, 2, 3) >  (2, 2, 2) ? true

    writeln("fail at end:    (2, 2, 2) >  (2, 2, 2) ? ", (2, 2, 2) > (2, 2, 2));

    > fail at end:    (2, 2, 2) >  (2, 2, 2) ? false

    writeln("pass at end:    (2, 2, 2) >= (2, 2, 2) ? ", (2, 2, 2) >= (2, 2, 2));

    > pass at end:    (2, 2, 2) >= (2, 2, 2) ? true

The code for these examples is in ex_tuple.chpl.  Compile and run it with

    make ex_tuple


Arrays and Ranges (lab_v1.chpl)

The goal for this section is to convert our color input image to greyscale so that we can do further image processing.  We have two options, either calculate the L plane from the LAB space or the Y plane from YUV (YCrCb). We'll pick the LAB transform, as defined above.  For each pixel in the image we will need to calculate L, and we will want to scale the value to an uint(8) (c_uchar) so we can write the result as a PNG.  Although we could do the conversion and scale it for display in one step, we'll break it into two.  This will be closer to our workflow later, of passing intermediate results from one processing routine to another until we're ready to save the result.  Therefore, we will create temporary arrays for the conversion and store all three components.  We'll further develop this approach in the next version.

An array declaration looks like

    var <name> : [<domain>] <type>

A domain represents the indices that are valid for the array.  It provides iterators so that we can traverse the set.  Domains may have as many dimensions as desired.  They may use ranges of numbers, or a discrete set of values, possibly non-numeric.  They may be subsets of other domains or have a continuous (dense) or sparse set of indices.  They may be mapped to a specific runtime configuration involving different locales, which are computational centers with processing and storage.  Domains are one of the key abstractions Chapel provides for safely working with arrays in a parallel environment.

We will be using a rectangular domain based on a range of numbers.  A range is specified by its lower and upper bounds, the stride or the increment in the index between steps, and its alignment or offset from 0.  The format for a range is

     [<start>] .. [<end>] [by <stride>] [align <offset>]

Either the start or the end can be omitted; in this case the range will generate a potentially infinite sequence of numbers.  You can use the range to iterate over the sequence only if it has a start; a range unbounded at the start can be used, however, to select a subset of another range, or if the stride is negative and you indicate how many steps to take.  An unbounded end can also be used to select a subset, or you can define the number of iteration steps that will be taken with a positive stride.  Note that the end value is inclusive.

You can think of the stride and alignment defining an infinite sequence of numbers, spaced by stride and including align.  The start and end points clip this sequence so that only members that fall between them (inclusively) are generated.  If the start or end are omitted then the sequence is infinite and can only be used if it modifies another range or you provide a count of how many values you want.

Some examples.  If we want to define a range covering all the pixels of an image

    0 .. (npix-1)

because the end is inclusive (in C we would write the loop as 'for (xy=0; xy<npix; xy++) { .. }').  The default stride is 1 and the default alignment is 0.  This generates all numbers from 0 t/m npix-1.  If we want to define a range covering one row, y,

     (y*ncol) .. ((y+1)*ncol - 1)

assuming that we treat the array as nrow rows of ncol pixels each.  The end of the row is just the pixel before the start of the next row (y+1).  If we want to define a range covering the first column

     0 .. (npix-1) by ncol

That is, we increment the index by an entire row between steps.  If we want a column x in the middle, we can set a range in two ways.  First, we can change the start of the range

     x .. (npix-1) by ncol

This generates the numbers x, x+ncol, x+2*ncol, ... x+(nrow-1)*ncol through the last row.  Alternately we can specify the alignment

     0 .. (npix-1) by ncol align x

without changing the bounds that define the entire image.  Qua style this is a better approach because it makes clear what the limits of the image are and how we should cover pixels within it.  The first option is closer to how the loop would be written in C, however, and years of practice means it feels more natural.

The test program ex_range.chpl prints out the indices generated by a small 10x10 image.  It can take two command line arguments --x and --y to print a row or column.  Compile and run it with

    make ex_range


    >  0   1   2   3   4   5   6   7   8   9

    > 10  11  12  13  14  15  16  17  18  19

    > 20  21  22  23  24  25  26  27  28  29

    > 30  31  32  33  34  35  36  37  38  39

    > 40  41  42  43  44  45  46  47  48  49

    > 50  51  52  53  54  55  56  57  58  59

    > 60  61  62  63  64  65  66  67  68  69

    > 70  71  72  73  74  75  76  77  78  79

    > 80  81  82  83  84  85  86  87  88  89

    > 90  91  92  93  94  95  96  97  98  99

    bin/ex_range --x=5     /* 0..lastpix by ncol align x */

    > column  5:   5  15  25  35  45  55  65  75  85  95

    bin/ex_range --y=5     /* y*ncol..(y+1)*ncol-1 */

    > row   5:  50  51  52  53  54  55  56  57  58  59

The program has checks that the column and row values are within the image.  If you remove them, you'll notice that the column can be any number and still generate a proper, valid sequence of indices. That's because the alignment is taken modulo the number of columns – it is folded back into the sequence.  Changing the alignment shifts the infinite sequence, and a shift by a multiple of the number columns intersects the image bounds at the same points. This would not happen with the first alternative: if x was greater than or equal to ncol then we would skip rows at the start, and if it was greater than npix-1 we would generate no indices at all (if end < start we have an empty set).

If the stride is negative the start and end indices are reversed internally so that the sequence counts down.  When writing out the range, however, still keep start < end.

Ranges are proper data structures and can be assigned to variables.  Starting with an existing range, we can generate a second in four ways.

1. by and align can be applied, modifying the base sequence.  For example, here the first range generates the odd integers to 20, and the second by takes every other one of those, or 1, 5, 9, 13, and 17.

     (1 .. 20 by 2) by 2

2. We can say how many indices we want to generate.  This is the count operator and the command looks like

    <range> # <number>

This adjusts the bounds of the range, but not the stride, to create <number> steps.  If <number> is positive the values are taken at the start of the sequence, if negative from the end.  This will generate the indices of column x in the first five rows, or x, x+ncol, x+2*ncol, x+3*ncol, x+4*ncol.

     (0 .. npix-1 by ncol align x) # 5

This for the last five rows, or x+(nrow-6)*ncol, x+(nrow-5)*ncol, ..., x+(nrow-1)*ncol.

    (0 .. npix-1 by ncol align x) # -5

3. We can add or subtract a number from the start and end values.  This also shifts the alignment, but not the stride.  For addition the number can be placed on either side of the range, ie. <range> + <number> or <number> + <range>; for subtraction it can only go to the right, ie. <range> - <number>.

4. We can use a second range to take a subrange of the first.  This is called slicing, and the resulting set is the intersection of the two ranges.  To define a slicing, place the second range in parentheses or brackets after the first

    <range1>(<range2>)    or    <range1>[<range2>]

where, as usual, the range can be either fully written out or a variable to which a range has been assigned.

For this program we only want a range to cover the entire image.  The conversion to LAB is the heart of the program.  Let rgb_to_lab() be the top-level function that processes the entire image, and rgbpix_to_lab() a function to handle one pixel.

    const LAB_LMIN =    0.0;

    const LAB_LMAX =  100.0;

    const LAB_AMIN = -128.15;

    const LAB_AMAX =  182.46;

    const LAB_BMIN = -155.36;

    const LAB_BMAX =  156.20;


    proc rgb_to_lab(rgb : rgbimage, ref lab : rgbimage) : c_int {

      const imgbounds = 0 .. (rgb.npix-1);   /* range covering image */

      var l : [imgbounds] real;              /* L color plane */

      var l_a : [imgbounds] real;            /* A color plane */

      var l_b : [imgbounds] real;            /* B color plane */

      var retval : c_int;


      retval = alloc_rgbimage(lab, rgb.ncol, rgb.nrow);

      if (retval < 0) then return retval;


      for xy in imgbounds {

        rgbpix_to_lab(rgb.r(xy), rgb.g(xy), rgb.b(xy), l(xy), l_a(xy), l_b(xy));


        clamped = clamp(l(xy), LAB_LMIN, LAB_LMAX);

        lab.r(xy) =

          ((255.0 * (clamped-LAB_LMIN) / (LAB_LMAX-LAB_LMIN)) + 0.5) : c_uchar;

        clamped = clamp(l_a(xy), LAB_AMIN, LAB_AMAX);

        lab.g(xy) =

          ((255.0 * (clamped-LAB_AMIN) / (LAB_AMAX-LAB_AMIN)) + 0.5) : c_uchar;

        clamped = clamp(l_b(xy), LAB_BMIN, LAB_BMAX);

        lab.b(xy) =

          ((255.0 * (clamped-LAB_BMIN) / (LAB_BMAX-LAB_BMIN)) + 0.5) : c_uchar;


      return 0;



    proc rgbpix_to_lab(r : c_uchar, g : c_uchar, b  :c_uchar,

                       out l : real, out l_a : real, out l_b : real) {

      /* see source for implementation */



    proc clamp(val : real, minval : real, maxval : real) : real {

      if (maxval < minval) then return minval;

      else if (val < minval) then return minval;

      else if (maxval < val) then return maxval;

      else return val;


Here rgb is the PNG image we've read and lab is the greyscale image we'll create.  The imgbounds range has been used to create the arrays, as well as driving the for loop.  rgbpix_to_lab() uses an out intent on the three components to modify the array elements in the caller directly.  Internally it calls two other procedures for the XYZ conversion and non-linear mapping.  Remember that the LAB space is bigger than the RGB, so we define constants LAB_[LAB]MIN and LAB_[LAB]MAX that specify how to scale each component (Example 2 asks you to explain these values).  The clamp() function limits the value to this range, and then we scale the value and convert it to a uint(8).  The 0.5 term is for rounding after the cast.  We end up storing L in the R plane of the lab image, A in the G plane, and B in the B(lue) plane.  We can specify in the PNG_write() call which plane we want to save since we're using the version from Exercise 1 of the previous chapter.  CLR_R is the luminance/greyscale information.

The main() routine is simple.  It checks that the command line arguments are valid, reads the image, calls rgb_to_lab(), and writes the result.

    config const inname : c_string;         /* input file name */

    config const outname : c_string;        /* output file name */

    proc main() {

      var rgb: rgbimage;                    /* image we've read */

      var grey : rgbimage;                  /* LAB transform in 8 bit grey */

      var retval : c_int;




      retval = PNG_read(inname, rgb);

      end_onerr(retval, rgb);


      retval = rgb_to_lab(rgb, grey);

      end_onerr(retval, rgb, grey);


      retval = PNG_write(outname, rgb, CLR_R);

      end_onerr(retval, rgb, grey);





      return 0;


Compile the program with

    make lab_v1

It takes two command line arguments.  --inname=<PNG file> is the file to read, and --outname=<file> is the file to create.  You can try the program with

    bin/lab_v1 --inname=bobcat.png --outname=grey_bobcat.png



Domains (lab_v2.chpl)

For the second version of the LAB conversion, we'll re-organize the program to make the two steps of the process, conversion and 8-bit scaling, clear. We'll move the intermediate arrays we used into a separate data structure. This gives us a chance to change the way we represent the image.  We use a flat array when working with C because building a 2D array, allocating row by row, is annoying.  This requires that we transform (x, y) image coordinates to an xy index; it's not a huge burden, creates standard idioms in code for loops, and is efficient at addressing, but it does obscure the intent of the code if you aren't familiar with the approach.  With Chapel's ranges and domains it will be easy to use the coordinates instead of the index, so we will change to a 2D domain.  As we split off the 8-bit scaling, we will also add different strategies other than over a fixed range, which lab_v1 uses.  This re-organization will lay the groundwork for the final program, a general colorspace converter.

A domain has a number of dimensions called a rank.  In lab_v1 the domains behind our arrays had a rank of 1: they used a single index.  In our Chapel images we want to work with rank 2.  Indices will be represented by a tuple (x, y).  Our domain is rectangular and dense, meaning we will have data at all points inside it.  In Chapel's terminology we'll use a base, rectangular domain.  To declare a domain, call the constructor

    domain(rank=<number dimensions>, idxType=int, stridable=false);

As the named arguments imply, you can change the type of the index generated.  The stridable flag turns on support for the 'by' keyword, similar to striding in ranges.  Since each index will have the same type, we can also create a domain by passing a tuple with all elements of that type


Domains are proper data structures and may be assigned to variables.

For our images, we want a 2D domain.  We'll start building a data structure to hold the arrays, using a class.  We need to track the image size and will still calculate the number of pixels, although there's less need for that since we aren't using a 1D index.  We've already seen how to define an array over a domain; we'll use c1, c2, and c3 to represent generic color planes whose interpretation depends on the color space we're in.  (Because we don't usually mix color spaces within a program, we won't use an enum inside the data structure to track which space is being used.)  The members of the class are

    class clrimage {

      var ncol : int;                   /* width (columns) of image */

      var nrow : int;                   /* height (rows) of image */

      var npix : int;                   /* number pixels = w * h */

      var area : domain(rank=2);        /* array bounds by x and y */

      var c1 : [area] real;             /* first color plane (L, H, Y, R) */

      var c2 : [area] real;             /* second plane (A, S, U, G) */

      var c3 : [area] real;             /* third plane (B, V, V, B) */


Each dimension of a domain is specified with a range.  The domain is the product of all the ranges, that is, all combinations of their values.  The indices are generated in rank order, so the range in the first rank varies the least. That is, the indices are formed by a series of nested loops, with the first rank the outermost and the last the innermost.  To specify a domain, put the ranges in a list between braces.

    area = {0..nrow-1, 0..ncol-1}

defines the bounds of our image, using 0-based indexing.  The first rank runs over y (along a column), the second over x.  Loops will match the pixel layout in memory, moving through the linear array.  You cannot use strides with these ranges.

The size of an array depends on the underlying domain.  When the domain changes, the array changes.  The domain must be kept in a variable to do this, as we have done above.  You cannot change the domain assigned to an array (although there is a method to access it), but you can change the contents of the domain if it's been given a name.  As we see above, one advantage of this approach is that domains can be shared.  If we change area, all three color planes will grow or shrink accordingly.  During the re-size, array elements in the overlap between the old and new domain specifications are kept and any elements added are initialized to the default of the data type.

area here is a placeholder.  Once we've read in the PNG, we can then set up the Chapel image using a constructor we add to the class.

    class clrimg {

      /* members defined above */

      proc clrimg(w: int, h: int) {

        ncol = w;

        nrow = h;

        npix = w * h;

        /* This automatically re-sizes the arrays. */

        area = {0..nrow-1, 0..ncol-1};



To use this, we modify the rgb_to_lab() procedure to take a clrimage instead of an rgbimage.  We instantiate the image, and use the domain to convert each pixel.  Since we're now generating the x and y coordinates from the domain, we need to calculate the xy index for the rgbimage inside the loop. The c1, c2, and c3 array references take (x,y) as a tuple.

    proc rgb_to_lab(rgb : rgbimage, ref lab : clrimage) {


      lab = new clrimage(rgb.ncol, rgb.nrow);


      for (y, x) in lab.area {

        const xy = (y * rgb.ncol) + x;

        rgbpix_to_lab(rgb.r(xy), rgb.g(xy, rgb.b(xy),

                      lab.c1(y,x), lab.c2(y,x), lab.c3(y,x));



With this framework we'll be able to support different color space conversions by creating a rgbpix_to_hsv() or rgbpix_to_yuv() function.  

We next need to convert the real results to 8-bit.  In lab_v1 the valid data range was fixed: if L, A, or B went outside the defined limits they were clamped before scaling to 0 t/m 255.  The range differs for each plane, however, and other color spaces use different strategies.  We will need

1. BOUND - The limits of the data are known.  The minimum is offset to 0, the maximum scaled to 255.  Values outside the limits are set to 0 or 255.  The minimum and maximum must be specified.

2. DATA - The limits are set to the smallest and largest values in the array.  Pixels after scaling will span 0 t/m 255.

3. CENTER - The limits are set to +/- the largest absolute value in the array, putting 0 in the middle.  Unless the minimum is the opposite of the maximum, the pixel values will not cover the full 8-bit range.

4. CLIP - Do no scaling.  Values outside the 8-bit range are clipped to 0 or 255.

We'll use an enum to represent these cases.

    enum rgbconvert {



For the general framework, we'll want to support which plane to copy to the PNG.  If we pick one of C1, C2, or C3 then we'll populate the RGB planes with the scaled value from the source plane.  We'll also allow an ALL option which will put C1 in R, C2 in G, and C3 in B.  The enum to specify this is

    enum clrplane {

      C1, C2, C3, ALL


A record will store the complete conversion spec

    record conversion {

      var plane : clrplane;            /* which plane to copy to RGB */

      var how : rgbconvert;            /* how to scale clr to rgb */

      var min : real;                  /* lower limit of bound/clip range */

      var max : real;                  /* upper limit of bound/clip range */


The routine that starts the conversion is called display_color().  

    proc display_color(clr : clrimage, ref rgb : rgbimage,

                       spec : conversion) : c_int

For each plane that must be copied to RGB, it calls display_plane() with the source clrimage array and the destination rgbimage pointer.  

    proc display_plane(clr : [] real, rgb : c_ptr(u_char), ncol : int,

                       spec : conversion) : c_int

This prototype shows how we can pass arrays and, for the C interface, pointers. The image width is necessary, since it's not stored with the C array and we need it to calculate the xy index.  The domain for the Chapel array, however, is available through the array method clr.domain.  (We do not assume both images have the same size, but rgb must be the same size or larger of the two.)  The return values follow the error code convention of PNG_read() and PNG_write().

In other words, display_color() routes the source color plane to the output plane with display_plane().  Except if all planes are being mapped, it also needs to make a greyscale image by copying the result into the other two planes using a range over all the pixels in the RGB image.

    if ((clrplane.C1 == spec.plane) || (clrplane.ALL == spec.plane)) {

      retval = display_plane(clr.c1, rgb.r, rgb.ncol, spec);

      if (retval < 0) then return retval;


      if (clrplane.C1 == spec.plane) {

        for xy in 0..(rgb.npix-1) {

          rgb.g(xy) = rgb.r(xy);

          rgb.b(xy) = rgb.r(xy);


        return 0;



    /* Similarly for C2/G, C3/B. */

display_plane() uses a reduction to yield a single value from a set.  It has the form

    <operation> reduce <iterable>

<operation> is a small set of operators and functions: + and *; &, &&, |, and ||; min, max; and the extrema with their location, minloc and maxloc.  The reduce command is a concise way to find the smallest and largest color values for the DATA and CENTER scaling.

    minpix = min reduce clr;

    maxpix = max reduce clr;

If we were to write this out, it would like like

    var minpix = max(real);

    var maxpix = min(real);

    for (y, x) in area {

      if (clr(y,x) < minpix) then minpix = clr(y,x);

      if (maxpix < clr(y,x)) then maxpix = clr(y,x);


In this snippet we're using the min(<type>) and max(<type>) procedures defined in modules/standard/Types.chpl that return the limit values for a given type. The rest of the procedure calculates the scaled color value, then converts it to an uint(8).  The conversion is the same as in lab_v1, except for the CLIP case.

Compile and run the program with

    make lab_v2

    bin/lab_v2--inname=bobcat.png --outname=grey_bobcat.png

If you run both lab_v1 and lab_v2 you can see that the output image is the same.

Program Organization (color_convert.chpl)

Our final program re-organizes lab_v2.chpl, pulling out the color transformations and 8-bit conversions into a separate module (file), ip_color.chpl, and adding RGB-to-HSV and RGB-to-YUV converters.  The color_convert.chpl program uses this library to transform an input image into a new color space, choosing which plane to save as a greyscale image.

We don't need any new language features.  ip_color.chpl contains both the color conversion library as well as the C interface.  We may wish to separate these in the future, but we expect that every program that will use this library will want to do immediately convert its input into another color space, so it makes sense to leave them in the same module.  The library adds four new top-level functions, rgb_to_hsv(), rgb_to_yuv(), and rgb_to_rgb() which convert an 8-bit RGB image into the new spaces (the last function merely copies the pixels, changing them to reals), as well as a routing function rgb_convert().

color_convert also does nothing new.  It adds command-line configuration constants for the color space and plane.  In main() it transforms the color, sets up the conversion spec back to 8-bit, and generates and saves the image for the requested plane.

To build and run the color conversion

    make color_convert

    bin/color_convert --inname=<file> --outname=<file> --space=[LAB|HSV|YUV]


where the enumeration constants are given by name and default to LAB and C1.  For example,

    bin/color_convert --inname=bobcat.png --outname=bobcat_h.png --space=HSV

Aside: First-Class Functions (color_convert_v1b.chpl, ip_color_v1b.chpl)

The directory CHPL_HOME/doc/rst/technotes contains documentation for Chapel features that are works in progress and not yet ready to add to the language spec.  One of these is support for functions, both as a type and as something that can be assigned to variables.  You'll notice that all the rgb_to_* procedures in ip_color are the same except for the function call to convert a pixel.  We can modify rgb_convert() to show how first-class functions can simplify them.

Chapel places strong restrictions on the functions.  They cannot be generic, they cannot belong to a class or record, and they cannot be overloaded, among others.  They cannot refer to non-global variables, although arguments may be declared with an intent that allows them to be modified.  More importantly for us, their argument lists cannot take non-default intents.  Therefore we have to re-work all four conversion functions to return a triple:

    proc rgbpix_to_lab(r : u_char, g : u_char, b : u_char) : 3 * real {

      var l, l_a, l_b;

      /* convert normally */

      return (l, l_a, l_b);


The function type is formed with the func keyword and a list of the argument types in parentheses.  The final argument is the return type.  The prototype of our conversion function is

    var fn : func(u_char, u_char, u_char, 3 * real);

You can then assign a procedure name to this variable and then use the variable as the name when calling the procedure.  In rgb_convert(), we'll select the proper conversion routine, then do what all the rgb_to_* procedures did: create an instance of the destination image and loop through all the pixels, converting each in turn.  Like this

    proc rgb_convert(rgb : rgbimage, ref clr : clrimage,

                     space : clrspace) {

      var clrfn : func(c_uchar, c_uchar, c_uchar, 3 * real);

      var xy : int;


      select space {

        when clrspace.LAB do clrfn = rgbpix_to_lab;

        when clrspace.HSV do clrfn = rgbpix_to_hsv;

        when clrspace.YUV do clrfn = rgbpix_to_yuv;

        when clrspace.RGB do clrfn = rgbpix_to_rgb;

        otherwise halt(“unknown colorspace “ + space);



      clr = new clrimage(rgb.ncol, rgb.nrow);


      for (y, x) in clr.area {

        xy = (y * rgb.ncol) + x;

        (clr.c1(y,x), clr.c2(y,x), clr.c3(y,x)) =

            clrfn(rgb.r(xy), rgb.g(xy), rgb.b(xy));



The other edits to ip_color_v1b deleted the four rgb_to_* variants and added rgbpix_to_rgb().

To test this we've modified color_convert_v1b.chpl to use this library.  Compile it with:

    chpl -o bin/color_convert_v1b color_convert_v1b.chpl ip_color_v1b.chpl \

         img_png_v3.h build/img_png_v3.o -lpng

or  make color_convert_v1b

Run it the same way

    bin/color_convert_v1b --inname=bobcat.png --outname=bobcat_h.png --space=HSV

This is just an example of how you can pass around functions in variables.  It would be just as legitimate to move the select into the loop and call the right rgbpix_to_* function, although you would have to duplicate the argument list for each case.

Chapel also supports unnamed functions called lambdas.  You create them when you assign them to a variable.  The lambda can take an argument list and is followed by a block of statements.

    var fn = lambda(x : int, y : int, ncol : int) { return ((y * ncol) + x); }

A lambda is evaluated as an expression.


In this chapter we've completed our survey of the basic language, including operators and statements and two additional data structures, enumerations and tuples.  We've also seen how Chapel represents domains, the set of indices that back arrays, and the range specifications that generate them.  We've learned:

Now that we can generate greyscale images from our color, we can start doing some image processing.  Since array operations are a key concern for the language, let's look at convolutions, a.k.a. matrix multiplication, next.


1. Add the inverse conversions from the LAB, YUV, and HSV spaces back to RGB.  You can find the formulas on the web.  You'll find our version in ip_color_v3.chpl, used from the k-Means Clustering chapter onward.

2. Confirm the values of LAB_AMIN, _AMAX, _BMIN, and _BMAX.  Because we have a limited 8-bit RGB space, we can exhaustively run all RGB combinations and find the actual limits.  You'll find our approach in lab_limits.chpl in the Parallel Programming chapter.


A tarball with the programs and image for this chapter is available here.  A zip file is here.

A PDF copy of this text is here.