Keith Vetter 2016-06-13 -- Recently I came back from vacation with a bootload of pictures. I wanted to upload a bunch of them and get prints of them, but I didn't trust the default cropping and the cropping tool they supply on the website was kind of primitive.
So I wrote this tool that lets you quickly and accurately crop photographs to 4x6, 5x7 or 8x10 ratios.
When you start the tool, it first scales the image so it can fit on your screen. Then you use the mouse to adjust the crop window. Pressing "m" adds a mask to just see your selection; pressing "f" flips the orientation and pressing "t" adds grid lines for the "
Rule of Thirds". Once you're happy, press "s" to save the image, then press "n" to move to the next image in the directory and repeat.
##+##########################################################################
#
# Photo Crop -- interactively crops photo to 4x6, 5x7 or 8x10 ratio
# uses ImageMagick if present which preserves exif data
# by Keith Vetter 2016-06-08
package require Tk
package require Img
package require jpeg
set S(orientations) {Vertical Horizontal}
set S(sizes) {4x6 5x7 8x10}
set S(crop,orientation) "Vertical"
set S(crop,ratio) {4 6}
set S(mask,type) none
set S(need,faux,stipple) [expr {$tcl_platform(os) eq "Darwin"}]
set S(thirds) 0
set S(useImageMagick) [expr {[auto_execok convert] ne ""}]
proc DoDisplay {} {
global S
WindowTitle
wm minsize . 150 150
. config -bg gray75
grid [DoControlPane] - -sticky ew
scrollbar .sby -orient v -command {.c yview}
scrollbar .sbx -orient h -command {.c xview}
canvas .c -width 400 -height 600 -bd 0 -highlightthickness 0 -bg gray75 \
-yscrollcommand {MyScrollFilter .sby set} -xscrollcommand {MyScrollFilter .sbx set}
grid .c .sby -sticky news
grid .sbx -sticky ew
grid rowconfigure . 1 -weight 1
grid columnconfigure . 0 -weight 1
.c create image 0 0 -anchor nw -tag image
.c create rect 0 0 200 300 -tag crop -fill {} -outline red -width 5 -dash "."
foreach corner {nw ne se sw} {
.c create rect 0 0 0 0 -fill black -width 0 -tag [list thumb thumb_$corner]
.c create rect 0 0 0 0 -fill red -width 0 -tag [list thumb2 thumb2_$corner]
}
.c create poly -9999 -9999 -9999 -9999 -tag mask -width 0 -fill gray75 -outline gray75
.c create image 0 0 -anchor nw -tag stipple
bind Canvas <Button-2> [bind Text <Button-2>]
bind Canvas <B2-Motion> [bind Text <B2-Motion>]
bind Canvas <MouseWheel> [bind Listbox <MouseWheel>]
bind Canvas <Shift-MouseWheel> [bind Listbox <Shift-MouseWheel>]
bind Canvas <Mod2-MouseWheel> [bind Listbox <Mod2-MouseWheel>]
bind Canvas <Shift-Mod2-MouseWheel> [bind Listbox <Shift-Mod2-MouseWheel>]
bind .c <Button-1> {ButtonPressHandler down %x %y}
bind .c <B1-Motion> {ButtonPressHandler move %x %y}
foreach {key action} {f FlipOrientation z ZoomCropBox m ToggleMask s SaveIt o NewImage t ToggleThirds
a About n NextImage p {NextImage -1} r {NextImage random} "Key-space" ShowMessage } {
bind all <$key> $action
}
bind .c <Configure> [list ConfigureEventHandler %w %h]
focus .
}
proc WindowTitle {} {
set title "Crop [join $::S(crop,ratio) x]"
if {[info exists ::I(fname)]} {
append title " -- [file tail $::I(fname)]"
if {$::I(shrunk) > 1} {
append title " \[shrunk \uf7$::I(shrunk)]"
}
if {[file exists $::I(cropName)]} { append title " <done>" }
}
wm title . $title
}
proc MyScrollFilter {args} {
{*}$args
lassign [Viewport] left top
if {$left != 0 || $top != 0} { DefaultCropBox }
DrawCropBox
}
proc ConfigureEventHandler {w h} {
if {! [info exists ::I(bbox)]} return
lassign $::I(bbox) x0 y0 x1 y1
if {$w > $x1 && $h > $y1} return
DefaultCropBox
DrawCropBox
}
proc DoControlPane {} {
::ttk::frame .top
::ttk::radiobutton .top.mask -text "Mask" -value solid -variable S(mask,type) -command DrawCropBox
::ttk::radiobutton .top.stipple -text "Stipple" -value stipple -variable S(mask,type) -command DrawCropBox
::ttk::radiobutton .top.none -text "None" -value none -variable S(mask,type) -command DrawCropBox
tk_optionMenu .top.orient ::S(crop,orientation) {*}$::S(orientations)
.top.orient config -width [string length "Horizontal"]
for {set i 0} {$i < [llength $::S(sizes)]} {incr i} {
[winfo child .top.orient] entryconfig $i -command ChangeOrientation
}
tk_optionMenu .top.size ::S(crop,size) {*}$::S(sizes)
for {set i 0} {$i < [llength $::S(sizes)]} {incr i} {
[winfo child .top.size] entryconfig $i -command ChangeOrientation
}
::ttk::checkbutton .top.thirds -text "Rule of Thirds" -variable S(thirds) -command DrawCropBox
::ttk::button .top.save -text Save -command SaveIt
::ttk::button .top.about -text About -command About
::ttk::button .top.new -text "Open Image" -command NewImage
::ttk::button .top.prev -text "Previous Image" -command {NextImage -1}
::ttk::button .top.next -text "Next Image" -command {NextImage 1}
foreach {a b c d} {
.top.mask .top.orient .top.new .top.save
.top.stipple .top.size .top.prev .top.about
.top.none .top.thirds .top.next x} {
grid x $a x $b x $c x $d x -sticky ew
}
grid columnconfigure .top {0 2 4 6 8} -weight 1
return .top
}
proc NewImage {} {
ShowMessage
set ftypes [list {"Images" {.jpg .png .gif}} {"All Files" *}]
set fname [tk_getOpenFile -defaultextension ".jpg" -filetypes $ftypes \
-initialfile $::I(fname) \
-initialdir [file dirname $::I(fname)]]
if {$fname eq ""} return
wm geom . {}
LoadImage $fname
}
proc NextImage {{dir 1}} {
global I
ShowMessage
set idir [file dirname $I(fname)]
set all {}
foreach fname [lsort -dictionary [glob -nocomplain -directory $idir -tail *.gif *.jpg *.png]] {
if {! [string match "*_cropped.*" $fname]} { lappend all $fname }
}
if {$all eq {}} return
if {$dir eq "random"} {
set idx [expr {int(rand() * [llength $all])}]
} else {
set idx [lsearch -exact $all [file tail $I(fname)]]
if {$idx == -1} {set idx [expr {-$dir}]}
set idx [expr {($idx + $dir) % [llength $all]}]
}
set fname [file join $idir [lindex $all $idx]]
LoadImage $fname
}
proc LoadImage {fname} {
global I
if {"::img::src" in [image names]} { image delete ::img::src }
if {"::img::display" in [image names]} { image delete ::img::display }
image create photo ::img::src -file $fname
image create photo ::img::display
GetDisplayImage
set I(fname) $fname
set I(w) [image width ::img::display]
set I(h) [image height ::img::display]
set I(image,format) [expr {[::jpeg::isJPEG $fname] ? "jpeg" : "png"}]
set I(cropName) "[file rootname $fname]_cropped[file extension $fname]"
unset -nocomplain I(bbox)
WindowTitle
.c itemconfig image -image ::img::display
set width [expr {min([winfo screenwidth .] - 400, $I(w))}]
set height [expr {min([winfo screenheight .] - 400, $I(h))}]
.c config -width $width -height $height
.c config -scrollregion [list 0 0 $I(w) $I(h)]
update ;# Need update to insure that [winfo width .] is accurate
set ::S(crop,orientation) [expr {$I(w) < $I(h) ? "Vertical" : "Horizontal"}]
ChangeOrientation
}
proc GetDisplayImage {} {
set w [image width ::img::src]
set h [image height ::img::src]
set max_w [expr {[winfo screenwidth .] - 100}]
set max_h [expr {[winfo screenheight .] - 100}]
foreach factor {1 2 3 4} {
if {$w / $factor < $max_w && $h / $factor < $max_h} break
}
::img::display copy ::img::src -subsample $factor $factor
set ::I(shrunk) $factor
}
proc ButtonPressHandler {action x y} {
global I
if {$action eq "down"} {
set I(mouse,action) {}
if {[IsInside crop $x $y]} { set I(mouse,action) move }
foreach corner {nw ne se sw} {
if {[IsInside thumb_$corner $x $y]} { set I(mouse,action) $corner ; break }
}
set I(mouse,x) $x
set I(mouse,y) $y
return
}
if {$I(mouse,action) eq {}} return
if {$I(mouse,action) eq "move"} {
set dx [expr {$x - $I(mouse,x)}]
set dy [expr {$y - $I(mouse,y)}]
set I(mouse,x) $x
set I(mouse,y) $y
MoveCropBox $dx $dy
DrawCropBox
return
}
if {$I(mouse,action) in {nw ne se sw}} {
ResizeCropBox $I(mouse,action) $x $y
DrawCropBox
return
}
error "bad action: $I(mouse,action)"
}
proc MoveCropBox {dx dy} {
global I
lassign [Viewport] left top right bottom
lassign $I(bbox) x0 y0 x1 y1
if {$x0 + $dx < $left} { set dx [expr {$left - $x0}] }
if {$x1 + $dx > $right} { set dx [expr {$right - $x1}] }
if {$y0 + $dy < $top} { set dy [expr {$top - $y0}] }
if {$y1 + $dy > $bottom} {set dy [expr {$bottom - $y1}]}
incr x0 $dx
incr x1 $dx
incr y0 $dy
incr y1 $dy
set I(bbox) [list $x0 $y0 $x1 $y1]
}
proc ResizeCropBox {corner x y} {
global I
lassign [Viewport] left top right bottom
set x [expr {max($left, min($x, $right))}]
set y [expr {max($top, min($y, $bottom))}]
lassign $I(bbox) x0 y0 x1 y1
if {$corner eq "se"} {
lassign [NewCropSize $x0 $y0 $x $y] dx dy
set x1 [expr {$x0 + $dx}]
set y1 [expr {$y0 + $dy}]
} elseif {$corner eq "nw"} {
lassign [NewCropSize $x $y $x1 $y1] dx dy
set x0 [expr {$x1 - $dx}]
set y0 [expr {$y1 - $dy}]
} elseif {$corner eq "ne"} {
lassign [NewCropSize $x0 $y $x $y1] dx dy
set x1 [expr {$x0 + $dx}]
set y0 [expr {$y1 - $dy}]
} elseif {$corner eq "sw"} {
lassign [NewCropSize $x $y0 $x1 $y] dx dy
set x0 [expr {$x1 - $dx}]
set y1 [expr {$y0 + $dy}]
} else { error "bad corner: $corner" }
if {$dx < 25 || $dy < 25} return
if {$x0 < $left || $x1 > $right || $y0 < $top || $y1 > $bottom} return
set I(bbox) [list $x0 $y0 $x1 $y1]
DrawCropBox
}
proc DefaultCropBox {} {
lassign [Viewport] left top right bottom
set dx [expr {$right - $left}]
set dy [expr {$bottom - $top}]
set newWidth [expr {3*$dx/4}]
set newHeight [expr {3*$dy/4}]
set x0 [expr {$left + ($dx - $newWidth) / 2}]
set y0 [expr {$left + ($dy - $newHeight) / 2}]
lassign $::S(crop,ratio) width height
if {$width > $height} {
set newHeight [expr {round($newWidth * $height / double($width))}]
if {$newHeight > $dy} {
set newHeight [expr {$dy - 20}]
set newWidth [expr {round($newHeight * $width / double($height))}]
}
} else {
set newWidth [expr {round($newHeight * $width / double($height))}]
if {$newWidth > $dx} {
set newWidth [expr {$dx - 20}]
set newHeight [expr {round($newWidth * $height / double($width))}]
}
}
set x1 [expr {$x0 + $newWidth}]
set y1 [expr {$y0 + $newHeight}]
set ::I(bbox) [list $x0 $y0 $x1 $y1]
CenterCropBox
if {$::S(mask,type) eq "solid"} { set ::S(mask,type) none}
}
proc CenterCropBox {} {
lassign [Viewport] left top right bottom
lassign $::I(bbox) x0 y0 x1 y1
set excess [expr {($right - $left) - ($x1 - $x0)}]
set dx [expr {($excess/2) - ($x0 - $left)}]
set excess [expr {($bottom - $top) - ($y1 - $y0)}]
set dy [expr {($excess/2) - ($y0 - $left)}]
MoveCropBox $dx $dy
}
proc ZoomCropBox {} {
global I S
lassign [Viewport] left top right bottom
lassign $I(bbox) x0 y0 x1 y1
lassign $S(crop,ratio) width height
if {$x1 < $right-1 && $y1 < $bottom-1} {
set x2 $right
set y2 [expr {$y1 + ($x2 - $x1) * $height / $width}]
if {$y2 > $bottom} {
set y2 $bottom
set x2 [expr {$x1 + ($y2 - $y1) * $width / $height}]
}
ResizeCropBox se $x2 $y2
} else {
set x2 $left
set y2 [expr {$y0 - ($x0 - $x2) * $height / $width}]
if {$y2 < $top} {
set y2 $top
set x2 [expr {$x0 - ($y0 - $y2) * $width / $height}]
}
ResizeCropBox nw $x2 $y2
}
DrawCropBox
}
proc NewCropSize {x0 y0 x1 y1} {
set w [expr {$x1 - $x0}]
set h [expr {$y1 - $y0}]
lassign $::S(crop,ratio) mul div
set w2 [expr {round($h * $mul / double($div))}]
set h2 $h
return [list $w2 $h2]
}
proc IsInside {tag x y} {
lassign [Screen2Canvas [list $x $y]] cx cy
lassign [.c bbox $tag] x0 y0 x1 y1
if {$cx < $x0 || $cx > $x1 || $cy < $y0 || $cy > $y1} { return false }
return true
}
proc Screen2Canvas {xy {scaler 1}} {
set result {}
foreach {x y} $xy {
lappend result [expr {$scaler * round([.c canvasx $x])}]
lappend result [expr {$scaler * round([.c canvasy $y])}]
}
return $result
}
proc DrawCropBox {{newWindow 0}} {
if {! [info exists ::I(bbox)]} return
ShowMask none
.c coords crop [Screen2Canvas $::I(bbox)]
set xy [.c bbox crop]
foreach corner {nw ne se sw} {
lassign [ThumbCoords $xy $corner] xy1 xy2
.c coords thumb_$corner $xy1
.c coords thumb2_$corner $xy2
}
ShowMask
ShowThirds
}
proc ThumbCoords {xy corner} {
set ts 11
set ts2 1
lassign $xy x0 y0 x1 y1
if {$corner eq "se"} {
set xy1 [list [expr {$x1-$ts}] [expr {$y1-$ts}] $x1 $y1]
} elseif {$corner eq "nw"} {
set xy1 [list $x0 $y0 [expr {$x0+$ts}] [expr {$y0+$ts}]]
} elseif {$corner eq "ne"} {
set xy1 [list [expr {$x1-$ts}] $y0 $x1 [expr {$y0+$ts}]]
} elseif {$corner eq "sw"} {
set xy1 [list $x0 [expr {$y1-$ts}] [expr {$x0+$ts}] $y1]
} else { error "bad corner: $corner" }
set xy2 {}
foreach pt $xy1 delta [list $ts2 $ts2 -$ts2 -$ts2] { lappend xy2 [expr {$pt + $delta}] }
return [list $xy1 $xy2]
}
proc About {} {
set msg "Photo Crop\nby Keith Vetter June 2016"
set details "Interactively lets you crop a photo image maintaining proper "
append details "4x6, 5x7 or 8x10 proportions."
append details "\n\nIf the image is bigger than the screen, it will be shrunk to "
append details "fit but cropping will still be done from the full size image."
append details "\n\nKeyboard shortcuts:\n"
append details " \u2022 z zoom\n"
append details " \u2022 f flip orientation\n"
append details " \u2022 m toggle mask\n"
append details " \u2022 t toggle rule of thirds grid\n"
append details " \u2022 s save cropped image\n"
append details " \u2022 o open image\n"
append details " \u2022 n next image in directory\n"
append details " \u2022 p previous image in directory\n"
append details " \u2022 r random image in directory\n"
tk_messageBox -title "About Photo Crop" -message $msg -detail $details
}
proc SaveIt {} {
global I
if {$::S(useImageMagick)} {
lassign [Screen2Canvas $I(bbox) $I(shrunk)] x0 y0 x1 y1
set w [expr {$x1 - $x0}]
set h [expr {$y1 - $y0}]
set geom "${w}x${h}+$x0+$y0"
exec convert -crop $geom -- $I(fname) $I(cropName)
focus .
} else {
image create photo ::img::tmp
::img::tmp copy ::img::src -from {*}[Screen2Canvas $I(bbox) $I(shrunk)]
::img::tmp write $I(cropName) -format $I(image,format)
image delete ::img::tmp
}
ShowMessage "Wrote $I(cropName)"
}
proc ShowMessage {{msg ""}} {
if {[lsearch [image names] ::img::chi] == -1} {
image create bitmap ::img::chi -data {
#define chi_width 7
#define chi_height 7
static char chi_bits = {
0x63, 0x77, 0x3e, 0x1c, 0x3e, 0x77, 0x63
}
}
}
destroy .c.msg
if {$msg eq ""} return
label .c.msg -bd 2 -relief ridge -wraplength 350 -text $msg -padx 2m -pady 4m
label .c.msg.x -image ::img::chi -bd 1 -relief solid
place .c.msg -relx .5 -rely .3 -anchor c
place .c.msg.x -relx 1 -rely 0 -x -2 -y 2 -anchor ne
bind .c.msg.x <1> ShowMessage
after [expr {5*1000}] ShowMessage
}
proc FlipOrientation {} {
set ::S(crop,orientation) [expr {$::S(crop,orientation) eq "Vertical" ? "Horizontal" : "Vertical"}]
ChangeOrientation
}
proc ChangeOrientation {} {
global S
set n [scan $S(crop,size) %dx%d a b]
if {$S(crop,orientation) eq "Vertical"} {
set newWidth $a
set newHeight $b
} else {
set newWidth $b
set newHeight $a
}
lassign $S(crop,ratio) width height
set S(crop,ratio) [list $newWidth $newHeight]
WindowTitle
DefaultCropBox
DrawCropBox
}
proc Viewport {} {
# Returns screen coordinates of image in canvas, needed when image is smaller than canvas
global I
set x0 [expr {max(0, round(- [.c canvasx 0]))}]
set y0 [expr {max(0, round(- [.c canvasy 0]))}]
set x1 [expr {min([winfo width .c], $x0 + $I(w))}]
set y1 [expr {min([winfo height .c], $y0 + $I(h))}]
return [list $x0 $y0 $x1 $y1]
}
proc ToggleMask {} {
global S
set d [dict create none solid solid stipple stipple none]
set S(mask,type) [dict get $d $S(mask,type)]
DrawCropBox
}
proc ShowMask {{which ""}} {
global I
if {$which eq ""} {set which $::S(mask,type)}
if {$which eq "none"} {
set xy {-9999 -9999 -9999 -9999}
.c itemconfig crop -outline red
.c itemconfig thumb -fill black
.c itemconfig thumb2 -fill red
.c coords mask $xy
.c delete faux_stipple
.c itemconfig stipple -image {}
} else {
if {$which eq "stipple" && $::S(need,faux,stipple)} {
ShowFauxStipple
return
}
lassign [Screen2Canvas $::I(bbox)] x0 y0 x1 y1
set xy [list $I(w) $I(h) 0 $I(h) 0 0 $I(w) 0 $I(w) $I(h) \
$x1 $y1 $x0 $y1 $x0 $y0 $x1 $y0 $x1 $y1]
.c coords mask $xy
.c itemconfig crop -outline {}
.c itemconfig thumb -fill {}
.c itemconfig thumb2 -fill {}
.c itemconfig mask -stipple [expr {$which eq "stipple" ? "gray75" : ""}]
}
}
proc ShowFauxStipple {} {
# Fake stippling on Mac's which don't support it natively
global I
if {"::img::stipple" ni [image names]} {
image create photo ::img::blank -width 16 -height 16
image create photo ::img::stipple -width 16 -height 16
for {set row 0} {$row < 16} {incr row 4} {
set row2 [expr {$row + 2}]
for {set col 0} {$col < 16} {incr col 4} {
set col2 [expr {$col + 2}]
::img::stipple put yellow -to $row $col
::img::stipple put yellow -to $row2 $col2
}
}
}
if {"::img::stippling" ni [image names]} {
image create photo ::img::stippling -width $I(w) -height $I(h)
}
.c itemconfig stipple -image ::img::stippling
::img::stippling blank
::img::stippling config -width $I(w) -height $I(h)
# NB. in theory, we don't need to copy ::img::display into ::img::stippling, but
# without it performance is terrible--over a minute or more.
::img::stippling copy ::img::display
::img::stippling copy ::img::stipple -to 0 0 $I(w) $I(h)
::img::stippling copy ::img::blank -to {*}[Screen2Canvas $I(bbox)] -compositingrule set
}
proc ToggleThirds {} {
set ::S(thirds) [expr {!$::S(thirds)}]
DrawCropBox
}
proc ShowThirds {} {
.c delete thirds
if {! $::S(thirds)} return
lassign $::I(bbox) x0 y0 x1 y1
set dx [expr {$x1 - $x0}]
set dy [expr {$y1 - $y0}]
set x1_3 [expr {round($x0 + $dx / 3.0)}]
set y1_3 [expr {round($y0 + $dy / 3.0)}]
set x2_3 [expr {round($x0 + 2 * $dx / 3.0)}]
set y2_3 [expr {round($y0 + 2 * $dy / 3.0)}]
.c create line $x0 $y1_3 $x1 $y1_3 -tag thirds -dash . -fill red -width 2
.c create line $x0 $y2_3 $x1 $y2_3 -tag thirds -dash . -fill red -width 2
.c create line $x1_3 $y0 $x1_3 $y1 -tag thirds -dash . -fill red -width 2
.c create line $x2_3 $y0 $x2_3 $y1 -tag thirds -dash . -fill red -width 2
}
if {[llength $argv] == 0} {
tk_messageBox -message "Photo Crop\nby Keith Vetter" \
-detail "Usage: photoCrop <image_file>" -icon warning
exit
}
if {$argv ne {}} {
set fname [lindex $argv 0]
}
DoDisplay
LoadImage $fname
return