Skip to content
This repository was archived by the owner on Nov 18, 2025. It is now read-only.

Commit be1a08b

Browse files
authored
Merge pull request #73 from jingw/improve-rounding
Improve rounding behavior
2 parents 386d244 + c8c7f79 commit be1a08b

4 files changed

Lines changed: 153 additions & 69 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ write the result.
77

88
It turns out that debian's jpegtran has a "-crop" flag which performs lossless
99
cropping of jpeg images as long as the crop is to a multiple of what the
10-
manpage calls the "iMCU boundary", a (usually?) 8x8 block of pixels. This
11-
feature may have been pioneered by Guido of jpegclub.org some years ago.
10+
manpage calls the "iMCU boundary", usually an 8x8 or 16x16 block of pixels.
11+
This feature may have been pioneered by Guido of jpegclub.org some years ago.
1212

1313
There's apparently a nice Windows front-end to this program, but I didn't find
1414
a Linux one. So I wrote one! It's pretty basic, but it gets the job done. You
@@ -22,9 +22,9 @@ overwrite an earlier cropped version). For example, if the input is "moon.jpg"
2222
then the output is "moon-cropped.jpg".
2323

2424
Images are automatically scaled by a power of 2 (e.g., 1/2, 1/4 or 1/8) in
25-
order to fit onscreen. After releasing the mouse button, the cropped image
26-
boundary may move a little bit; this represents the limitation that the
27-
upper-left corner must be at a multiple of 8x8 original image pixels.
25+
order to fit onscreen. While dragging, the cropped image boundary will snap
26+
to a multiple of 8 or 16 pixels; this represents the limitation that the
27+
upper-left corner must be at a multiple of the iMCU blocks.
2828

2929
## PREREQUISITES
3030

cropgtk.py

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,14 @@ def run(self):
243243
_("%s - CropGTK") % os.path.basename(image_name))
244244
self.set_busy()
245245
try:
246-
i = Image.open(image_name)
247-
drag.w, drag.h = i.size
246+
image = Image.open(image_name)
247+
drag.round_x, drag.round_y = image_round(image)
248+
drag.w, drag.h = image.size
248249
scale = 1
249250
scale = max (scale, nextPowerOf2((drag.w-1)/(max_w+1)))
250251
scale = max (scale, nextPowerOf2((drag.h-1)/(max_h+1)))
251-
i.thumbnail((drag.w/scale, drag.h/scale))
252+
thumbnail = image.copy()
253+
thumbnail.thumbnail((drag.w/scale, drag.h/scale))
252254
except (IOError,) as detail:
253255
m = gtk.MessageDialog(self['window1'],
254256
gtk.DialogFlags.MODAL | gtk.DialogFlags.DESTROY_WITH_PARENT,
@@ -259,9 +261,9 @@ def run(self):
259261
m.destroy()
260262
continue
261263
image_type = imghdr.what(image_name)
262-
drag.image = i
264+
drag.image = thumbnail
263265
drag.rotation = 1
264-
rotation = image_rotation(i)
266+
rotation = image_rotation(image)
265267
if rotation in (3,6,8):
266268
while drag.rotation != rotation:
267269
drag.rotate_ccw()
@@ -274,34 +276,18 @@ def run(self):
274276
self.log("Skipped %s" % os.path.basename(image_name))
275277
continue # user hit "next" / escape
276278

277-
t, l, r, b = drag.top, drag.left, drag.right, drag.bottom
278-
cropspec = "%dx%d+%d+%d" % (r-l, b-t, l, t)
279-
280-
if drag.rotation == 3: rotation = '180'
281-
elif drag.rotation == 6: rotation = '90'
282-
elif drag.rotation == 8: rotation = '270'
283-
else: rotation = "none"
284-
285279
target = self.output_name(image_name,image_type)
286280
if not target:
287281
self.log("Skipped %s" % os.path.basename(image_name))
288282
continue # user hit "cancel" on save dialog
289283

290-
# Copy file if no cropping or rotation.
291-
if (r+b-l-t) == (drag.w+drag.h) and rotation =="none":
292-
command = ['nice', 'cp' , image_name, target]
293-
# JPEG crop uses jpegtran
294-
elif image_type == "jpeg":
295-
command = ['nice', 'jpegtran']
296-
if not rotation == "none": command.extend(['-rotate', rotation])
297-
command.extend(['-copy', 'all', '-crop', cropspec,'-outfile', target, image_name])
298-
# All other images use imagemagic convert.
299-
else:
300-
command = ['nice', 'convert']
301-
if not rotation == "none": command.extend(['-rotate', rotation])
302-
command.extend([image_name, '-crop', cropspec, target])
303-
print(" ".join(command))
304-
task.add(command, target)
284+
task.add(CropRequest(
285+
image=image,
286+
image_name=image_name,
287+
corners=drag.get_corners(),
288+
rotation=drag.rotation,
289+
target=target,
290+
))
305291

306292
def image_names(self):
307293
if len(sys.argv) > 1:

cropgui.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -268,17 +268,19 @@ def set_busy(new_busy=True):
268268
for image_name in image_names():
269269
# load new image
270270
set_busy()
271-
i = Image.open(image_name)
271+
image = Image.open(image_name)
272272

273+
drag.round_x, drag.round_y = image_round(image)
274+
drag.w, drag.h = image.size
273275
# compute scale to fit image on display
274-
drag.w, drag.h = i.size
275276
drag.scale=1
276277
drag.scale = max (drag.scale, (drag.w-1)/max_w+1)
277278
drag.scale = max (drag.scale, (drag.h-1)/max_h+1)
278279

279280
# put image into drag object
280-
i.thumbnail((drag.w/drag.scale, drag.h/drag.scale))
281-
drag.image = i
281+
thumbnail = image.copy()
282+
thumbnail.thumbnail((drag.w/drag.scale, drag.h/drag.scale))
283+
drag.image = thumbnail
282284

283285
# get user input
284286
set_busy(0)
@@ -287,18 +289,15 @@ def set_busy(new_busy=True):
287289
if v == -1: break # user closed app
288290
if v == 0: continue # user hit "next" / escape
289291

290-
t, l, r, b = drag.get_corners()
291-
# Copy file if no cropping.
292-
if (r+b-l-t) == (drag.w+drag.h):
293-
command = ['nice', 'cp' , image_name, target]
294-
# call jpegtran
295-
else:
296-
base, ext = os.path.splitext(image_name)
297-
cropspec = "%dx%d+%d+%d" % (r-l, b-t, l, t)
298-
target = base + "-crop" + ext
299-
command=['nice', 'jpegtran', '-copy', 'all', '-crop', cropspec, '-outfile', target, image_name]
300-
print(" ".join(command))
301-
task.add(command, target)
292+
base, ext = os.path.splitext(image_name)
293+
target = base + "-crop" + ext
294+
task.add(CropRequest(
295+
image=image,
296+
image_name=image_name,
297+
corners=drag.get_corners(),
298+
rotation=drag.rotation,
299+
target=target,
300+
))
302301
finally:
303302
task.done()
304303

cropgui_common.py

Lines changed: 119 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# You should have received a copy of the GNU General Public License
1414
# along with this program; if not, write to the Free Software
1515
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
16+
from collections import namedtuple
17+
1618
from PIL import Image
1719
from PIL import ImageFilter
1820
from PIL import ImageDraw
@@ -61,12 +63,44 @@ def nextPowerOf2(n):
6163

6264
return 1 << count;
6365

66+
67+
def get_cropspec(image, corners, rotation):
68+
t, l, r, b = corners
69+
w = r - l
70+
h = b - t
71+
72+
# The coordinates passed to jpegtran are interpreted post-rotation.
73+
# Non-whole blocks are already imperfectly rotated by being left on the
74+
# side, so we need to subtract them
75+
if image.format == "JPEG":
76+
round_x, round_y = image_round(image)
77+
orig_w, orig_h = image.size
78+
if rotation in (8, 6):
79+
orig_w, orig_h = orig_h, orig_w
80+
round_x, round_y = round_y, round_x
81+
if rotation in (3, 8):
82+
t -= orig_h % round_y
83+
if rotation in (3, 6):
84+
l -= orig_w % round_x
85+
assert t >= 0, "t < 0 should be handled in fix(): {}".format(t)
86+
assert l >= 0, "l < 0 should be handled in fix(): {}".format(l)
87+
88+
return "%dx%d+%d+%d" % (w, h, l, t)
89+
90+
6491
def ncpus():
6592
if os.path.exists("/proc/cpuinfo"):
6693
return open("/proc/cpuinfo").read().count("bogomips") or 1
6794
return 1
6895
ncpus = ncpus()
6996

97+
98+
CropRequest = namedtuple(
99+
"CropRequest",
100+
["image", "image_name", "corners", "rotation", "target"]
101+
)
102+
103+
70104
class CropTask(object):
71105
def __init__(self, log):
72106
self.log = log
@@ -86,17 +120,55 @@ def create_task(self):
86120
def count(self):
87121
return len(self.tasks) + len(self.threads)
88122

89-
def add(self, args, target):
90-
self.tasks.put((args, target))
123+
def add(self, task):
124+
self.tasks.put(task)
91125

92126
def runner(self):
93127
while 1:
94128
task = self.tasks.get()
95129
if task is None:
96130
break
97-
command, target = task
131+
image = task.image
132+
image_name = task.image_name
133+
rotation_int = task.rotation
134+
target = task.target
98135
shortname = os.path.basename(target)
99136
self.log.progress(_("Cropping to %s") % shortname)
137+
138+
t, l, r, b = task.corners
139+
cropspec = get_cropspec(image, task.corners, rotation_int)
140+
141+
if rotation_int == 3:
142+
rotation = "180"
143+
elif rotation_int == 6:
144+
rotation = "90"
145+
elif rotation_int == 8:
146+
rotation = "270"
147+
else:
148+
rotation = "none"
149+
150+
# Copy file if no cropping or rotation.
151+
if (r + b - l - t) == (image.width + image.height) and rotation == "none":
152+
command = ["nice", "cp", image_name, target]
153+
# JPEG crop uses jpegtran
154+
elif image.format == "JPEG":
155+
command = ["nice", "jpegtran"]
156+
if rotation != "none":
157+
command += ["-rotate", rotation]
158+
command += [
159+
"-copy", "all",
160+
"-crop", cropspec,
161+
"-outfile", target,
162+
image_name,
163+
]
164+
# All other images use ImageMagick convert.
165+
else:
166+
command = ["nice", "convert"]
167+
if rotation != "none":
168+
command += ["-rotate", rotation]
169+
command += [image_name, "-crop", cropspec, target]
170+
171+
print(" ".join(command))
100172
subprocess.call(command)
101173
subprocess.call(["exiftool", "-overwrite_original", "-Orientation=1", "-n", target])
102174
self.log.log(_("Cropped to %s") % shortname)
@@ -106,7 +178,8 @@ def __init__(self):
106178
self.render_flag = 0
107179
self.show_handles = True
108180
self.state = DRAG_NONE
109-
self.round = 8
181+
self.round_x = None
182+
self.round_y = None
110183
self.image = None
111184
self.w = 0
112185
self.h = 0
@@ -133,10 +206,8 @@ def apply_rotation(self, image):
133206
def image_or_rotation_changed(self):
134207
self._image = image = self.apply_rotation(self._orig_image)
135208
self.apply_rotation(image)
136-
self.top = 0
137-
self.left = 0
138-
self.right = self.w
139-
self.bottom = self.h
209+
self.top, self.bottom = self.fix(0, self.h, self.h, self.round_y, self.rotation in (3, 8))
210+
self.left, self.right = self.fix(0, self.w, self.w, self.round_x, self.rotation in (3, 6))
140211
blurred = image.copy()
141212
mult = len(self.image.mode) # replicate filter for L, RGB, RGBA
142213
self.blurred = image.copy().filter(
@@ -145,20 +216,33 @@ def image_or_rotation_changed(self):
145216
self.image_set()
146217
self.render()
147218

148-
def fix(self, a, b, lim):
149-
a, b = sorted((b,a))
150-
a = clamp(a, 0, lim)
151-
b = clamp(b, 0, lim)
152-
a = (a / self.round)*self.round
153-
b = (b / self.round)*self.round
154-
return int(a), int(b)
219+
def fix(self, a, b, lim, r, reverse):
220+
"""
221+
a, b: interval to fix
222+
lim: upper bound
223+
r: rounding size
224+
reverse: True to treat the upper bound as the origin
225+
"""
226+
if reverse:
227+
offset = lim % r
228+
else:
229+
offset = 0
230+
a, b = sorted((int(a), int(b)))
231+
a = ((a - offset) // r) * r + offset
232+
b = ((b - offset + r - 1) // r) * r + offset
233+
# jpegtran handles non-whole blocks by leaving them on the edge of the
234+
# image, away from the rotated position of their old neighbors.
235+
# Keeping them isn't useful, so clamp them off.
236+
a = clamp(a, offset if reverse else 0, lim)
237+
b = clamp(b, offset if reverse else 0, lim)
238+
return a, b
155239

156240
def get_corners(self):
157241
return self.top, self.left, self.right, self.bottom
158242

159243
def get_screencorners(self):
160244
t, l, r, b = self.get_corners()
161-
return(int(t/int(self.scale)), int(l/int(self.scale)),
245+
return(int(t/int(self.scale)), int(l/int(self.scale)),
162246
int(r/int(self.scale)), int(b/int(self.scale)))
163247

164248
def describe_ratio(self):
@@ -198,8 +282,8 @@ def set_stdsize(self, x, y):
198282
self.set_crop (top, left, right, bottom)
199283

200284
def set_crop(self, top, left, right, bottom):
201-
self.top, self.bottom = self.fix(top, bottom, self.h)
202-
self.left, self.right = self.fix(left, right, self.w)
285+
self.top, self.bottom = self.fix(top, bottom, self.h, self.round_y, self.rotation in (3, 8))
286+
self.left, self.right = self.fix(left, right, self.w, self.round_x, self.rotation in (3, 6))
203287
self.render()
204288

205289
def get_image(self):
@@ -332,8 +416,12 @@ def drag_end(self, x, y):
332416
self.set_crop(self.top, self.left, self.right, self.bottom)
333417
self.state = DRAG_NONE
334418

335-
def rotate_ccw(self):
419+
def _flip_dimensions(self):
336420
self.w, self.h = self.h, self.w
421+
self.round_x, self.round_y = self.round_y, self.round_x
422+
423+
def rotate_ccw(self):
424+
self._flip_dimensions()
337425
r = self.rotation
338426
if r == 1: r = 8
339427
elif r == 8: r = 3
@@ -342,7 +430,7 @@ def rotate_ccw(self):
342430
self.rotation = r
343431

344432
def rotate_cw(self):
345-
self.w, self.h = self.h, self.w
433+
self._flip_dimensions()
346434
r = self.rotation
347435
if r == 1: r = 6
348436
elif r == 6: r = 3
@@ -377,6 +465,17 @@ def image_rotation(i):
377465
print("image_rotation", result)
378466
return result or 1
379467

468+
469+
def image_round(i):
470+
"""Return (horizontal block size, vertical block size)"""
471+
if i.format == "JPEG":
472+
x = max(xsamp for _id, xsamp, ysamp, _qtable in i.layer)
473+
y = max(ysamp for _id, xsamp, ysamp, _qtable in i.layer)
474+
return x * 8, y * 8
475+
else:
476+
return 1, 1
477+
478+
380479
_desktop_name = None
381480
def desktop_name():
382481
global _desktop_name

0 commit comments

Comments
 (0)