Skip to content

Commit

Permalink
gh-118225: Support more options for copying images in Tkinter (GH-118228
Browse files Browse the repository at this point in the history
)

* Add the PhotoImage method copy_replace() to copy a region
  from one image to other image, possibly with pixel zooming and/or
  subsampling.
* Add from_coords parameter to PhotoImage methods copy(), zoom() and subsample().
* Add zoom and subsample parameters to PhotoImage method copy().
  • Loading branch information
serhiy-storchaka authored May 6, 2024
1 parent 09871c9 commit 1b639a0
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 17 deletions.
9 changes: 9 additions & 0 deletions Doc/library/tkinter.rst
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,15 @@ of :class:`tkinter.Image`:
Either type of image is created through either the ``file`` or the ``data``
option (other options are available as well).

.. versionchanged:: 3.13
Added the :class:`!PhotoImage` method :meth:`!copy_replace` to copy a region
from one image to other image, possibly with pixel zooming and/or
subsampling.
Add *from_coords* parameter to :class:`!PhotoImage` methods :meth:`!copy()`,
:meth:`!zoom()` and :meth:`!subsample()`.
Add *zoom* and *subsample* parameters to :class:`!PhotoImage` method
:meth:`!copy()`.

The image object can then be used wherever an ``image`` option is supported by
some widget (e.g. labels, buttons, menus). In these cases, Tk will not keep a
reference to the image. When the last Python reference to the image object is
Expand Down
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,15 @@ tkinter
* Add the :meth:`!after_info` method for Tkinter widgets.
(Contributed by Cheryl Sabella in :gh:`77020`.)

* Add the :class:`!PhotoImage` method :meth:`!copy_replace` to copy a region
from one image to other image, possibly with pixel zooming and/or
subsampling.
Add *from_coords* parameter to :class:`!PhotoImage` methods :meth:`!copy()`,
:meth:`!zoom()` and :meth:`!subsample()`.
Add *zoom* and *subsample* parameters to :class:`!PhotoImage` method
:meth:`!copy()`.
(Contributed by Serhiy Storchaka in :gh:`118225`.)

traceback
---------

Expand Down
151 changes: 150 additions & 1 deletion Lib/test/test_tkinter/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,37 @@ def test_copy(self):
image2 = image.copy()
self.assertEqual(image2.width(), 16)
self.assertEqual(image2.height(), 16)
self.assertEqual(image.get(4, 6), image.get(4, 6))
self.assertEqual(image2.get(4, 6), image.get(4, 6))

image2 = image.copy(from_coords=(2, 3, 14, 11))
self.assertEqual(image2.width(), 12)
self.assertEqual(image2.height(), 8)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(11, 7), image.get(13, 10))
self.assertEqual(image2.get(2, 4), image.get(2+2, 4+3))

image2 = image.copy(from_coords=(2, 3, 14, 11), zoom=2)
self.assertEqual(image2.width(), 24)
self.assertEqual(image2.height(), 16)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(23, 15), image.get(13, 10))
self.assertEqual(image2.get(2*2, 4*2), image.get(2+2, 4+3))
self.assertEqual(image2.get(2*2+1, 4*2+1), image.get(6+2, 2+3))

image2 = image.copy(from_coords=(2, 3, 14, 11), subsample=2)
self.assertEqual(image2.width(), 6)
self.assertEqual(image2.height(), 4)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(5, 3), image.get(12, 9))
self.assertEqual(image2.get(3, 2), image.get(3*2+2, 2*2+3))

image2 = image.copy(from_coords=(2, 3, 14, 11), subsample=2, zoom=3)
self.assertEqual(image2.width(), 18)
self.assertEqual(image2.height(), 12)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(17, 11), image.get(12, 9))
self.assertEqual(image2.get(1*3, 2*3), image.get(1*2+2, 2*2+3))
self.assertEqual(image2.get(1*3+2, 2*3+2), image.get(1*2+2, 2*2+3))

def test_subsample(self):
image = self.create()
Expand All @@ -316,6 +346,13 @@ def test_subsample(self):
self.assertEqual(image2.height(), 8)
self.assertEqual(image2.get(2, 3), image.get(4, 6))

image2 = image.subsample(2, from_coords=(2, 3, 14, 11))
self.assertEqual(image2.width(), 6)
self.assertEqual(image2.height(), 4)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(5, 3), image.get(12, 9))
self.assertEqual(image2.get(1, 2), image.get(1*2+2, 2*2+3))

def test_zoom(self):
image = self.create()
image2 = image.zoom(2, 3)
Expand All @@ -330,6 +367,118 @@ def test_zoom(self):
self.assertEqual(image2.get(8, 12), image.get(4, 6))
self.assertEqual(image2.get(9, 13), image.get(4, 6))

image2 = image.zoom(2, from_coords=(2, 3, 14, 11))
self.assertEqual(image2.width(), 24)
self.assertEqual(image2.height(), 16)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(23, 15), image.get(13, 10))
self.assertEqual(image2.get(2*2, 4*2), image.get(2+2, 4+3))
self.assertEqual(image2.get(2*2+1, 4*2+1), image.get(6+2, 2+3))

def test_copy_replace(self):
image = self.create()
image2 = tkinter.PhotoImage(master=self.root)
image2.copy_replace(image)
self.assertEqual(image2.width(), 16)
self.assertEqual(image2.height(), 16)
self.assertEqual(image2.get(4, 6), image.get(4, 6))

image2 = tkinter.PhotoImage(master=self.root)
image2.copy_replace(image, from_coords=(2, 3, 14, 11))
self.assertEqual(image2.width(), 12)
self.assertEqual(image2.height(), 8)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(11, 7), image.get(13, 10))
self.assertEqual(image2.get(2, 4), image.get(2+2, 4+3))

image2 = tkinter.PhotoImage(master=self.root)
image2.copy_replace(image)
image2.copy_replace(image, from_coords=(2, 3, 14, 11), shrink=True)
self.assertEqual(image2.width(), 12)
self.assertEqual(image2.height(), 8)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(11, 7), image.get(13, 10))
self.assertEqual(image2.get(2, 4), image.get(2+2, 4+3))

image2 = tkinter.PhotoImage(master=self.root)
image2.copy_replace(image, from_coords=(2, 3, 14, 11), to=(3, 6))
self.assertEqual(image2.width(), 15)
self.assertEqual(image2.height(), 14)
self.assertEqual(image2.get(0+3, 0+6), image.get(2, 3))
self.assertEqual(image2.get(11+3, 7+6), image.get(13, 10))
self.assertEqual(image2.get(2+3, 4+6), image.get(2+2, 4+3))

image2 = tkinter.PhotoImage(master=self.root)
image2.copy_replace(image, from_coords=(2, 3, 14, 11), to=(0, 0, 100, 50))
self.assertEqual(image2.width(), 100)
self.assertEqual(image2.height(), 50)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(11, 7), image.get(13, 10))
self.assertEqual(image2.get(2, 4), image.get(2+2, 4+3))
self.assertEqual(image2.get(2+12, 4+8), image.get(2+2, 4+3))
self.assertEqual(image2.get(2+12*2, 4), image.get(2+2, 4+3))
self.assertEqual(image2.get(2, 4+8*3), image.get(2+2, 4+3))

image2 = tkinter.PhotoImage(master=self.root)
image2.copy_replace(image, from_coords=(2, 3, 14, 11), zoom=2)
self.assertEqual(image2.width(), 24)
self.assertEqual(image2.height(), 16)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(23, 15), image.get(13, 10))
self.assertEqual(image2.get(2*2, 4*2), image.get(2+2, 4+3))
self.assertEqual(image2.get(2*2+1, 4*2+1), image.get(6+2, 2+3))

image2 = tkinter.PhotoImage(master=self.root)
image2.copy_replace(image, from_coords=(2, 3, 14, 11), subsample=2)
self.assertEqual(image2.width(), 6)
self.assertEqual(image2.height(), 4)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(5, 3), image.get(12, 9))
self.assertEqual(image2.get(1, 2), image.get(1*2+2, 2*2+3))

image2 = tkinter.PhotoImage(master=self.root)
image2.copy_replace(image, from_coords=(2, 3, 14, 11), subsample=2, zoom=3)
self.assertEqual(image2.width(), 18)
self.assertEqual(image2.height(), 12)
self.assertEqual(image2.get(0, 0), image.get(2, 3))
self.assertEqual(image2.get(17, 11), image.get(12, 9))
self.assertEqual(image2.get(3*3, 2*3), image.get(3*2+2, 2*2+3))
self.assertEqual(image2.get(3*3+2, 2*3+2), image.get(3*2+2, 2*2+3))
self.assertEqual(image2.get(1*3, 2*3), image.get(1*2+2, 2*2+3))
self.assertEqual(image2.get(1*3+2, 2*3+2), image.get(1*2+2, 2*2+3))

def checkImgTrans(self, image, expected):
actual = {(x, y)
for x in range(image.width())
for y in range(image.height())
if image.transparency_get(x, y)}
self.assertEqual(actual, expected)

def test_copy_replace_compositingrule(self):
image1 = tkinter.PhotoImage(master=self.root, width=2, height=2)
image1.blank()
image1.put('black', to=(0, 0, 2, 2))
image1.transparency_set(0, 0, True)

# default compositingrule
image2 = tkinter.PhotoImage(master=self.root, width=3, height=3)
image2.blank()
image2.put('white', to=(0, 0, 2, 2))
image2.copy_replace(image1, to=(1, 1))
self.checkImgTrans(image2, {(0, 2), (2, 0)})

image3 = tkinter.PhotoImage(master=self.root, width=3, height=3)
image3.blank()
image3.put('white', to=(0, 0, 2, 2))
image3.copy_replace(image1, to=(1, 1), compositingrule='overlay')
self.checkImgTrans(image3, {(0, 2), (2, 0)})

image4 = tkinter.PhotoImage(master=self.root, width=3, height=3)
image4.blank()
image4.put('white', to=(0, 0, 2, 2))
image4.copy_replace(image1, to=(1, 1), compositingrule='set')
self.checkImgTrans(image4, {(0, 2), (1, 1), (2, 0)})

def test_put(self):
image = self.create()
image.put('{red green} {blue yellow}', to=(4, 6))
Expand Down
111 changes: 95 additions & 16 deletions Lib/tkinter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4278,33 +4278,112 @@ def cget(self, option):

def __getitem__(self, key):
return self.tk.call(self.name, 'cget', '-' + key)
# XXX copy -from, -to, ...?

def copy(self):
"""Return a new PhotoImage with the same image as this widget."""
def copy(self, *, from_coords=None, zoom=None, subsample=None):
"""Return a new PhotoImage with the same image as this widget.
The FROM_COORDS option specifies a rectangular sub-region of the
source image to be copied. It must be a tuple or a list of 1 to 4
integers (x1, y1, x2, y2). (x1, y1) and (x2, y2) specify diagonally
opposite corners of the rectangle. If x2 and y2 are not specified,
the default value is the bottom-right corner of the source image.
The pixels copied will include the left and top edges of the
specified rectangle but not the bottom or right edges. If the
FROM_COORDS option is not given, the default is the whole source
image.
If SUBSAMPLE or ZOOM are specified, the image is transformed as in
the subsample() or zoom() methods. The value must be a single
integer or a pair of integers.
"""
destImage = PhotoImage(master=self.tk)
self.tk.call(destImage, 'copy', self.name)
destImage.copy_replace(self, from_coords=from_coords,
zoom=zoom, subsample=subsample)
return destImage

def zoom(self, x, y=''):
def zoom(self, x, y='', *, from_coords=None):
"""Return a new PhotoImage with the same image as this widget
but zoom it with a factor of x in the X direction and y in the Y
direction. If y is not given, the default value is the same as x.
but zoom it with a factor of X in the X direction and Y in the Y
direction. If Y is not given, the default value is the same as X.
The FROM_COORDS option specifies a rectangular sub-region of the
source image to be copied, as in the copy() method.
"""
destImage = PhotoImage(master=self.tk)
if y=='': y=x
self.tk.call(destImage, 'copy', self.name, '-zoom',x,y)
return destImage
return self.copy(zoom=(x, y), from_coords=from_coords)

def subsample(self, x, y=''):
def subsample(self, x, y='', *, from_coords=None):
"""Return a new PhotoImage based on the same image as this widget
but use only every Xth or Yth pixel. If y is not given, the
default value is the same as x.
but use only every Xth or Yth pixel. If Y is not given, the
default value is the same as X.
The FROM_COORDS option specifies a rectangular sub-region of the
source image to be copied, as in the copy() method.
"""
destImage = PhotoImage(master=self.tk)
if y=='': y=x
self.tk.call(destImage, 'copy', self.name, '-subsample',x,y)
return destImage
return self.copy(subsample=(x, y), from_coords=from_coords)

def copy_replace(self, sourceImage, *, from_coords=None, to=None, shrink=False,
zoom=None, subsample=None, compositingrule=None):
"""Copy a region from the source image (which must be a PhotoImage) to
this image, possibly with pixel zooming and/or subsampling. If no
options are specified, this command copies the whole of the source
image into this image, starting at coordinates (0, 0).
The FROM_COORDS option specifies a rectangular sub-region of the
source image to be copied. It must be a tuple or a list of 1 to 4
integers (x1, y1, x2, y2). (x1, y1) and (x2, y2) specify diagonally
opposite corners of the rectangle. If x2 and y2 are not specified,
the default value is the bottom-right corner of the source image.
The pixels copied will include the left and top edges of the
specified rectangle but not the bottom or right edges. If the
FROM_COORDS option is not given, the default is the whole source
image.
The TO option specifies a rectangular sub-region of the destination
image to be affected. It must be a tuple or a list of 1 to 4
integers (x1, y1, x2, y2). (x1, y1) and (x2, y2) specify diagonally
opposite corners of the rectangle. If x2 and y2 are not specified,
the default value is (x1,y1) plus the size of the source region
(after subsampling and zooming, if specified). If x2 and y2 are
specified, the source region will be replicated if necessary to fill
the destination region in a tiled fashion.
If SHRINK is true, the size of the destination image should be
reduced, if necessary, so that the region being copied into is at
the bottom-right corner of the image.
If SUBSAMPLE or ZOOM are specified, the image is transformed as in
the subsample() or zoom() methods. The value must be a single
integer or a pair of integers.
The COMPOSITINGRULE option specifies how transparent pixels in the
source image are combined with the destination image. When a
compositing rule of 'overlay' is set, the old contents of the
destination image are visible, as if the source image were printed
on a piece of transparent film and placed over the top of the
destination. When a compositing rule of 'set' is set, the old
contents of the destination image are discarded and the source image
is used as-is. The default compositing rule is 'overlay'.
"""
options = []
if from_coords is not None:
options.extend(('-from', *from_coords))
if to is not None:
options.extend(('-to', *to))
if shrink:
options.append('-shrink')
if zoom is not None:
if not isinstance(zoom, (tuple, list)):
zoom = (zoom,)
options.extend(('-zoom', *zoom))
if subsample is not None:
if not isinstance(subsample, (tuple, list)):
subsample = (subsample,)
options.extend(('-subsample', *subsample))
if compositingrule:
options.extend(('-compositingrule', compositingrule))
self.tk.call(self.name, 'copy', sourceImage, *options)

def get(self, x, y):
"""Return the color (red, green, blue) of the pixel at X,Y."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Add the :class:`!PhotoImage` method :meth:`!copy_replace` to copy a region
from one image to other image, possibly with pixel zooming and/or
subsampling. Add *from_coords* parameter to :class:`!PhotoImage` methods
:meth:`!copy()`, :meth:`!zoom()` and :meth:`!subsample()`. Add *zoom* and
*subsample* parameters to :class:`!PhotoImage` method :meth:`!copy()`.

0 comments on commit 1b639a0

Please sign in to comment.