Evaluate doubt on each sample result.
[Image2SGF.git] / Image2SGF.pm
blob842151380084496f53cae31448072a81afeff6ac
1 #!/usr/bin/perl -w
3 package Games::Go::Image2SGF;
4 our $VERSION = '1.03+';
6 =pod
8 =head1 NAME
10 Games::Go::Image2SGF -- interpret photographs of go positions.
12 =head1 SYNOPSIS
14 my $board = Games::Go::Image2SGF->new(
15 boardsize => 13,
16 images => [
17 { tl => [50, 50],
18 tr => [1000, 50],
19 bl => [50, 1000],
20 br => [1000, 1000],
21 file => 'go_photograph_01.jpg'
23 { tl => [50, 50],
24 tr => [1000, 50],
25 bl => [50, 1000],
26 br => [1000, 1000],
27 file => 'go_photograph_02.jpg'
32 $board->to_sgf;
33 print $board->{sgf};
35 =head1 DESCRIPTION
37 B<Games::Go::Image2SGF> is a I<perl5> module to create a computer-readable
38 I<SGF> format description of the position on a Go board, given a photograph
39 of the position.
41 =head1 OPTIONS
43 Options are passed to B<Games::Go::ImageSGF> via its constructor. It will
44 attempt to use sane defaults for arguments you don't supply; you must supply
45 values for the required arguments.
47 =over 4
49 =item boardsize
51 Optional. Size of the board. When using gocam to detect the corners,
52 it assumes that the gocam_test binary in the PATH will locate the
53 correct grid (which currently requires gocam to be rebuilt for that
54 size). Default: 19.
56 =item images
58 Required. A list of hash refs defining the of images to interpret.
59 Keys of this hash are:
61 =over 4
63 =item file
65 Required. This can be in any format supported by I<Imager>.
67 =item tl, tr, bl, br
69 Required. The coordinates of the four corners of the go board's grid. You
70 can obtain these by loading your photograph in an image editor that displays
71 image coordinates and hovering the cursor over each of the grid corners.
73 =back
75 =item white, black, board
77 Optional. A fairly-representative colour for the white stones, black stones,
78 and go board itself, presented in decimal RGB triplets -- eg. C<[255,255,255]>
79 for white. You should only set these if the defaults are generating incorrect
80 SGF. Default: Black is C<[0,0,0]>, white is C<[255,255,255]>, board colour
81 is C<[100,100,100]>.
83 =item scalefactor
85 Optional. Factor by which to scale images before running gocam on
86 them. Running gocam on smaller images can drastically boost the
87 analysis time. Stone detection is done on full-sized images.
88 Default: 1.
90 =item sample_radius
92 Optional. After inferring the grid from the corner points you give, the
93 module will search in a radius of C<sample_radius> pixels to look at the
94 area's colour. As with the C<white, black, board> arguments, the default
95 is likely to do the right thing; you should only need to change this if
96 your image is very large or very small. Default: 10 pixels.
98 =item doubtmargin
100 Optional. When sampling pixels around grid points, Image2SGF can flag
101 as doubtful positions where the number of pixel used to identify a
102 (lack of) stone won by only a small number of pixels. This is the
103 percentage of the total number of sampled pixels that the winner must
104 have above its closest challenger to be considered "identified without
105 a doubt". Default: 0.15.
107 =back
109 =head1 NOTES
111 You may want to use the methods defined in the module in another order, or
112 in conjunction with other methods of your own -- for example, to track
113 video of a live game instead of still images. Note that methods with a
114 leading C<_> are considered internal, and their semantics may change.
116 =head1 DEPENDENCIES
118 C<Imager>, C<perl5>.
120 =head1 SEE ALSO
122 Further examples at L<http://www.inference.phy.cam.ac.uk/cjb/image2sgf.html>,
123 the L<http://www.red-bean.com/sgf/> SGF standard, and the collaborative guide
124 to Go at L<http://senseis.xmp.net/>.
126 =head1 AUTHORS
128 Chris Ball E<lt>chris@cpan.orgE<gt>,
129 Yann Dirson E<lt>ydirson@altern.orgE<gt>
131 =cut
133 use constant BOARD => 0;
134 use constant WHITE => 1;
135 use constant BLACK => 2;
136 use constant X => 0;
137 use constant Y => 1;
138 use constant EPSILON => 0.0001;
140 our @COLORNAMES = ('board', 'white', 'black');
142 use strict;
143 use Imager;
144 use Set::Object;
145 use File::Temp;
147 sub show_image {
148 my ($img) = @_;
149 my $tmp = File::Temp->new(SUFFIX => '.jpg');
150 my $filename = $tmp->filename;
151 $img->write(file => $filename, type => 'jpeg');
152 system("display $filename");
155 sub new {
156 # Set up some initial defaults. These are overridden by the user
157 # in their constructor. White/black/board/sample_radius are optional.
158 my $self = bless {
159 white => [255,255,255],
160 black => [0,0,0],
161 board => [100,100,100],
162 sample_radius => 10,
163 doubtmargin => 0.15,
164 boardsize => 19,
165 }, shift;
167 # Handle arguments.
168 my %options = @_;
169 while (my($key, $val) = each %options) {
170 $self->{$key} = $val;
173 # Some of our arguments are required, and we should have them at this point.
174 foreach (qw/images/) {
175 unless (defined ($self->{$_})) {
176 die "$_ is a required option; see the POD documentation.\n";
180 # The mycolors array will be used by Imager to perform the quantization.
181 $self->{mycolors} = [ Imager::Color->new(@{ $self->{white} }),
182 Imager::Color->new(@{ $self->{board} }),
183 Imager::Color->new(@{ $self->{black} }) ];
185 return $self;
188 sub read_image {
189 my $self = shift;
190 my $image = shift;
192 # Some of our arguments are required, and we should have them at this point.
193 foreach (qw/file/) {
194 unless (defined ($image->{$_})) {
195 die "$_ is a required option; see the POD documentation.\n";
197 $self->{$_} = $image->{$_};
200 my $img = Imager->new();
201 $img->read(file => $self->{file}) or die $img->errstr();
202 $self->{img} = $img;
204 # if corners are not specified, try to find them using gocam
205 if (!defined $image->{tl}) {
206 # scale the image down if asked for
207 my ($filename, $scalefactor);
208 my $tmp;
209 if (defined($self->{scalefactor})) {
210 $scalefactor = $self->{scalefactor};
211 $tmp = File::Temp->new(SUFFIX => '.jpg');
212 my $tmpimg = $img->scale(scalefactor => $scalefactor);
213 $filename = $tmp->filename;
214 $tmpimg->write(file => $filename, type => 'jpeg');
215 } else {
216 $scalefactor = 1;
217 $filename = $self->{file};
220 open CORNERS, "gocam_test --no-display --corners $filename |"
221 or die "Unable to launch a suitable gocam program";
222 my $line = <CORNERS>;
223 die "Cannot read gocam output" unless defined $line;
224 close CORNERS;
225 my @data = split ' ', $line;
226 die "gocam did not return correct corner data"
227 unless ((scalar @data) == 8); # FIXME: should test better
229 $image->{tl} = [ $data[0] / $scalefactor, $data[1] / $scalefactor ];
230 $image->{tr} = [ $data[2] / $scalefactor, $data[3] / $scalefactor ];
231 $image->{bl} = [ $data[4] / $scalefactor, $data[5] / $scalefactor ];
232 $image->{br} = [ $data[6] / $scalefactor, $data[7] / $scalefactor ];
235 # if corners are not specified and gocam failed, no SGF...
236 foreach (qw/tl tr bl br/) {
237 unless (defined ($image->{$_})) {
238 die "$_ is a required option; see the POD documentation.\n";
240 $self->{$_} = $image->{$_};
245 sub quantize {
246 my $self = shift;
248 # Quantize the image. We tell Imager to choose the colour in mycolors
249 # that each pixel in the image is nearest to, and set the pixel in the
250 # created image to that colour.
251 $self->{img} = $self->{img}->to_paletted(
252 make_colors => "none",
253 colors => $self->{mycolors},
254 max_colors => 3
255 ) or die $self->{img}->errstr();
256 #show_image($self->{img});
259 sub find_intersections {
260 my $self = shift;
262 $self->invert_coords;
264 # Find the equations for the lines connecting the four sides.
265 # Lines are defined by their slope (m) and yintercept (b) with
266 # the line equation: y = mx + b.
267 my $m_left = ($self->{tl}[Y] - $self->{bl}[Y]) /
268 ($self->{tl}[X] - $self->{bl}[X]);
269 my $b_left = $self->{bl}[Y] - ($m_left * $self->{bl}[X]);
271 my $m_right = ($self->{tr}[Y] - $self->{br}[Y]) /
272 ($self->{tr}[X] - $self->{br}[X]);
273 my $b_right = $self->{br}[Y] - ($m_right * $self->{br}[X]);
275 my $m_top = ($self->{tr}[Y] - $self->{tl}[Y]) /
276 ($self->{tr}[X] - $self->{tl}[X]);
277 my $b_top = $self->{tl}[Y] - ($m_top * $self->{tl}[X]);
279 my $m_bottom = ($self->{br}[Y] - $self->{bl}[Y]) /
280 ($self->{br}[X] - $self->{bl}[X]);
281 my $b_bottom = $self->{bl}[Y] - ($m_bottom * $self->{bl}[X]);
283 # Find the "vanishing points" for the grid the board forms. These are a
284 # "vertical vanishing point" (vvp) for the intersection of left and right
285 # lines, and a "horizontal vanishing point" (hvp) for top and bottom
286 # intersection. There is the possibility that two lines are perfectly
287 # parallel -- we check this first and create a very small difference if
288 # we would otherwise generate a SIGFPE.
289 if ($m_top == $m_bottom) {
290 $m_top += EPSILON;
292 if ($m_left == $m_right) {
293 $m_left += EPSILON;
296 my $x_vvp = ($b_right - $b_left) / ($m_left - $m_right);
297 my $y_vvp = ($m_left * $x_vvp) + $b_left;
298 my $x_hvp = ($b_top - $b_bottom) / ($m_bottom - $m_top);
299 my $y_hvp = ($m_bottom * $x_hvp) + $b_bottom;
301 # The "horizon" for any two point perspective grid will be the line
302 # connecting these two vanishing points.
303 my $m_horizon = ($y_vvp - $y_hvp) / ($x_vvp - $x_hvp);
304 my $b_horizon = $y_vvp - ($m_horizon * $x_vvp);
306 # Now find the equation of a line parallel to the horizon that goes through
307 # the bottom right point, called "fg" (short for foreground). (It's
308 # arbitrary which point this parallel line goes through, really, as long as
309 # it's different from the horizon line itself.)
310 my $m_fg = $m_horizon;
311 my $b_fg = $self->{br}[Y] - ($m_fg * $self->{br}[X]);
313 # Find intersections of the left and right lines on this foreground (fg)
314 my $left_fg_x = ($b_left - $b_fg) / ($m_fg - $m_left);
315 my $right_fg_x = ($b_right - $b_fg) / ($m_fg - $m_right);
317 # Find distance between these intersections along the x axis.
318 my $left_right_fg_x_dist = abs($right_fg_x - $left_fg_x);
320 # Divide this distance into BOARDSIZE-1 fragments to find the spacing of
321 # BOARDSIZE points along it.
322 my $fg_lr_spacing = $left_right_fg_x_dist / ($self->{boardsize} - 1);
324 # Find intersections of the top and bottom lines on the foreground
325 my $top_fg_x = ($b_top - $b_fg) / ($m_fg - $m_top);
326 my $bottom_fg_x = ($b_bottom - $b_fg) / ($m_fg - $m_bottom);
328 # Find distance between these intersections along the x axis.
329 my $top_bottom_fg_x_dist = abs($top_fg_x - $bottom_fg_x);
331 # Divide this distance into BOARDSIZE-1 fragments to find spacing.
332 my $fg_tb_spacing = $top_bottom_fg_x_dist / ($self->{boardsize} - 1);
334 # Go through the foreground left-right x points, establish the vertical
335 # lines as detemined by the slope between them and the vvp. Start
336 # with left point and move towards the right.
337 if ($left_fg_x < $right_fg_x) {
338 for my $i (1 .. $self->{boardsize}) {
339 my $x_i = $left_fg_x + ($fg_lr_spacing * ($i - 1));
340 my $y_i = $m_fg * $x_i + $b_fg;
341 $self->{vert_m_hash}[$i] = ($y_vvp - $y_i) / ($x_vvp - $x_i);
342 $self->{vert_b_hash}[$i] = $y_i - ($self->{vert_m_hash}[$i] * $x_i);
344 } else {
345 for my $i (1 .. $self->{boardsize}) {
346 my $x_i = $left_fg_x - ($fg_lr_spacing * ($i - 1));
347 my $y_i = $m_fg * $x_i + $b_fg;
348 $self->{vert_m_hash}[$i] = ($y_vvp - $y_i) / ($x_vvp - $x_i);
349 $self->{vert_b_hash}[$i] = $y_i - ($self->{vert_m_hash}[$i] * $x_i);
353 # Similarly, go through the foreground top-bottom x points, establish the
354 # horizontal lines as determined by the slope between them and the hvp.
355 # Want to number things from top to bottom, so will start things from
356 # top foreground x and move towards bottom.
357 if ($top_fg_x < $bottom_fg_x) {
358 for my $i (1 .. $self->{boardsize}) {
359 my $x_i = $top_fg_x + ($fg_tb_spacing * ($i - 1));
360 my $y_i = $m_fg * $x_i + $b_fg;
361 $self->{horiz_m_hash}[$i] = ($y_hvp - $y_i) / ($x_hvp - $x_i);
362 $self->{horiz_b_hash}[$i] = $y_i - ($self->{horiz_m_hash}[$i] * $x_i);
364 } else {
365 for my $i (1 .. $self->{boardsize}) {
366 my $x_i = $top_fg_x - ($fg_tb_spacing * ($i - 1));
367 my $y_i = $m_fg * $x_i + $b_fg;
368 $self->{horiz_m_hash}[$i] = ($y_hvp - $y_i) / ($x_hvp - $x_i);
369 $self->{horiz_b_hash}[$i] = $y_i - ($self->{horiz_m_hash}[$i] * $x_i);
373 for my $i (1 .. $self->{boardsize}) {
374 for my $j (1 .. $self->{boardsize}) {
375 my $x_vertex = ($self->{horiz_b_hash}[$i] - $self->{vert_b_hash}[$j]) /
376 ($self->{vert_m_hash}[$j] - $self->{horiz_m_hash}[$i]);
377 my $y_vertex = ($self->{horiz_m_hash}[$i] * $x_vertex) +
378 $self->{horiz_b_hash}[$i];
379 # Coordinate system:
380 # intersection [3,5] is third from top, fifth from left
381 $self->{intersection}[$i][$j] = [ $x_vertex, -1 * $y_vertex ];
386 sub show_intersections {
387 my $self = shift;
388 my $img = $self->{img}->copy;
389 my $color = Imager::Color->new(gray=>128);
391 for my $i (1 .. $self->{boardsize}) {
392 for my $j (1 .. $self->{boardsize}) {
393 $img->circle(x=>$self->{intersection}[$i][$j][0],
394 y=>$self->{intersection}[$i][$j][1],
395 color=>$color, r=>$self->{sample_radius});
399 show_image($img);
402 sub sample {
403 my ($self, $i, $j, $radius) = @_;
404 my $color;
405 my $x_vertex = $self->{intersection}[$i][$j][X];
406 my $y_vertex = $self->{intersection}[$i][$j][Y];
407 my $white = $self->{mycolors}[0];
408 my $board = $self->{mycolors}[1];
409 my $black = $self->{mycolors}[2];
411 my $offx = 0;
412 my $offy = 0;
414 my $blackcount = 0;
415 my $whitecount = 0;
416 my $boardcount = 0;
418 for (my $k = ($x_vertex+$offx - $radius); $k <= ($x_vertex+$offx + $radius); $k++) {
419 for (my $l = ($y_vertex+$offy - $radius); $l <= ($y_vertex+$offy + $radius); $l++) {
420 if (($x_vertex+$offx - $k)**2 + ($y_vertex+$offy - $l)**2 <= ($radius**2)) {
421 # If this is true, then the point ($k, $l) is in our circle.
422 # Now we sample at it.
423 my $gp = $self->{img}->getpixel('x' => $k, 'y' => $l);
424 next if $gp == undef;
425 if (_color_cmp($gp, $black) == 1) { $blackcount++; }
426 if (_color_cmp($gp, $board) == 1) { $boardcount++; }
427 if (_color_cmp($gp, $white) == 1) { $whitecount++; }
432 # Finished sampling. Use a simple majority to work out which colour
433 # wins. TODO -- there are better ways of doing this. For example,
434 # if we determine one stone to be white or black, we could afterwards
435 # set its radius _in our quantized image_ back to the board colour;
436 # this "explaining away" would alleviate cases where the grid is
437 # slightly off and we're catching pixels of an already-recorded
438 # stone on the edges.
439 if (($whitecount > $blackcount) and ($whitecount > $boardcount)) {
440 $color = WHITE;
441 } elsif ($blackcount > $boardcount) {
442 $color = BLACK;
443 } else {
444 $color = BOARD;
447 my $totalcount = ($whitecount+$blackcount+$boardcount);
448 my $threshold = $self->{doubtmargin} * $totalcount;
449 my $doubt;
450 if ($color != BLACK and abs($whitecount-$boardcount) < $threshold) {
451 my $doubtscore = int(100*abs($whitecount-$boardcount) / $totalcount);
452 $doubt = 'may be empty' if ($color == WHITE);
453 $doubt = 'may be white' if ($color == BOARD);
454 $doubt .= " ($whitecount vs. $boardcount - $doubtscore\%)";
456 if ($color != WHITE and abs($blackcount-$boardcount) < $threshold) {
457 my $doubtscore = int(100*abs($blackcount-$boardcount) / $totalcount);
458 $doubt = 'may be empty' if ($color == BLACK);
459 $doubt = 'may be black' if ($color == BOARD);
460 $doubt .= " ($blackcount vs. $boardcount - $doubtscore\%)";
462 if ($color != BOARD and abs($blackcount-$whitecount) < $threshold) {
463 my $doubtscore = int(100*abs($blackcount-$whitecount) / $totalcount);
464 $doubt = 'may be white' if ($color == BLACK);
465 $doubt = 'may be black' if ($color == WHITE);
466 $doubt .= " ($whitecount vs. $blackcount - $doubtscore\%)";
468 $self->{comment} .= $COLORNAMES[$color] . "($j,".($self->{boardsize} + 1 - $i) .
469 ") $doubt\n" if defined $doubt;
471 print STDERR "($i,$j) w$whitecount _$boardcount b$blackcount\t$color ($doubt)\n" if $doubt;
473 if ($color == WHITE or $color == BLACK) {
474 $self->add_stone($color, $i, $j);
477 return $color;
480 sub invert_coords {
481 my $self = shift;
483 # Because the origin (0,0) in the inputed coordinates is in the
484 # upper left instead of the intuitive-for-geometry bottom left,
485 # we want to call this the "fourth quadrant". That means all the
486 # y values are treated as negative numbers, so we convert:
487 for (qw(tl tr bl br)) { $self->{$_}[Y] = -$self->{$_}[Y]; }
490 sub start_sgf {
491 my $self = shift;
492 my $time = scalar localtime;
493 $self->{sgf} .= <<ENDSTARTSGF;
494 (;GM[1]FF[4]SZ[$self->{boardsize}]
495 GN[Image2SGF conversion of $time.]
497 AP[Image2SGF by Chris Ball and Yann Dirson.]
498 ENDSTARTSGF
501 sub add_stone {
502 my $self = shift;
503 my ($color, $i, $j) = @_;
504 my @letters = qw/z a b c d e f g h i j k l m n o p q r s/;
505 my ($x, $y) = ($letters[$i], $letters[$j]);
506 if ($color == BLACK) {
507 $self->{blackstones}->insert("$y$x");
509 elsif ($color == WHITE) {
510 $self->{whitestones}->insert("$y$x");
514 sub sgf_add_stones {
515 my $self = shift;
516 my ($black, $white) = @_;
518 if (!$black->is_null()) {
519 $self->{sgf} .= "\nAB";
520 $self->{sgf} .= "[$_]" foreach ($black->members());
522 if (!$white->is_null()) {
523 $self->{sgf} .= "\nAW";
524 $self->{sgf} .= "[$_]" foreach ($white->members());
528 sub sgf_clear_stones {
529 my $self = shift;
530 my ($stones) = @_;
532 return if $stones->is_null();
534 $self->{sgf} .= "\nAE";
535 $self->{sgf} .= "[$_]" foreach ($stones->members());
538 sub sgf_add_comment {
539 my $self = shift;
540 $self->{sgf} .= "\nC[$self->{comment}]" if ($self->{comment} ne '');
543 sub sgf_next_move {
544 my $self = shift;
545 $self->{sgf} .= ";";
548 sub finish_sgf {
549 my $self = shift;
550 $self->{sgf} .= ")\n";
553 sub _color_cmp {
554 my ($l, $r) = @_;
555 my @l = $l->rgba;
556 my @r = $r->rgba;
557 return ($l[0] == $r[0] and $l[1] == $r[1] and $l[2] == $r[2]);
560 sub _to_coords {
561 # Example: "cd" => "C16".
562 my ($x, $y) = @_;
563 return chr(64 + $y + ($y > 9 && 1)) . (20 - $x);
566 sub _from_coords {
567 # Example: "C16" => "cd".
568 my $move = shift;
569 /(.)(\d+)/;
570 return ($2, ord($1) - 65);
573 sub sample_board {
574 my $self = shift;
575 my $image = shift;
577 $self->read_image($image);
578 $self->find_intersections;
579 $self->quantize;
580 #$self->show_intersections;
582 $self->{blackstones} = Set::Object->new();
583 $self->{whitestones} = Set::Object->new();
584 $self->{comment} = '';
586 for my $i (1 .. $self->{boardsize}) {
587 for my $j (1 .. $self->{boardsize}) {
588 $self->sample($i, $j, $self->{sample_radius});
593 sub save_board {
594 my $self = shift;
596 $self->{prev_blackstones} = $self->{blackstones};
597 $self->{prev_whitestones} = $self->{whitestones};
600 sub to_sgf {
601 my $self = shift;
603 # The only user-visible method right now. Runs the conversion functions.
604 # (Which are separate methods so that we can keep track of a live game
605 # efficiently -- if the camera is stationary above the board, we only
606 # have to find the grid location once, and can just repeatedly call
607 # read_image/quantize/sample, reusing the coordinates.)
609 $self->start_sgf;
611 for my $image (@{$self->{images}}) {
612 print STDERR "Scanning $image->{file} ...\n";
613 $self->sample_board($image);
614 if (!defined $self->{prev_blackstones}) {
615 # first image
616 $self->sgf_add_stones($self->{blackstones},
617 $self->{whitestones});
618 } else {
619 $self->sgf_next_move;
620 $self->sgf_add_stones($self->{blackstones} - $self->{prev_blackstones},
621 $self->{whitestones} - $self->{prev_whitestones});
622 $self->sgf_clear_stones($self->{prev_blackstones} - $self->{blackstones});
623 $self->sgf_clear_stones($self->{prev_whitestones} - $self->{whitestones});
625 $self->sgf_add_comment;
626 $self->save_board;
629 $self->finish_sgf;