qhi_encoder.pl 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. #!/usr/bin/perl
  2. # Variation of the QOI encoder, combined with Huffman coding.
  3. # QHIf = Quite Huffman Image format. :)
  4. # See also:
  5. # https://qoiformat.org/
  6. # https://github.com/phoboslab/qoi
  7. use 5.020;
  8. use warnings;
  9. use Imager;
  10. use experimental qw(signatures);
  11. # produce encode and decode dictionary from a tree
  12. sub walk ($node, $code, $h, $rev_h) {
  13. my $c = $node->[0] // return ($h, $rev_h);
  14. if (ref $c) { walk($c->[$_], $code . $_, $h, $rev_h) for ('0', '1') }
  15. else { $h->{$c} = $code; $rev_h->{$code} = $c }
  16. return ($h, $rev_h);
  17. }
  18. # make a tree, and return resulting dictionaries
  19. sub mktree ($bytes) {
  20. my (%freq, @nodes);
  21. ++$freq{$_} for @$bytes;
  22. @nodes = map { [$_, $freq{$_}] } sort { $a <=> $b } keys %freq;
  23. do { # poor man's priority queue
  24. @nodes = sort { $a->[1] <=> $b->[1] } @nodes;
  25. my ($x, $y) = splice(@nodes, 0, 2);
  26. if (defined($x) and defined($y)) {
  27. push @nodes, [[$x, $y], $x->[1] + $y->[1]];
  28. }
  29. } while (@nodes > 1);
  30. walk($nodes[0], '', {}, {});
  31. }
  32. sub huffman_encode ($bytes, $dict) {
  33. my $enc = '';
  34. for (@$bytes) {
  35. $enc .= $dict->{$_} // die "bad char: $_";
  36. }
  37. return $enc;
  38. }
  39. sub qhi_encoder ($img, $out_fh) {
  40. use constant {
  41. QOI_OP_RGB => 0b1111_1110,
  42. QOI_OP_RGBA => 0b1111_1111,
  43. QOI_OP_DIFF => 0b01_000_000,
  44. QOI_OP_RUN => 0b11_000_000,
  45. QOI_OP_LUMA => 0b10_000_000,
  46. };
  47. my $width = $img->getwidth;
  48. my $height = $img->getheight;
  49. my $channels = $img->getchannels;
  50. my $colorspace = 0;
  51. say "[$width, $height, $channels, $colorspace]";
  52. my @header = unpack('C*', 'qhif');
  53. push @header, unpack('C4', pack('N', $width));
  54. push @header, unpack('C4', pack('N', $height));
  55. push @header, $channels;
  56. push @header, $colorspace;
  57. my @bytes;
  58. my $run = 0;
  59. my @px = (0, 0, 0, 255);
  60. my @prev_px = @px;
  61. my @colors = (map { [0, 0, 0, 0] } 1 .. 64);
  62. foreach my $y (0 .. $height - 1) {
  63. my @line = unpack('C*', scalar $img->getscanline(y => $y));
  64. my $line_len = scalar(@line);
  65. for (my $i = 0 ; $i < $line_len ; $i += 4) {
  66. @px = splice(@line, 0, 4);
  67. if ( $px[0] == $prev_px[0]
  68. and $px[1] == $prev_px[1]
  69. and $px[2] == $prev_px[2]
  70. and $px[3] == $prev_px[3]) {
  71. if (++$run == 62) {
  72. push @bytes, QOI_OP_RUN | ($run - 1);
  73. $run = 0;
  74. }
  75. }
  76. else {
  77. if ($run > 0) {
  78. push @bytes, (QOI_OP_RUN | ($run - 1));
  79. $run = 0;
  80. }
  81. my $hash = ($px[0] * 3 + $px[1] * 5 + $px[2] * 7 + $px[3] * 11) % 64;
  82. my $index_px = $colors[$hash];
  83. if ( $px[0] == $index_px->[0]
  84. and $px[1] == $index_px->[1]
  85. and $px[2] == $index_px->[2]
  86. and $px[3] == $index_px->[3]) { # OP INDEX
  87. push @bytes, $hash;
  88. }
  89. else {
  90. $colors[$hash] = [@px];
  91. if ($px[3] == $prev_px[3]) {
  92. my $vr = $px[0] - $prev_px[0];
  93. my $vg = $px[1] - $prev_px[1];
  94. my $vb = $px[2] - $prev_px[2];
  95. my $vg_r = $vr - $vg;
  96. my $vg_b = $vb - $vg;
  97. if ( $vr > -3
  98. and $vr < 2
  99. and $vg > -3
  100. and $vg < 2
  101. and $vb > -3
  102. and $vb < 2) {
  103. push(@bytes, QOI_OP_DIFF | (($vr + 2) << 4) | (($vg + 2) << 2) | ($vb + 2));
  104. }
  105. elsif ( $vg_r > -9
  106. and $vg_r < 8
  107. and $vg > -33
  108. and $vg < 32
  109. and $vg_b > -9
  110. and $vg_b < 8) {
  111. push(@bytes, QOI_OP_LUMA | ($vg + 32));
  112. push(@bytes, (($vg_r + 8) << 4) | ($vg_b + 8));
  113. }
  114. else {
  115. push(@bytes, QOI_OP_RGB, $px[0], $px[1], $px[2]);
  116. }
  117. }
  118. else {
  119. push(@bytes, QOI_OP_RGBA, $px[0], $px[1], $px[2], $px[3]);
  120. }
  121. }
  122. }
  123. @prev_px = @px;
  124. }
  125. }
  126. if ($run > 0) {
  127. push(@bytes, 0b11_00_00_00 | ($run - 1));
  128. }
  129. my @footer;
  130. push(@footer, (0x00) x 7);
  131. push(@footer, 0x01);
  132. my ($h, $rev_h) = mktree(\@bytes);
  133. my $enc = huffman_encode(\@bytes, $h);
  134. my $dict = '';
  135. my $codes = '';
  136. foreach my $i (0 .. 255) {
  137. my $c = $h->{$i} // '';
  138. $codes .= $c;
  139. $dict .= chr(length($c));
  140. }
  141. # Header
  142. print $out_fh pack('C*', @header);
  143. # Huffman dictionary + data
  144. print $out_fh $dict;
  145. print $out_fh pack("B*", $codes);
  146. print $out_fh pack("N", length($enc));
  147. print $out_fh pack("B*", $enc);
  148. # Footer
  149. print $out_fh pack('C*', @footer);
  150. }
  151. @ARGV || do {
  152. say STDERR "usage: $0 [input.png] [output.qhi]";
  153. exit(2);
  154. };
  155. my $in_file = $ARGV[0];
  156. my $out_file = $ARGV[1] // "$in_file.qhi";
  157. my $img = 'Imager'->new(file => $in_file)
  158. or die "Can't read image: $in_file";
  159. open(my $out_fh, '>:raw', $out_file)
  160. or die "Can't open file <<$out_file>> for writing: $!";
  161. qhi_encoder($img, $out_fh);