pano, panorama, projection, projection-mapping



Packagist CircleCI branch Coveralls Scrutinizer Code Climate

Don't believe the coverage, it's lying.

This is a simple library created for common work with sphere projections - at the moment of writing, to convert equirectangular and cube map projection types one into another.


composer require ama-team/image-projection-framework

PHP 5.6+ and GD / Imagick are required. All file operations are done through league/flysystem, which is explicitly set as dependency.


Sixty-second start

use \AmaTeam\Image\Projection\Framework;
use \AmaTeam\Image\Projection\Framework\EncodingOptions;
use \AmaTeam\Image\Projection\Specification;
use \AmaTeam\Image\Projection\Image\Format;

$framework = new Framework();
$source = new Specification('equirect', 'tmp/uploads/source.jpg');
$target = new Specification(
    new Box(512, 512) // tile size,
    new Box(2, 2) // layout, amount of tiles horizontally and vertically

$options = (new EncodingOptions())->setQuality(0.9);

$framework->convert($source, $target, Format::JPEG, $options);

You'll need a Framework instance to start off, it will use cwd() as fs root. Next, Framework has #convert() and #convertAll() methods to turn one specification into other (others), optionally specifying format and encoding options (don't worry, it's already jpeg / 90% by default). Specification is basically a description of a pano: it's type, it's location, it's face size and tile size. Location allows several placeholders - {f} or {face}, {x} and {y} to automatically recognize / populate it with tile parameters. You won't need them at all for equirectangular images, while faces for cubemap are named using their first letters - u(p), f(ront), b(ack), l(eft), r(ight), d(own). Type is found using case-insensitive prefix search, so EQUIRECTANGULAR and equirect is basically the same.

Chances are you will be creating multi-resolution cube map out of equirectangular projection, and to lessen resource consumption, source projection should be read just once - this can be easily done using #convertAll(), which uses single reader:

$targets = array_map(function ($index) {
    $side = 256 * (int) pow($index, 2);
    $tileSide = min($side, 512);
    $size = $side / $side;
    $tileSize = new Box($tileSide, $tileSide);
    $layout = new Box($size, $size);
    $path = sprintf('static/pano/%d/{f}/{x}/{y}.jpg', $index);
    return new Specification('cube', $path, $tileSize, $layout);
}, range(0, 3));

$framework->convertAll($source, $targets);

This should do the trick.

The last thing to tell: it is extremely slow. The current processing model fills target projection pixel-by-pixel, so it takes a while (up to minute on my laptop, cheap servers may be quite slower). And yes, it will burn a single core as much as it can. Also don't forget that source projection has to fit in memory, don't blame me for your images being too big.


Projections are represented as a three-level deep tile structures. Every projection consists of arbitrary amount of faces (six for cube map, one for equirectangular and planet), and each face is a two-dimension grid of tiles. Tile position, which is determined as {face, x, y}, is expressed through curly-braces placeholders, as in the main usage example. All projections follow that rule (even though equirectangular is unlikely to have multiple tiles), but that may change in future releases.

Framework assumes that all tiles have the exact sizes. Not following that rule may result in undefined behavior and errors.

Flysystem usage assumes that all paths are specified using slash (not backslash), and there are no absolute paths at all, all paths are relative to root.


This library processes images by creating virtual sphere accessor from source projection and then using it to populate target projection, texel by texel. This raises following problems:

  • To complete target projection, source projection has to be fully loaded into RAM. This could be quite a lot for big projections, and the library itself doesn't do any size validations, so you have to watch RAM yourself. Also, image loading adds time penalty itself.
  • The main workload is giant loop that asks for color of specific sphere coordinates and then populates target image with that color. This results in {number of pixels} function calls, and that number is usually huge even for modern processor - generating 2048x2048 cube map will need to populate 24M pixels, and all that work would be done on single CPU core, and there is not much overhead to optimize. It is slow as hell and not much can be done. Generating such a projection for two minutes is OK, having one core occupied at 100% is expected as well. All you can do is to parallelize work on several cores by filtering which tiles should be generated; this, however, will require to load source projection into RAM for every core.

If you want to convert projections faster or exploit GPU, you will need to implement the same thing yourself, most probably using other language. Image processing was always a complex thing and PHP is not enough to solve it efficiently.

Filtering generated tiles, adding processors and listeners

All the specified above needs more fine work and other methods. Framework instance provides method getConverter() that allows more detailed work using methods createConversion() and createConversions(). Those will create an object ready to perform conversion, but not yet launched (which is done through #run() method), and which expose additional processing capabilities.

If you've considered parallelizing work by scheduling different tiles to different workers, you'll need to restrict some tiles from being created on particular worker. Filter functionality exists just for this case:

// only specified faces will be created
$filter = new FaceFilter('f', 'b');
$options = (new ConversionOptions())->setFilters([$filter]);
    ->createConversion($source, $target, $options)

If you need to add some kind of watermark or apply custom antialiasing, you can do it via processor which is run on tile after it's generation:

$fxaaProcessor = new FXAAProcessor();
$watermarkProcessor = new WatermarkProcessor();
$conversion = $framework
    ->createConversion($source, $target);
    // 50 is order in which processor will run, so it will run before
    // watermark processor
    ->addProcessor(50, $fxaaProcessor)
    ->addProcessor(99, $watermarkProcessor);

Last thing to mention is listeners - those are simply some side-effect generators that accept fully generated tiles. The most common example is SaveListener that is not included in conversion by default:

$filesystem = $framework->getRegistry()->getFilesystem();
$listener = new SaveListener($filesystem, Format::JPEG);
$conversion = $framework
    ->createConversion($source, $target)

This is what actually default methods in sixty second example do.

Adding custom projection type

All projections are quite the same: they are bunch of images with some rules of mapping sphere on it and vice versa. All you actually need is to implement MappingInterface, bury it in AbstractHandler child and register in framework:

$framework = new Framework();
$framework->register('LittlePlanet', new LittlePlanetHandler()); 

You're ready to go.


Feel free to fork and send PR to dev branch.


Testing is done using Codeception, and everything, except for directory structure and installable fixtures, is quite the usual.

To install the fixtures, simply run test:setup task:

bin/robo test:setup

This will download some public license test images from flickr and put them into tests/Data/External/Projections, as well as cur them into faces.

You can run tests by using test and test:<suite> commands:

bin/robo test
bin/robo test:acceptance
bin/robo test:unit --coverage

Acceptance tests are doing some real work mangling millions of texels, so you should never ever turn on coverage for them unless you are on Intel Stress Test team. They are already slow enough, believe me.

Following command will generate coverage and Allure reports:

bin/robo test:report

Dev branch state

CircleCI Coveralls Scrutinizer


MIT License

AMA Team, 2017