3 package Games
::Go
::Image2SGF
;
4 our $VERSION = '1.03+';
10 Games::Go::Image2SGF -- interpret photographs of go positions.
14 my $board = Games::Go::Image2SGF->new(
21 file => 'go_photograph_01.jpg'
27 file => 'go_photograph_02.jpg'
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
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.
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
58 Required. A list of hash refs defining the of images to interpret.
59 Keys of this hash are:
65 Required. This can be in any format supported by I<Imager>.
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.
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
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.
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.
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.
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.
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/>.
128 Chris Ball E<lt>chris@cpan.orgE<gt>,
129 Yann Dirson E<lt>ydirson@altern.orgE<gt>
133 use constant BOARD
=> 0;
134 use constant WHITE
=> 1;
135 use constant BLACK
=> 2;
138 use constant EPSILON
=> 0.0001;
140 our @COLORNAMES = ('board', 'white', 'black');
149 my $tmp = File
::Temp
->new(SUFFIX
=> '.jpg');
150 my $filename = $tmp->filename;
151 $img->write(file
=> $filename, type
=> 'jpeg');
152 system("display $filename");
156 # Set up some initial defaults. These are overridden by the user
157 # in their constructor. White/black/board/sample_radius are optional.
159 white
=> [255,255,255],
161 board
=> [100,100,100],
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
} }) ];
192 # Some of our arguments are required, and we should have them at this point.
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();
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);
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');
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;
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->{$_};
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
},
255 ) or die $self->{img
}->errstr();
256 #show_image($self->{img});
259 sub find_intersections
{
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) {
292 if ($m_left == $m_right) {
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);
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);
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];
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
{
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
});
403 my ($self, $i, $j, $radius) = @_;
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];
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)) {
441 } elsif ($blackcount > $boardcount) {
447 my $totalcount = ($whitecount+$blackcount+$boardcount);
448 my $threshold = $self->{doubtmargin
} * $totalcount;
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);
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
]; }
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.]
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");
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
{
532 return if $stones->is_null();
534 $self->{sgf
} .= "\nAE";
535 $self->{sgf
} .= "[$_]" foreach ($stones->members());
538 sub sgf_add_comment
{
540 $self->{sgf
} .= "\nC[$self->{comment}]" if ($self->{comment
} ne '');
550 $self->{sgf
} .= ")\n";
557 return ($l[0] == $r[0] and $l[1] == $r[1] and $l[2] == $r[2]);
561 # Example: "cd" => "C16".
563 return chr(64 + $y + ($y > 9 && 1)) . (20 - $x);
567 # Example: "C16" => "cd".
570 return ($2, ord($1) - 65);
577 $self->read_image($image);
578 $self->find_intersections;
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
});
596 $self->{prev_blackstones
} = $self->{blackstones
};
597 $self->{prev_whitestones
} = $self->{whitestones
};
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.)
611 for my $image (@
{$self->{images
}}) {
612 print STDERR
"Scanning $image->{file} ...\n";
613 $self->sample_board($image);
614 if (!defined $self->{prev_blackstones
}) {
616 $self->sgf_add_stones($self->{blackstones
},
617 $self->{whitestones
});
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;