{ "cells": [ { "cell_type": "markdown", "id": "a4cdce42", "metadata": {}, "source": [ "# NAME\n", "\n", "InferenceUsingTFHubMobileNetV2Model - Using TensorFlow to do image classification using a pre-trained model" ] }, { "cell_type": "markdown", "id": "d74d6520", "metadata": {}, "source": [ "# SYNOPSIS\n", "\n", "The following tutorial is based on the [Image Classification with TensorFlow Hub notebook](https://github.com/tensorflow/docs/blob/master/site/en/hub/tutorials/image_classification.ipynb). It uses a pre-trained model based on the *MobileNet V2* architecture trained on the *Imagenet* dataset. Running the code requires an Internet connection to download the model (from Google servers) and testing data (from Wikimedia servers).\n", "\n", "Please look at the [SECURITY note](https://github.com/tensorflow/tensorflow/blob/master/SECURITY.md) regarding running models as models are programs. You can also used `saved_model_cli scan` to check for [security-sensitive \"denylisted ops\"](https://github.com/tensorflow/tensorflow/pull/17529) which by default are operations that do I/O.\n", "\n", "If you would like to visualise a model, you can use [Netron](https://github.com/lutzroeder/netron) on the `.pb` file.\n", "\n", "# COLOPHON\n", "\n", "The following document is either a POD file which can additionally be run as a Perl script or a Jupyter Notebook which can be run in [IPerl](https://p3rl.org/Devel::IPerl) (viewable online at [nbviewer](https://nbviewer.org/github/EntropyOrg/perl-AI-TensorFlow-Libtensorflow/blob/master/notebook/InferenceUsingTFHubMobileNetV2Model.ipynb)). If you are reading this as POD, there should be a generated list of Perl dependencies in the [CPANFILE](#CPANFILE) section.\n", "\n", "If you are running the code, you may optionally install the [`tensorflow` Python package](https://www.tensorflow.org/install/pip) in order to access the `saved_model_cli` command, but this is only used for informational purposes." ] }, { "cell_type": "markdown", "id": "bb098c5b", "metadata": {}, "source": [ "# TUTORIAL\n", "\n", "## Load the library\n", "\n", "First, we need to load the `AI::TensorFlow::Libtensorflow` library and more helpers. We then create an `AI::TensorFlow::Libtensorflow::Status` object and helper function to make sure that the calls to the `libtensorflow` C library are working properly." ] }, { "cell_type": "code", "execution_count": 1, "id": "81eaec3a", "metadata": {}, "outputs": [], "source": [ "use strict;\n", "use warnings;\n", "use utf8;\n", "use constant IN_IPERL => !! $ENV{PERL_IPERL_RUNNING};\n", "no if IN_IPERL, warnings => 'redefine'; # fewer messages when re-running cells\n", "\n", "use feature qw(say state);\n", "use Syntax::Construct qw(each-array);\n", "\n", "use lib::projectroot qw(lib);\n", "\n", "BEGIN {\n", " if( IN_IPERL ) {\n", " $ENV{TF_CPP_MIN_LOG_LEVEL} = 3;\n", " }\n", " require AI::TensorFlow::Libtensorflow;\n", "}\n", "\n", "use URI ();\n", "use HTTP::Tiny ();\n", "use Path::Tiny qw(path);\n", "\n", "use File::Which ();\n", "\n", "use List::Util ();\n", "\n", "use Data::Printer ( output => 'stderr', return_value => 'void', filters => ['PDL'] );\n", "use Data::Printer::Filter::PDL ();\n", "use Text::Table::Tiny qw(generate_table);\n", "\n", "use Imager;\n", "\n", "my $s = AI::TensorFlow::Libtensorflow::Status->New;\n", "sub AssertOK {\n", " die \"Status $_[0]: \" . $_[0]->Message\n", " unless $_[0]->GetCode == AI::TensorFlow::Libtensorflow::Status::OK;\n", " return;\n", "}\n", "AssertOK($s);" ] }, { "cell_type": "markdown", "id": "b99b9097", "metadata": {}, "source": [ "In this notebook, we will use `PDL` to store and manipulate the ndarray data before converting it to a `TFTensor`. The following functions help with copying the data back and forth between the two object types.\n", "\n", "An important thing to note about the dimensions used by TensorFlow's TFTensors when compared with PDL is that the dimension lists are reversed. Consider a binary raster image with width W and height H stored in [row-major format](https://en.wikipedia.org/wiki/Row-_and_column-major_order) (meaning the pixels in the first row are stored next to each other followed by the second row and so on). With PDL, the dimension list for this will be `[ W H ]` and for TFTensor the dimension list will be `[ H W ]`. TensorFlow uses the same convention for the dimension list as NumPy with the faster changing dimensions at the end of the dimension list while PDL is the opposite (see [Dima Kogan's library and talk](https://github.com/dkogan/numpysane) for more on this).\n", "\n", "This difference will be explained more concretely further in the tutorial.\n", "\n", "Future work will provide an API for more convenient wrappers which will provide an option to either copy or share the same data (if possible)." ] }, { "cell_type": "code", "execution_count": 2, "id": "3d52ab35", "metadata": {}, "outputs": [], "source": [ "use PDL;\n", "use AI::TensorFlow::Libtensorflow::DataType qw(FLOAT);\n", "\n", "use FFI::Platypus::Memory qw(memcpy);\n", "use FFI::Platypus::Buffer qw(scalar_to_pointer);\n", "\n", "sub FloatPDLTOTFTensor {\n", " my ($p) = @_;\n", " return AI::TensorFlow::Libtensorflow::Tensor->New(\n", " FLOAT, [ reverse $p->dims ], $p->get_dataref, sub { undef $p }\n", " );\n", "}\n", "\n", "sub FloatTFTensorToPDL {\n", " my ($t) = @_;\n", "\n", " my $pdl = zeros(float,reverse( map $t->Dim($_), 0..$t->NumDims-1 ) );\n", "\n", " memcpy scalar_to_pointer( ${$pdl->get_dataref} ),\n", " scalar_to_pointer( ${$t->Data} ),\n", " $t->ByteSize;\n", " $pdl->upd_data;\n", "\n", " $pdl;\n", "}" ] }, { "cell_type": "markdown", "id": "8c61da2f", "metadata": {}, "source": [ "The following is just a small helper to generate an HTML `` for output in `IPerl`." ] }, { "cell_type": "code", "execution_count": 3, "id": "1f4a4673", "metadata": {}, "outputs": [], "source": [ "use HTML::Tiny;\n", "\n", "sub my_table {\n", " my ($data, $cb) = @_;\n", " my $h = HTML::Tiny->new;\n", " $h->table( { style => 'width: 100%' },\n", " [\n", " $h->tr(\n", " map {\n", " [\n", " $h->td( $cb->($_, $h) )\n", " ]\n", " } @$data\n", " )\n", " ]\n", " )\n", "}" ] }, { "cell_type": "markdown", "id": "926aa0af", "metadata": {}, "source": [ "This is a helper to display images in Gnuplot for debugging, but those debugging lines are commented out." ] }, { "cell_type": "code", "execution_count": 4, "id": "57b96611", "metadata": {}, "outputs": [], "source": [ "sub show_in_gnuplot {\n", " my ($p) = @_;\n", " require PDL::Graphics::Gnuplot;\n", " PDL::Graphics::Gnuplot::image( square => 1, $p );\n", "}" ] }, { "cell_type": "markdown", "id": "e6eb3d2d", "metadata": {}, "source": [ "## Fetch the model and labels\n", "\n", "We are going to use an [image classification model](https://tfhub.dev/google/imagenet/mobilenet_v2_100_224/classification/5) from TensorFlow Hub based on the MobileNet V2 architecture. We download both the model and ImageNet classification labels." ] }, { "cell_type": "code", "execution_count": 5, "id": "4f6194a1", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Selected model: mobilenet_v2_100_224 : https://tfhub.dev/google/imagenet/mobilenet_v2_100_224/classification/5\n" ] }, { "data": { "text/plain": [ "1" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# image_size => [width, height] (but usually square images)\n", "my %model_name_to_params = (\n", " mobilenet_v2_100_224 => {\n", " handle => 'https://tfhub.dev/google/imagenet/mobilenet_v2_100_224/classification/5',\n", " image_size => [ 224, 224 ],\n", " },\n", " mobilenet_v2_140_224 => {\n", " handle => \"https://tfhub.dev/google/imagenet/mobilenet_v2_140_224/classification/5\",\n", " image_size => [ 224, 224 ],\n", " },\n", ");\n", "\n", "my $model_name = 'mobilenet_v2_100_224';\n", "\n", "say \"Selected model: $model_name : $model_name_to_params{$model_name}{handle}\";" ] }, { "cell_type": "markdown", "id": "cf912979", "metadata": {}, "source": [ "We download the model and labels to the current directory then extract the model to a folder with the name given in `$model_base`." ] }, { "cell_type": "code", "execution_count": 6, "id": "94cae10a", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Downloading https://tfhub.dev/google/imagenet/mobilenet_v2_100_224/classification/5?tf-hub-format=compressed to google_imagenet_mobilenet_v2_100_224_classification_5.tar.gz\n", "Downloading https://storage.googleapis.com/download.tensorflow.org/data/ImageNetLabels.txt to ImageNetLabels.txt\n", "Saved model is in google_imagenet_mobilenet_v2_100_224_classification_5/saved_model.pb\n", "Got labels: background, tench, goldfish, great white shark, tiger shark, etc.\n" ] }, { "data": { "text/plain": [ "1" ] }, "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "my $model_uri = URI->new( $model_name_to_params{$model_name}{handle} );\n", "$model_uri->query_form( 'tf-hub-format' => 'compressed' );\n", "my $model_base = substr( $model_uri->path, 1 ) =~ s,/,_,gr;\n", "my $model_archive_path = \"${model_base}.tar.gz\";\n", "\n", "use constant IMAGENET_LABEL_COUNT_WITH_BG => 1001;\n", "my $labels_uri = URI->new('https://storage.googleapis.com/download.tensorflow.org/data/ImageNetLabels.txt');\n", "my $labels_path = ($labels_uri->path_segments)[-1];\n", "\n", "my $http = HTTP::Tiny->new;\n", "\n", "for my $download ( [ $model_uri => $model_archive_path ],\n", " [ $labels_uri => $labels_path ]) {\n", " my ($uri, $path) = @$download;\n", " say \"Downloading $uri to $path\";\n", " next if -e $path;\n", " $http->mirror( $uri, $path );\n", "}\n", "\n", "use Archive::Extract;\n", "my $ae = Archive::Extract->new( archive => $model_archive_path );\n", "die \"Could not extract archive\" unless $ae->extract( to => $model_base );\n", "\n", "my $saved_model = path($model_base)->child('saved_model.pb');\n", "say \"Saved model is in $saved_model\" if -f $saved_model;\n", "\n", "my @labels = path($labels_path)->lines( { chomp => 1 });\n", "die \"Labels should have @{[ IMAGENET_LABEL_COUNT_WITH_BG ]} items\"\n", " unless @labels == IMAGENET_LABEL_COUNT_WITH_BG;\n", "say \"Got labels: \", join( \", \", List::Util::head(5, @labels) ), \", etc.\";" ] }, { "cell_type": "markdown", "id": "08f41a54", "metadata": {}, "source": [ "## Load the model and session\n", "\n", "We define the tag set `[ 'serve' ]` which we will use to load the model." ] }, { "cell_type": "code", "execution_count": 7, "id": "5e1e3e03", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "serve" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "my @tags = ( 'serve' );" ] }, { "cell_type": "markdown", "id": "57eac4d7", "metadata": {}, "source": [ "We can examine what computations are contained in the graph in terms of the names of the inputs and outputs of an operation found in the graph by running `saved_model_cli`." ] }, { "cell_type": "code", "execution_count": 8, "id": "e39a4af1", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "The given SavedModel SignatureDef contains the following input(s):\n", " inputs['inputs'] tensor_info:\n", " dtype: DT_FLOAT\n", " shape: (-1, 224, 224, 3)\n", " name: serving_default_inputs:0\n", "The given SavedModel SignatureDef contains the following output(s):\n", " outputs['logits'] tensor_info:\n", " dtype: DT_FLOAT\n", " shape: (-1, 1001)\n", " name: StatefulPartitionedCall:0\n", "Method name is: tensorflow/serving/predict\n" ] }, { "data": { "text/plain": [ "1" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "if( File::Which::which('saved_model_cli')) {\n", " local $ENV{TF_CPP_MIN_LOG_LEVEL} = 3; # quiet the TensorFlow logger for the following command\n", " system(qw(saved_model_cli show),\n", " qw(--dir) => $model_base,\n", " qw(--tag_set) => join(',', @tags),\n", " qw(--signature_def) => 'serving_default'\n", " ) == 0 or die \"Could not run saved_model_cli\";\n", "} else {\n", " say \"Install the tensorflow Python package to get the `saved_model_cli` command.\";\n", "}" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The above `saved_model_cli` output shows that the model input is at `serving_default_inputs:0` which means the operation named `serving_default_inputs` at index `0` and the output is at `StatefulPartitionedCall:0` which means the operation named `StatefulPartitionedCall` at index `0`.\n", "\n", "It also shows the type and shape of the `TFTensor`s for those inputs and outputs. Together this is known as a signature.\n", "\n", "For the `input`, we have `(-1, 224, 224, 3)` which is a [common input image specification for TensorFlow Hub](https://www.tensorflow.org/hub/common_signatures/images#input). This is known as `channels_last` (or `NHWC`) layout where the TensorFlow dimension list is `[batch_size, height, width, 3]` where 3 represents the RGB channels where each element is normalised to the range `[0, 1]`. The `-1` in the `batch_size` dimension represents an unknown dimension size so the model can accept any number of images. Note that the `TFTensor` dimension list has the dimension that changes the fastest in memory at the end of the list, so the `float32_t` channels for a single pixel as stored next to each other followed by the next pixel in the same row.\n", "\n", "For the `output`, we have `(-1, 1001)` which is `[batch_size, num_classes]` where the elements are scores that the image received for that ImageNet class." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now we can load the model from that folder with the tag set `[ 'serve' ]` by using the `LoadFromSavedModel` constructor to create a `::Graph` and a `::Session` for that graph." ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [], "source": [ "my $opt = AI::TensorFlow::Libtensorflow::SessionOptions->New;\n", "\n", "my $graph = AI::TensorFlow::Libtensorflow::Graph->New;\n", "my $session = AI::TensorFlow::Libtensorflow::Session->LoadFromSavedModel(\n", " $opt, undef, $model_base, \\@tags, $graph, undef, $s\n", ");\n", "AssertOK($s);" ] }, { "cell_type": "markdown", "id": "4a3b4151", "metadata": {}, "source": [ "So let's use the names from the `saved_model_cli` output to create our `::Output` `ArrayRef`s." ] }, { "cell_type": "code", "execution_count": 10, "id": "87e63b30", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Input: serving_default_inputs:0\n", "Output: StatefulPartitionedCall:0\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ "\u001b[0;38;5;81m{\u001b[m\n", " \u001b[0;38;5;104min\u001b[m \u001b[0;38;5;81m \u001b[m\u001b[0;38;5;81m[\u001b[m\n", " \u001b[0;38;5;146m[0] \u001b[m\u001b[0;38;5;176mAI::TensorFlow::Libtensorflow::Output\u001b[m \u001b[0;38;5;81m{\u001b[m\n", " \u001b[0;38;5;104mindex\u001b[m\u001b[0;38;5;81m \u001b[m\u001b[0;38;5;209m0\u001b[m\u001b[0;38;5;81m,\u001b[m\n", " \u001b[0;38;5;104moper\u001b[m \u001b[0;38;5;81m \u001b[m\u001b[0;38;5;176mAI::TensorFlow::Libtensorflow::Operation\u001b[m \u001b[0;38;5;81m{\u001b[m\n", " \u001b[0;38;5;104mName\u001b[m \u001b[0;38;5;81m \u001b[m\u001b[0;38;5;81m\"\u001b[m\u001b[0;38;5;107mserving_default_inputs\u001b[m\u001b[0;38;5;81m\"\u001b[m\u001b[0;38;5;81m,\u001b[m\n", " \u001b[0;38;5;104mNumInputs\u001b[m \u001b[0;38;5;81m \u001b[m\u001b[0;38;5;209m0\u001b[m\u001b[0;38;5;81m,\u001b[m\n", " \u001b[0;38;5;104mNumOutputs\u001b[m\u001b[0;38;5;81m \u001b[m\u001b[0;38;5;209m1\u001b[m\u001b[0;38;5;81m,\u001b[m\n", " \u001b[0;38;5;104mOpType\u001b[m \u001b[0;38;5;81m \u001b[m\u001b[0;38;5;81m\"\u001b[m\u001b[0;38;5;107mPlaceholder\u001b[m\u001b[0;38;5;81m\"\u001b[m\n", " \u001b[0;38;5;81m}\u001b[m\n", " \u001b[0;38;5;81m}\u001b[m\n", " \u001b[0;38;5;81m]\u001b[m\u001b[0;38;5;81m,\u001b[m\n", " \u001b[0;38;5;104mout\u001b[m\u001b[0;38;5;81m \u001b[m\u001b[0;38;5;81m[\u001b[m\n", " \u001b[0;38;5;146m[0] \u001b[m\u001b[0;38;5;176mAI::TensorFlow::Libtensorflow::Output\u001b[m \u001b[0;38;5;81m{\u001b[m\n", " \u001b[0;38;5;104mindex\u001b[m\u001b[0;38;5;81m \u001b[m\u001b[0;38;5;209m0\u001b[m\u001b[0;38;5;81m,\u001b[m\n", " \u001b[0;38;5;104moper\u001b[m \u001b[0;38;5;81m \u001b[m\u001b[0;38;5;176mAI::TensorFlow::Libtensorflow::Operation\u001b[m \u001b[0;38;5;81m{\u001b[m\n", " \u001b[0;38;5;104mName\u001b[m \u001b[0;38;5;81m \u001b[m\u001b[0;38;5;81m\"\u001b[m\u001b[0;38;5;107mStatefulPartitionedCall\u001b[m\u001b[0;38;5;81m\"\u001b[m\u001b[0;38;5;81m,\u001b[m\n", " \u001b[0;38;5;104mNumInputs\u001b[m \u001b[0;38;5;81m \u001b[m\u001b[0;38;5;209m263\u001b[m\u001b[0;38;5;81m,\u001b[m\n", " \u001b[0;38;5;104mNumOutputs\u001b[m\u001b[0;38;5;81m \u001b[m\u001b[0;38;5;209m1\u001b[m\u001b[0;38;5;81m,\u001b[m\n", " \u001b[0;38;5;104mOpType\u001b[m \u001b[0;38;5;81m \u001b[m\u001b[0;38;5;81m\"\u001b[m\u001b[0;38;5;107mStatefulPartitionedCall\u001b[m\u001b[0;38;5;81m\"\u001b[m\n", " \u001b[0;38;5;81m}\u001b[m\n", " \u001b[0;38;5;81m}\u001b[m\n", " \u001b[0;38;5;81m]\u001b[m\n", "\u001b[0;38;5;81m}\u001b[m\n" ] }, { "data": { "text/plain": [ "1" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "my %ops = (\n", " in => $graph->OperationByName('serving_default_inputs'),\n", " out => $graph->OperationByName('StatefulPartitionedCall'),\n", ");\n", "\n", "die \"Could not get all operations\" unless List::Util::all(sub { defined }, values %ops);\n", "\n", "my %outputs = map { $_ => [ AI::TensorFlow::Libtensorflow::Output->New( { oper => $ops{$_}, index => 0 } ) ] }\n", " keys %ops;\n", "\n", "p %outputs;\n", "\n", "say \"Input: \" , $outputs{in}[0];\n", "say \"Output: \", $outputs{out}[0];" ] }, { "cell_type": "markdown", "id": "e28fbf5b", "metadata": {}, "source": [ "Now we can get the following testing images from Wikimedia." ] }, { "cell_type": "code", "execution_count": 11, "id": "fa18a474", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "
apple\"apple\"
banana\"banana\"
bus\"bus\"
car\"car\"
cat\"cat\"
dog\"dog\"
flamingo\"flamingo\"
honeycomb\"honeycomb\"
piano\"piano\"
teapot\"teapot\"
tiger\"tiger\"
turtle\"turtle\"
\n" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "my %images_for_test_to_uri = (\n", " \"tiger\" => \"https://upload.wikimedia.org/wikipedia/commons/b/b0/Bengal_tiger_%28Panthera_tigris_tigris%29_female_3_crop.jpg\",\n", " #by Charles James Sharp, CC BY-SA 4.0 , via Wikimedia Commons\n", " \"bus\" => \"https://upload.wikimedia.org/wikipedia/commons/6/63/LT_471_%28LTZ_1471%29_Arriva_London_New_Routemaster_%2819522859218%29.jpg\",\n", " #by Martin49 from London, England, CC BY 2.0 , via Wikimedia Commons\n", " \"car\" => \"https://upload.wikimedia.org/wikipedia/commons/4/49/2013-2016_Toyota_Corolla_%28ZRE172R%29_SX_sedan_%282018-09-17%29_01.jpg\",\n", " #by EurovisionNim, CC BY-SA 4.0 , via Wikimedia Commons\n", " \"cat\" => \"https://upload.wikimedia.org/wikipedia/commons/4/4d/Cat_November_2010-1a.jpg\",\n", " #by Alvesgaspar, CC BY-SA 3.0 , via Wikimedia Commons\n", " \"dog\" => \"https://upload.wikimedia.org/wikipedia/commons/archive/a/a9/20090914031557%21Saluki_dog_breed.jpg\",\n", " #by Craig Pemberton, CC BY-SA 3.0 , via Wikimedia Commons\n", " \"apple\" => \"https://upload.wikimedia.org/wikipedia/commons/1/15/Red_Apple.jpg\",\n", " #by Abhijit Tembhekar from Mumbai, India, CC BY 2.0 , via Wikimedia Commons\n", " \"banana\" => \"https://upload.wikimedia.org/wikipedia/commons/1/1c/Bananas_white_background.jpg\",\n", " #by fir0002 flagstaffotos [at] gmail.com\t\tCanon 20D + Tamron 28-75mm f/2.8, GFDL 1.2 , via Wikimedia Commons\n", " \"turtle\" => \"https://upload.wikimedia.org/wikipedia/commons/8/80/Turtle_golfina_escobilla_oaxaca_mexico_claudio_giovenzana_2010.jpg\",\n", " #by Claudio Giovenzana, CC BY-SA 3.0 , via Wikimedia Commons\n", " \"flamingo\" => \"https://upload.wikimedia.org/wikipedia/commons/b/b8/James_Flamingos_MC.jpg\",\n", " #by Christian Mehlführer, User:Chmehl, CC BY 3.0 , via Wikimedia Commons\n", " \"piano\" => \"https://upload.wikimedia.org/wikipedia/commons/d/da/Steinway_%26_Sons_upright_piano%2C_model_K-132%2C_manufactured_at_Steinway%27s_factory_in_Hamburg%2C_Germany.png\",\n", " #by \"Photo: © Copyright Steinway & Sons\", CC BY-SA 3.0 , via Wikimedia Commons\n", " \"honeycomb\" => \"https://upload.wikimedia.org/wikipedia/commons/f/f7/Honey_comb.jpg\",\n", " #by Merdal, CC BY-SA 3.0 , via Wikimedia Commons\n", " \"teapot\" => \"https://upload.wikimedia.org/wikipedia/commons/4/44/Black_tea_pot_cropped.jpg\",\n", " #by Mendhak, CC BY-SA 2.0 , via Wikimedia Commons\n", ");\n", "\n", "my @image_names = sort keys %images_for_test_to_uri;\n", "\n", "\n", "if( IN_IPERL ) {\n", " IPerl->html(\n", " my_table( \\@image_names, sub {\n", " my ($image_name, $h) = @_;\n", " (\n", " $h->tt($image_name),\n", " $h->a( { href => $images_for_test_to_uri{$image_name} },\n", " $h->img({\n", " src => $images_for_test_to_uri{$image_name},\n", " alt => $image_name,\n", " width => '50%',\n", " })\n", " ),\n", " )\n", " })\n", " );\n", "}" ] }, { "cell_type": "markdown", "id": "853815b0", "metadata": {}, "source": [ "## Download the test images and transform them into suitable input data\n", "\n", "We now fetch these images and prepare them to be the in the needed format by using `Imager` to resize and add padding. Then we turn the `Imager` data into a `PDL` ndarray. Since the `Imager` data is stored as 32-bits with 4 channels in the order `ARGB`, we create an `uint32_t` PDL ndarray and use bit manipulation to create a `uint8_t` ndarray (which gives a PDL dimension list that starts with 3 for the RGB channels). Then we create a `float32_t` ndarray by normalising the values from `[0, 1]` as the model specifies.\n", "\n", "We then take all the PDL ndarrays and concatenate them. Again, note that the dimension lists for the PDL ndarray and the TFTensor are reversed." ] }, { "cell_type": "code", "execution_count": 12, "id": "bdcf4f61", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Downloaded https://upload.wikimedia.org/wikipedia/commons/1/15/Red_Apple.jpg\n", "Rescaled image from [ 2418 x 2192 ] to [ 224 x 203 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/1/1c/Bananas_white_background.jpg\n", "Rescaled image from [ 1600 x 1067 ] to [ 224 x 149 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/6/63/LT_471_%28LTZ_1471%29_Arriva_London_New_Routemaster_%2819522859218%29.jpg\n", "Rescaled image from [ 3840 x 2560 ] to [ 224 x 149 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/4/49/2013-2016_Toyota_Corolla_%28ZRE172R%29_SX_sedan_%282018-09-17%29_01.jpg\n", "Rescaled image from [ 4152 x 2252 ] to [ 224 x 121 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/4/4d/Cat_November_2010-1a.jpg\n", "Rescaled image from [ 1795 x 2397 ] to [ 168 x 224 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/archive/a/a9/20090914031557%21Saluki_dog_breed.jpg\n", "Rescaled image from [ 543 x 523 ] to [ 224 x 216 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/b/b8/James_Flamingos_MC.jpg\n", "Rescaled image from [ 3000 x 1999 ] to [ 224 x 149 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/f/f7/Honey_comb.jpg\n", "Rescaled image from [ 800 x 600 ] to [ 224 x 168 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/d/da/Steinway_%26_Sons_upright_piano%2C_model_K-132%2C_manufactured_at_Steinway%27s_factory_in_Hamburg%2C_Germany.png\n", "Rescaled image from [ 2059 x 2080 ] to [ 222 x 224 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/4/44/Black_tea_pot_cropped.jpg\n", "Rescaled image from [ 900 x 838 ] to [ 224 x 209 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/b/b0/Bengal_tiger_%28Panthera_tigris_tigris%29_female_3_crop.jpg\n", "Rescaled image from [ 4500 x 3000 ] to [ 224 x 149 ]\n", "Padded to [ 224 x 224 ]\n", "Downloaded https://upload.wikimedia.org/wikipedia/commons/8/80/Turtle_golfina_escobilla_oaxaca_mexico_claudio_giovenzana_2010.jpg\n", "Rescaled image from [ 2000 x 1329 ] to [ 224 x 149 ]\n", "Padded to [ 224 x 224 ]\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ "\u001b[0;38;5;176mPDL\u001b[m\u001b[0;38;5;81m {\u001b[m\n", " \u001b[0;38;5;104mData \u001b[m : \u001b[0;38;5;107mtoo long to print\u001b[m\n", " \u001b[0;38;5;104mType \u001b[m : \u001b[0;38;5;176mfloat\u001b[m\n", " \u001b[0;38;5;104mShape \u001b[m : \u001b[0;38;5;81m[\u001b[m\u001b[0;38;5;146m3 224 224 12\u001b[m\u001b[0;38;5;81m]\u001b[m\n", " \u001b[0;38;5;104mNelem \u001b[m : \u001b[93m1806336\u001b[m\n", " \u001b[0;38;5;104mMin \u001b[m : \u001b[91m0\u001b[m\n", " \u001b[0;38;5;104mMax \u001b[m : \u001b[94m1\u001b[m\n", " \u001b[0;38;5;104mBadflag \u001b[m : \u001b[32mNo\u001b[m\n", " \u001b[0;38;5;104mHas Bads\u001b[m : \u001b[32mNo\u001b[m\n", "\u001b[0;38;5;81m}\u001b[m\n", "\u001b[0;38;5;176mAI::TensorFlow::Libtensorflow::Tensor\u001b[m \u001b[0;38;5;81m{\u001b[m\n", " \u001b[0;38;5;104mType \u001b[m \u001b[0;38;5;176mFLOAT\u001b[m\n", " \u001b[0;38;5;104mDims \u001b[m \u001b[0;38;5;81m[\u001b[m \u001b[0;38;5;209m12\u001b[m \u001b[0;38;5;209m224\u001b[m \u001b[0;38;5;209m224\u001b[m \u001b[0;38;5;209m3\u001b[m \u001b[0;38;5;81m]\u001b[m\n", " \u001b[0;38;5;104mNumDims \u001b[m \u001b[0;38;5;209m4\u001b[m\n", " \u001b[0;38;5;104mElementCount \u001b[m \u001b[0;38;5;209m1806336\u001b[m\n", "\u001b[0;38;5;81m}\u001b[m\n" ] } ], "source": [ "sub imager_paste_center_pad {\n", " my ($inner, $padded_sz, @rest) = @_;\n", "\n", " my $outer = Imager->new( List::Util::mesh( [qw(xsize ysize)], $padded_sz ),\n", " @rest\n", " );\n", "\n", " $outer->paste(\n", " left => int( ($outer->getwidth - $inner->getwidth ) / 2 ),\n", " top => int( ($outer->getheight - $inner->getheight) / 2 ),\n", " src => $inner,\n", " );\n", "\n", " $outer;\n", "}\n", "\n", "sub imager_scale_to {\n", " my ($img, $image_size) = @_;\n", " my $rescaled = $img->scale(\n", " List::Util::mesh( [qw(xpixels ypixels)], $image_size ),\n", " type => 'min',\n", " qtype => 'mixing', # 'mixing' seems to work better than 'normal'\n", " );\n", "}\n", "\n", "sub load_image_to_pdl {\n", " my ($uri, $image_size) = @_;\n", "\n", " my $http = HTTP::Tiny->new;\n", " my $response = $http->get( $uri );\n", " die \"Could not fetch image from $uri\" unless $response->{success};\n", " say \"Downloaded $uri\";\n", "\n", " my $img = Imager->new;\n", " $img->read( data => $response->{content} );\n", "\n", " my $rescaled = imager_scale_to($img, $image_size);\n", "\n", " say sprintf \"Rescaled image from [ %d x %d ] to [ %d x %d ]\",\n", " $img->getwidth, $img->getheight,\n", " $rescaled->getwidth, $rescaled->getheight;\n", "\n", " my $padded = imager_paste_center_pad($rescaled, $image_size,\n", " # ARGB fits in 32-bits (uint32_t)\n", " channels => 4\n", " );\n", "\n", " say sprintf \"Padded to [ %d x %d ]\", $padded->getwidth, $padded->getheight;\n", "\n", " # Create PDL ndarray from Imager data in-memory.\n", " my $data;\n", " $padded->write( data => \\$data, type => 'raw' )\n", " or die \"could not write \". $padded->errstr;\n", "\n", " # $data is packed as PDL->dims == [w,h] with ARGB pixels\n", " # $ PDL::howbig(ulong) # 4\n", " my $pdl_raw = zeros(ulong, $padded->getwidth, $padded->getheight);\n", " ${ $pdl_raw->get_dataref } = $data;\n", " $pdl_raw->upd_data;\n", "\n", " # Split uint32_t pixels into first dimension with 3 channels (R,G,B) with values 0-255.\n", " my @shifts = map 8*$_, 0..2;\n", " my $pdl_channels = $pdl_raw->dummy(0)\n", " ->and2(ulong(map 0xFF << $_, @shifts)->slice(':,*,*') )\n", " ->shiftright( ulong(@shifts)->slice(':,*,*') )\n", " ->byte;\n", "\n", " my $pdl_scaled = (\n", " # Scale to [ 0, 1 ].\n", " ( $pdl_channels / float(255) )\n", " );\n", "\n", " ## flip vertically to see image right way up\n", " #show_in_gnuplot( $pdl_channels->slice(':,:,-1:0') ); #DEBUG\n", " #show_in_gnuplot( $pdl_scaled->slice(':,:,-1:0') * 255.0 ); #DEBUG\n", "\n", " $pdl_scaled;\n", "}\n", "\n", "my @pdl_images = map {\n", " load_image_to_pdl(\n", " $images_for_test_to_uri{$_},\n", " $model_name_to_params{$model_name}{image_size}\n", " );\n", "} @image_names;\n", "\n", "my $pdl_image_batched = cat(@pdl_images);\n", "my $t = FloatPDLTOTFTensor($pdl_image_batched);\n", "\n", "p $pdl_image_batched;\n", "p $t;" ] }, { "cell_type": "markdown", "id": "02a87430", "metadata": {}, "source": [ "## Run the model for inference\n", "\n", "We can use the `Run` method to run the session and get the output `TFTensor`.\n", "\n", "First, we send a single random input to warm up the model." ] }, { "cell_type": "code", "execution_count": 13, "id": "e7e06051", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Warming up the model\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ "\u001b[0;38;5;176mAI::TensorFlow::Libtensorflow::Tensor\u001b[m \u001b[0;38;5;81m{\u001b[m\n", " \u001b[0;38;5;104mType \u001b[m \u001b[0;38;5;176mFLOAT\u001b[m\n", " \u001b[0;38;5;104mDims \u001b[m \u001b[0;38;5;81m[\u001b[m \u001b[0;38;5;209m1\u001b[m \u001b[0;38;5;209m1001\u001b[m \u001b[0;38;5;81m]\u001b[m\n", " \u001b[0;38;5;104mNumDims \u001b[m \u001b[0;38;5;209m2\u001b[m\n", " \u001b[0;38;5;104mElementCount \u001b[m \u001b[0;38;5;209m1001\u001b[m\n", "\u001b[0;38;5;81m}\u001b[m\n" ] } ], "source": [ "my $RunSession = sub {\n", " my ($session, $t) = @_;\n", " my @outputs_t;\n", "\n", " $session->Run(\n", " undef,\n", " $outputs{in}, [$t],\n", " $outputs{out}, \\@outputs_t,\n", " undef,\n", " undef,\n", " $s\n", " );\n", " AssertOK($s);\n", "\n", " return $outputs_t[0];\n", "};\n", "\n", "say \"Warming up the model\";\n", "use PDL::GSL::RNG;\n", "my $rng = PDL::GSL::RNG->new('default');\n", "my $image_size = $model_name_to_params{$model_name}{image_size};\n", "my $warmup_input = zeros(float, 3, @$image_size, 1 );\n", "$rng->get_uniform($warmup_input);\n", "\n", "p $RunSession->($session, FloatPDLTOTFTensor($warmup_input));" ] }, { "cell_type": "markdown", "id": "8c3b9cdf", "metadata": {}, "source": [ "Then we send the batched image data. The returned scores need to by normalised using the [softmax function](https://en.wikipedia.org/wiki/Softmax_function) with the following formula (taken from Wikipedia):\n", "\n", "$$ {\\displaystyle \\sigma (\\mathbf {z} )_{i}={\\frac {e^{z_{i}}}{\\sum _{j=1}^{K}e^{z_{j}}}}\\ \\ {\\text{ for }}i=1,\\dotsc ,K{\\text{ and }}\\mathbf {z} =(z_{1},\\dotsc ,z_{K})\\in \\mathbb {R} ^{K}.} $$" ] }, { "cell_type": "code", "execution_count": 14, "id": "ffd5bbb6", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "\u001b[0;38;5;176mPDL\u001b[m\u001b[0;38;5;81m {\u001b[m\n", " \u001b[0;38;5;104mData \u001b[m : \u001b[0;38;5;107mtoo long to print\u001b[m\n", " \u001b[0;38;5;104mType \u001b[m : \u001b[0;38;5;176mfloat\u001b[m\n", " \u001b[0;38;5;104mShape \u001b[m : \u001b[0;38;5;81m[\u001b[m\u001b[0;38;5;146m1001 12\u001b[m\u001b[0;38;5;81m]\u001b[m\n", " \u001b[0;38;5;104mNelem \u001b[m : \u001b[93m12012\u001b[m\n", " \u001b[0;38;5;104mMin \u001b[m : \u001b[91m2.73727380317723e-07\u001b[m\n", " \u001b[0;38;5;104mMax \u001b[m : \u001b[94m0.980696022510529\u001b[m\n", " \u001b[0;38;5;104mBadflag \u001b[m : \u001b[32mNo\u001b[m\n", " \u001b[0;38;5;104mHas Bads\u001b[m : \u001b[32mNo\u001b[m\n", "\u001b[0;38;5;81m}\u001b[m\n" ] } ], "source": [ "my $output_pdl_batched = FloatTFTensorToPDL($RunSession->($session, $t));\n", "my $softmax = sub { ( map $_/sumover($_)->dummy(0), exp($_[0]) )[0] };\n", "my $probabilities_batched = $softmax->($output_pdl_batched);\n", "p $probabilities_batched;" ] }, { "cell_type": "markdown", "id": "caec73f1", "metadata": {}, "source": [ "## Results summary\n", "\n", "Then select the top 5 of those and find their class labels." ] }, { "cell_type": "code", "execution_count": 15, "id": "d8f638e0", "metadata": {}, "outputs": [ { "data": { "text/html": [ "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "
apple\"apple\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1958pomegranate0.764890849590302
2949Granny Smith0.0557115897536278
3951orange0.0294644851237535
4955banana0.0140652684494853
5952lemon0.0104219866916537
\n", "
banana\"banana\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1955banana0.980696022510529
2941spaghetti squash0.00609391508623958
3940zucchini0.000924494117498398
4942acorn squash0.000428267841925845
5988corn0.000371591129805893
\n", "
bus\"bus\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1706passenger car0.503800392150879
2875trolleybus0.334556519985199
3655minibus0.0483399331569672
4830streetcar0.0060268952511251
5556fire engine0.00416999123990536
\n", "
car\"car\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1437beach wagon0.653993427753448
2480car wheel0.0596377961337566
3582grille0.0583300851285458
4512convertible0.0284444093704224
5469cab0.0261545460671186
\n", "
cat\"cat\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1283tiger cat0.406540781259537
2286Egyptian cat0.217931881546974
3282tabby0.162566006183624
4288lynx0.0042705861851573
5284Persian cat0.003710369579494
\n", "
dog\"dog\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1177Saluki0.956575691699982
2215Gordon setter0.0140251601114869
3166black-and-tan coonhound0.0014705111971125
4170borzoi0.00110328639857471
5159toy terrier0.000861090025864542
\n", "
flamingo\"flamingo\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1131flamingo0.959895372390747
2130spoonbill0.0161255765706301
3128white stork0.00166661932598799
4129black stork0.000550497847143561
5773safety pin0.000548230716958642
\n", "
honeycomb\"honeycomb\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1600honeycomb0.877344310283661
2411apiary0.0387796945869923
362boa constrictor0.0105699924752116
463rock python0.00116742600221187
5552face powder0.000891718140337616
\n", "
piano\"piano\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1882upright0.935266852378845
2852television0.0038805918302387
3599home theater0.00317473593167961
4652microwave0.00226501375436783
5528desktop computer0.00212761503644288
\n", "
teapot\"teapot\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1850teapot0.934735298156738
2506coffeepot0.0514399670064449
3969cup0.000874492223374546
4551espresso maker0.000661507365293801
5726pitcher0.000382225960493088
\n", "
tiger\"tiger\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
1293tiger0.648495197296143
2283tiger cat0.181235983967781
3341zebra0.0170289929956198
4291jaguar0.00196429016068578
5353impala0.00144677574280649
\n", "
turtle\"turtle\"\n", "\n", "\n", "\n", "\n", "\n", "
RankLabel NoLabelProb
135leatherback turtle0.535791635513306
2347water buffalo0.0588447786867619
3148grey whale0.0354104563593864
4345hippopotamus0.0267104711383581
534loggerhead0.0217578746378422
\n", "
\n" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "my $N = 5; # number to select\n", "\n", "my $top_batched = $probabilities_batched->qsorti->slice([-1, -$N]);\n", "\n", "my @top_lists = dog($top_batched);\n", "\n", "my $includes_background_class = $probabilities_batched->dim(0) == IMAGENET_LABEL_COUNT_WITH_BG;\n", "\n", "if( IN_IPERL ) {\n", " my $html = IPerl->html(\n", " my_table( [0..$#image_names], sub {\n", " my ($batch_idx, $h) = @_;\n", " my $image_name = $image_names[$batch_idx];\n", " my @top_for_image = $top_lists[$batch_idx]->list;\n", " (\n", " $h->tt($image_name),\n", " $h->a( { href => $images_for_test_to_uri{$image_name} },\n", " $h->img({\n", " src => $images_for_test_to_uri{$image_name},\n", " alt => $image_name,\n", " width => '50%',\n", " })\n", " ),\n", " do {\n", " my @tr;\n", " push @tr, [ $h->th('Rank', 'Label No', 'Label', 'Prob') ];\n", " while( my ($i, $label_index) = each @top_for_image ) {\n", " my $class_index = $includes_background_class ? $label_index : $label_index + 1;\n", " push @tr, [ $h->td(\n", " $i + 1,\n", " $class_index,\n", " $labels[$class_index],\n", " $probabilities_batched->at($label_index,$batch_idx),\n", " ) ];\n", "\n", " }\n", " $h->table([$h->tr(@tr)])\n", " },\n", " )\n", " })\n", " );\n", " IPerl->display($html);\n", "} else {\n", " for my $batch_idx (0..$#image_names) {\n", " my $image_name = $image_names[$batch_idx];\n", " my @top_for_image = $top_lists[$batch_idx]->list;\n", " my @td;\n", " say \"Image name: `$image_name`\";\n", " my $header = [ ('Rank', 'Label No', 'Label', 'Prob') ];\n", " my @rows;\n", " while( my ($i, $label_index) = each @top_for_image ) {\n", " my $class_index = $includes_background_class ? $label_index : $label_index + 1;\n", " push @rows, [ (\n", " $i + 1,\n", " $class_index,\n", " $labels[$class_index],\n", " $probabilities_batched->at($label_index,$batch_idx),\n", " ) ];\n", " }\n", " say generate_table( rows => [ $header, @rows ], header_row => 1 );\n", " print \"\\n\";\n", " }\n", "}" ] }, { "cell_type": "code", "execution_count": 16, "id": "c052958d", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "All probabilities sum up to approximately 1\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ "\u001b[0;38;5;176mPDL\u001b[m\u001b[0;38;5;81m {\u001b[m\n", " \u001b[0;38;5;104mData \u001b[m : \u001b[0;38;5;81m[\u001b[m\u001b[0;38;5;209m1 1 1 1 1 1 1 1 1 1 1 1\u001b[m\u001b[0;38;5;81m]\u001b[m\n", " \u001b[0;38;5;104mType \u001b[m : \u001b[0;38;5;176mdouble\u001b[m\n", " \u001b[0;38;5;104mShape \u001b[m : \u001b[0;38;5;81m[\u001b[m\u001b[0;38;5;146m12\u001b[m\u001b[0;38;5;81m]\u001b[m\n", " \u001b[0;38;5;104mNelem \u001b[m : \u001b[93m12\u001b[m\n", " \u001b[0;38;5;104mMin \u001b[m : \u001b[91m1\u001b[m\n", " \u001b[0;38;5;104mMax \u001b[m : \u001b[94m1\u001b[m\n", " \u001b[0;38;5;104mBadflag \u001b[m : \u001b[32mNo\u001b[m\n", " \u001b[0;38;5;104mHas Bads\u001b[m : \u001b[32mNo\u001b[m\n", "\u001b[0;38;5;81m}\u001b[m\n" ] }, { "data": { "text/plain": [ "1" ] }, "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ "my $p_approx_batched = $probabilities_batched->sumover->approx(1, 1e-5);\n", "p $p_approx_batched;\n", "say \"All probabilities sum up to approximately 1\" if $p_approx_batched->all->sclr;" ] }, { "cell_type": "markdown", "id": "f9cb0dd9", "metadata": {}, "source": [ "# RESOURCE USAGE" ] }, { "cell_type": "code", "execution_count": 17, "id": "69c9cf1d", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Disk space usage: 27.45M\n" ] } ], "source": [ "use Filesys::DiskUsage qw/du/;\n", "\n", "my $total = du( { 'human-readable' => 1, dereference => 1 },\n", " $model_archive_path, $model_base, $labels_path );\n", "\n", "say \"Disk space usage: $total\"; undef;" ] }, { "cell_type": "markdown", "id": "3f0649fb", "metadata": {}, "source": [ "# DEBUGGING\n", "\n", "The following images can be used to test the `load_image_to_pdl` function." ] }, { "cell_type": "code", "execution_count": 18, "id": "1934d68c", "metadata": {}, "outputs": [], "source": [ "my @solid_channel_uris = (\n", " 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Solid_red.svg/480px-Solid_red.svg.png',\n", " 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/1d/Green_00FF00_9x9.svg/480px-Green_00FF00_9x9.svg.png',\n", " 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/ff/Solid_blue.svg/480px-Solid_blue.svg.png',\n", ");\n", "undef;" ] } ], "metadata": { "kernelspec": { "display_name": "IPerl 0.012", "language": "perl", "name": "iperl" }, "language_info": { "file_extension": ".pl", "mimetype": "text/x-perl", "name": "perl", "version": "5.30.0" } }, "nbformat": 4, "nbformat_minor": 5 }