diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b958328
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/build*/
+__pycache__/
diff --git a/addon/__init__.py b/addon/__init__.py
new file mode 100644
index 0000000..5d27d34
--- /dev/null
+++ b/addon/__init__.py
@@ -0,0 +1,205 @@
+'''
+ Copyright (C) 2021 - 2023 Akaneyu
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+'''
+
+bl_info = {
+ "name": "Image Editor Plus",
+ "author": "akaneyu",
+ "version": (1, 7, 3),
+ "blender": (2, 93, 0),
+ "location": "Image",
+ "warning": "",
+ "description": "",
+ "wiki_url": "",
+ "tracker_url": "",
+ "category": "Paint"}
+
+if "bpy" in locals():
+ import importlib
+ importlib.reload(app)
+ importlib.reload(api)
+ importlib.reload(operators)
+ importlib.reload(ui)
+ importlib.reload(ui_renderer)
+ importlib.reload(utils)
+
+import bpy
+from . import app
+from . import api
+from . import operators
+from . import ui
+
+classes = [
+ app.IMAGE_EDITOR_PLUS_WindowPropertyGroup,
+ app.IMAGE_EDITOR_PLUS_LayerPropertyGroup,
+ app.IMAGE_EDITOR_PLUS_ImagePropertyGroup,
+ operators.IMAGE_EDITOR_PLUS_OT_make_selection,
+ operators.IMAGE_EDITOR_PLUS_OT_cancel_selection,
+ operators.IMAGE_EDITOR_PLUS_OT_swap_colors,
+ operators.IMAGE_EDITOR_PLUS_OT_fill_with_fg_color,
+ operators.IMAGE_EDITOR_PLUS_OT_fill_with_bg_color,
+ operators.IMAGE_EDITOR_PLUS_OT_clear,
+ operators.IMAGE_EDITOR_PLUS_OT_cut,
+ operators.IMAGE_EDITOR_PLUS_OT_copy,
+ operators.IMAGE_EDITOR_PLUS_OT_paste,
+ operators.IMAGE_EDITOR_PLUS_OT_crop,
+ operators.IMAGE_EDITOR_PLUS_OT_deselect_layer,
+ operators.IMAGE_EDITOR_PLUS_OT_move_layer,
+ operators.IMAGE_EDITOR_PLUS_OT_delete_layer,
+ operators.IMAGE_EDITOR_PLUS_OT_change_image_layer_order,
+ operators.IMAGE_EDITOR_PLUS_OT_merge_layers,
+ operators.IMAGE_EDITOR_PLUS_OT_flip,
+ operators.IMAGE_EDITOR_PLUS_OT_rotate,
+ operators.IMAGE_EDITOR_PLUS_OT_scale,
+ operators.IMAGE_EDITOR_PLUS_OT_flip_layer,
+ operators.IMAGE_EDITOR_PLUS_OT_rotate_layer,
+ operators.IMAGE_EDITOR_PLUS_OT_rotate_layer_arbitrary,
+ operators.IMAGE_EDITOR_PLUS_OT_scale_layer,
+ operators.IMAGE_EDITOR_PLUS_OT_offset,
+ operators.IMAGE_EDITOR_PLUS_OT_adjust_color,
+ operators.IMAGE_EDITOR_PLUS_OT_adjust_brightness,
+ operators.IMAGE_EDITOR_PLUS_OT_adjust_gamma,
+ operators.IMAGE_EDITOR_PLUS_OT_adjust_color_curve,
+ operators.IMAGE_EDITOR_PLUS_OT_replace_color,
+ operators.IMAGE_EDITOR_PLUS_OT_blur,
+ operators.IMAGE_EDITOR_PLUS_OT_sharpen,
+ operators.IMAGE_EDITOR_PLUS_OT_add_noise,
+ operators.IMAGE_EDITOR_PLUS_OT_pixelize,
+ operators.IMAGE_EDITOR_PLUS_OT_make_seamless,
+ ui.IMAGE_EDITOR_PLUS_OT_scale_dialog,
+ ui.IMAGE_EDITOR_PLUS_OffsetPropertyGroup,
+ ui.IMAGE_EDITOR_PLUS_OT_offset_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_adjust_color_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_adjust_brightness_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_adjust_gamma_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_adjust_color_curve_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_replace_color_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_blur_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_sharpen_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_add_noise_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_pixelize_dialog,
+ ui.IMAGE_EDITOR_PLUS_OT_make_seamless_dialog,
+ ui.IMAGE_EDITOR_PLUS_UL_layer_list,
+ ui.IMAGE_EDITOR_PLUS_MT_edit_menu,
+ ui.IMAGE_EDITOR_PLUS_MT_layers_menu,
+ ui.IMAGE_EDITOR_PLUS_MT_transform_menu,
+ ui.IMAGE_EDITOR_PLUS_MT_transform_layer_menu,
+ ui.IMAGE_EDITOR_PLUS_MT_offset_menu,
+ ui.IMAGE_EDITOR_PLUS_MT_adjust_menu,
+ ui.IMAGE_EDITOR_PLUS_MT_filter_menu,
+ ui.IMAGE_EDITOR_PLUS_PT_select_panel,
+ ui.IMAGE_EDITOR_PLUS_PT_edit_panel,
+ ui.IMAGE_EDITOR_PLUS_PT_layers_panel,
+ ui.IMAGE_EDITOR_PLUS_PT_transform_panel,
+ ui.IMAGE_EDITOR_PLUS_PT_transform_layer_panel,
+ ui.IMAGE_EDITOR_PLUS_PT_offset_panel,
+ ui.IMAGE_EDITOR_PLUS_PT_adjust_panel,
+ ui.IMAGE_EDITOR_PLUS_PT_filter_panel
+]
+
+def register_keymaps():
+ session = app.get_session()
+
+ keymaps = session.keymaps
+
+ wm = bpy.context.window_manager
+
+ km = wm.keyconfigs.addon.keymaps.new(name='Image Generic', space_type='IMAGE_EDITOR')
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_make_selection.bl_idname,
+ 'B', 'PRESS')
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_cancel_selection.bl_idname,
+ 'D', 'PRESS', ctrl=True)
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_cut.bl_idname,
+ 'X', 'PRESS', ctrl=True)
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_copy.bl_idname,
+ 'C', 'PRESS', ctrl=True)
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_paste.bl_idname,
+ 'V', 'PRESS', ctrl=True)
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_fill_with_fg_color.bl_idname,
+ 'DEL', 'PRESS', ctrl=True)
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_clear.bl_idname,
+ 'DEL', 'PRESS')
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_move_layer.bl_idname,
+ 'G', 'PRESS')
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_rotate_layer_arbitrary.bl_idname,
+ 'R', 'PRESS')
+ kmi = km.keymap_items.new(operators.IMAGE_EDITOR_PLUS_OT_scale_layer.bl_idname,
+ 'C', 'PRESS')
+
+ keymaps.append(km)
+
+def unregister_keymaps():
+ session = app.get_session()
+
+ wm = bpy.context.window_manager
+
+ for km in session.keymaps:
+ wm.keyconfigs.addon.keymaps.remove(km)
+
+def register():
+ global draw_handler
+
+ api.API.VERSION = bl_info['version']
+
+ app.load_icons()
+
+ for cls in classes:
+ bpy.utils.register_class(cls)
+
+ bpy.types.IMAGE_MT_image.append(ui.menu_func)
+
+ bpy.app.handlers.save_pre.append(app.save_pre_handler)
+
+ draw_handler = bpy.types.SpaceImageEditor.draw_handler_add(
+ app.draw_handler, (), 'WINDOW', 'POST_PIXEL')
+
+ wm = bpy.types.WindowManager
+
+ wm.imageeditorplus_api = api.API
+ wm.imageeditorplus_properties = \
+ bpy.props.PointerProperty(type=app.IMAGE_EDITOR_PLUS_WindowPropertyGroup)
+ bpy.types.Image.imageeditorplus_properties = \
+ bpy.props.PointerProperty(type=app.IMAGE_EDITOR_PLUS_ImagePropertyGroup)
+
+ register_keymaps()
+
+def unregister():
+ global draw_handler
+
+ app.cleanup_scene()
+
+ app.dispose_icons()
+
+ for cls in classes:
+ bpy.utils.unregister_class(cls)
+
+ bpy.types.IMAGE_MT_image.remove(ui.menu_func)
+
+ bpy.app.handlers.save_pre.remove(app.save_pre_handler)
+
+ bpy.types.SpaceImageEditor.draw_handler_remove(draw_handler, 'WINDOW')
+
+ wm = bpy.types.WindowManager
+
+ del wm.imageeditorplus_properties
+ del bpy.types.Image.imageeditorplus_properties
+
+ unregister_keymaps()
+
+if __name__ == "__main__":
+ register()
+
+ draw_handler = None
diff --git a/addon/api.py b/addon/api.py
new file mode 100644
index 0000000..04ef25d
--- /dev/null
+++ b/addon/api.py
@@ -0,0 +1,103 @@
+'''
+ Copyright (C) 2021 Akaneyu
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+'''
+
+import bpy
+from . import app
+from . import utils
+
+class API:
+ VERSION = (0, 0, 0) # set by register()
+
+ @staticmethod
+ def read_pixels_from_image(img):
+ return utils.read_pixels_from_image(img)
+
+ @staticmethod
+ def write_pixels_to_image(img, pixels):
+ utils.write_pixels_to_image(img, pixels)
+
+ @staticmethod
+ def refresh_image(context):
+ app.refresh_image(context)
+
+ @staticmethod
+ def select_layer(img, layer):
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+
+ if layer and layer in layers:
+ img_props.selected_layer_index = layers.index(layer)
+ else:
+ img_props.selected_layer_index = -1
+
+ @staticmethod
+ def get_selected_layer(img):
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+ selected_layer_index = img_props.selected_layer_index
+
+ if selected_layer_index == -1 or selected_layer_index >= len(layers):
+ return None
+
+ return layers[selected_layer_index]
+
+ @staticmethod
+ def create_layer(base_img, pixels, img_settings={}, layer_settings={}):
+ img_settings_mod = {
+ 'is_float': img_settings.get('is_float', True),
+ 'colorspace_name': img_settings.get('colorspace_name', 'Linear')
+ }
+
+ layer_settings_mod = {
+ 'rotation': layer_settings.get('rotation', 0),
+ 'scale': layer_settings.get('scale', [1.0, 1.0]),
+ 'custom_data': layer_settings.get('custom_data', '{}')
+ }
+
+ app.create_layer(base_img, pixels, img_settings_mod, layer_settings_mod)
+
+ @staticmethod
+ def read_pixels_from_layer(layer):
+ layer_img = bpy.data.images.get(layer.name, None)
+ if not layer_img:
+ return 0, 0, None
+
+ layer_width, layer_height = layer_img.size[0], layer_img.size[1]
+
+ layer_pixels = utils.read_pixels_from_image(layer_img)
+
+ return layer_width, layer_height, layer_pixels
+
+ @staticmethod
+ def write_pixels_to_layer(layer, pixels):
+ layer_img = bpy.data.images.get(layer.name, None)
+ if not layer_img:
+ return
+
+ utils.write_pixels_to_image(layer_img, pixels)
+
+ @staticmethod
+ def scale_layer(layer, width, height):
+ layer_img = bpy.data.images.get(layer.name, None)
+ if not layer_img:
+ return
+
+ layer_img.scale(width, height)
+
+ @staticmethod
+ def update_layers(img):
+ app.rebuild_image_layers_nodes(img)
diff --git a/addon/app.py b/addon/app.py
new file mode 100644
index 0000000..bf95a54
--- /dev/null
+++ b/addon/app.py
@@ -0,0 +1,531 @@
+'''
+ Copyright (C) 2021 - 2023 Akaneyu
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+'''
+
+import os
+import bpy
+import bpy.utils.previews
+import blf
+import numpy as np
+from . ui_renderer import UIRenderer as UIRenderer
+from . import utils
+
+class AreaSession:
+ def __init__(self):
+ self.selection = None
+ self.selection_region = None
+ self.selecting = False
+ self.layer_moving = False
+ self.layer_rotating = False
+ self.layer_scaling = False
+ self.prevent_layer_update_event = False
+ self.prev_image = None
+
+class Session:
+ def __init__(self):
+ self.icons = None
+ self.keymaps = []
+ self.cached_image_pixels = None
+ self.cached_image_hsl = None
+ self.cached_layer_location = None
+ self.ui_renderer = None
+ self.copied_image_pixels = None
+ self.copied_image_settings = None
+ self.copied_layer_settings = None
+ self.areas = {}
+
+def get_session():
+ global session
+
+ return session
+
+def get_area_session(context):
+ area_session = session.areas.get(context.area, None)
+ if not area_session:
+ area_session = AreaSession()
+ session.areas[context.area] = area_session
+
+ return area_session
+
+def draw_handler():
+ global session
+
+ context = bpy.context
+ area_session = get_area_session(context)
+
+ info_text = None
+
+ width, height, img_name = 0, 0, ''
+ img = context.area.spaces.active.image
+ if img:
+ width, height = img.size[0], img.size[1]
+
+ # render selection frame
+ if img and area_session.selection or area_session.selection_region:
+ if area_session.selection:
+ selection = area_session.selection
+ view_x1 = selection[0][0] / width
+ view_y1 = selection[0][1] / height
+ view_x2 = selection[1][0] / width
+ view_y2 = selection[1][1] / height
+
+ region_pos1 = context.region.view2d.view_to_region(view_x1, view_y1, clip=False)
+ region_pos2 = context.region.view2d.view_to_region(view_x2, view_y2, clip=False)
+ else:
+ region_pos1, region_pos2 = area_session.selection_region
+
+ if not session.ui_renderer:
+ session.ui_renderer = UIRenderer()
+
+ region_size = [region_pos2[0] - region_pos1[0],
+ region_pos2[1] - region_pos1[1]]
+
+ session.ui_renderer.render_selection_frame(region_pos1, region_size)
+
+ # render layers
+ if img:
+ if not session.ui_renderer:
+ session.ui_renderer = UIRenderer()
+
+ img_props = img.imageeditorplus_properties
+ selected_layer_index = img_props.selected_layer_index
+ layers = img_props.layers
+
+ for i, layer in reversed(list(enumerate(layers))):
+ layer_img = bpy.data.images.get(layer.name, None)
+ if layer_img:
+ layer_width, layer_height = layer_img.size[0], layer_img.size[1]
+ layer_pos = layer.location
+ layer_pos1 = [layer_pos[0], layer_pos[1] + layer_height]
+ layer_pos2 = [layer_pos[0] + layer_width, layer_pos[1]]
+
+ layer_view_x1 = layer_pos1[0] / width
+ layer_view_y1 = 1.0 - layer_pos1[1] / height
+ layer_region_pos1 = context.region.view2d.view_to_region(
+ layer_view_x1, layer_view_y1, clip=False)
+
+ layer_view_x2 = layer_pos2[0] / width
+ layer_view_y2 = 1.0 - layer_pos2[1] / height
+ layer_region_pos2 = context.region.view2d.view_to_region(
+ layer_view_x2, layer_view_y2, clip=False)
+
+ layer_region_size = [layer_region_pos2[0] - layer_region_pos1[0],
+ layer_region_pos2[1] - layer_region_pos1[1]]
+
+ if not layer.hide:
+ session.ui_renderer.render_image(layer_img, layer_region_pos1,
+ layer_region_size, layer.rotation, layer.scale)
+
+ if i == selected_layer_index:
+ session.ui_renderer.render_selection_frame(
+ layer_region_pos1, layer_region_size, layer.rotation,
+ layer.scale)
+
+ # release the selection if the image is changed
+ if area_session.selection or area_session.selection_region:
+ if img != area_session.prev_image:
+ cancel_selection(context)
+
+ elif width != area_session.prev_image_width \
+ or height != area_session.prev_image_height:
+
+ crop_selection(context)
+
+ area_session.prev_image = img
+ area_session.prev_image_width = width
+ area_session.prev_image_height = height
+
+ if area_session.layer_moving \
+ or area_session.layer_rotating \
+ or area_session.layer_scaling:
+
+ info_text = "LMB: Perform\n" \
+ + "RMB: Cancel"
+
+ area_height = context.area.height
+
+ # info text
+ if info_text:
+ blf.enable(0, blf.WORD_WRAP)
+ blf.word_wrap(0, 100)
+
+ blf.position(0, 30, area_height - 70, 0)
+ blf.size(0, 14, 72)
+ blf.draw(0, info_text)
+
+ blf.disable(0, blf.WORD_WRAP)
+
+def get_curve_node():
+ node_group = bpy.data.node_groups.get('imageeditorplus')
+ if not node_group:
+ node_group = bpy.data.node_groups.new('imageeditorplus', 'ShaderNodeTree')
+
+ nodes = node_group.nodes
+
+ curve_node = next((node for node in nodes if node.bl_idname == 'ShaderNodeRGBCurve'),
+ None)
+ if not curve_node:
+ curve_node = nodes.new('ShaderNodeRGBCurve')
+
+ return curve_node
+
+def get_curve_mapping():
+ return get_curve_node().mapping
+
+def reset_curve_mapping():
+ curve_mapping = get_curve_mapping()
+
+ for curve in curve_mapping.curves:
+ curve_points = curve.points
+ num_curve_points = len(curve_points)
+
+ # remove extra points (> 2)
+ if num_curve_points > 2:
+ for i in range(num_curve_points - 2):
+ curve_points.remove(curve_points[2])
+
+ curve_points[0].location[0] = 0
+ curve_points[0].location[1] = 0
+ curve_points[0].select = False
+ curve_points[1].location[0] = 1.0
+ curve_points[1].location[1] = 1.0
+ curve_points[1].select = False
+
+ curve_mapping.update()
+
+def get_active_layer(context):
+ img = context.area.spaces.active.image
+ if not img:
+ return None
+
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+ selected_layer_index = img_props.selected_layer_index
+
+ if selected_layer_index == -1 or selected_layer_index >= len(layers):
+ return None
+
+ return layers[selected_layer_index]
+
+def get_target_image(context):
+ layer = get_active_layer(context)
+ if layer:
+ return bpy.data.images.get(layer.name, None)
+ else:
+ return context.area.spaces.active.image
+
+def cache_image(img, need_hsl=False):
+ global session
+
+ pixels = utils.read_pixels_from_image(img)
+
+ session.cached_image_pixels = pixels
+
+ hsl = None
+ if need_hsl:
+ session.cached_image_hsl = utils.rgb_to_hsl(pixels)
+
+ return pixels, hsl
+
+def get_image_cache():
+ global session
+
+ pixels = session.cached_image_pixels
+ hsl = session.cached_image_hsl
+
+ if pixels is not None:
+ pixels = pixels.copy()
+ if hsl is not None:
+ hsl = hsl.copy()
+
+ return pixels, hsl
+
+def revert_image_cache(img):
+ global session
+
+ pixels = session.cached_image_pixels
+ if pixels is None:
+ return
+
+ utils.write_pixels_to_image(img, pixels)
+
+def clear_image_cache():
+ global session
+
+ session.cached_image_pixels = None
+ session.cached_image_hsl = None
+
+def convert_selection(context):
+ area_session = get_area_session(context)
+
+ img = context.area.spaces.active.image
+ if not img:
+ return
+
+ width, height = img.size[0], img.size[1]
+
+ selection_region = area_session.selection_region
+ if not selection_region:
+ return
+
+ x1, y1 = context.region.view2d.region_to_view(*selection_region[0])
+ x2, y2 = context.region.view2d.region_to_view(*selection_region[1])
+
+ x1, x2 = sorted((x1, x2))
+ y1, y2 = sorted((y1, y2))
+
+ x1 = round(x1 * width)
+ y1 = round(y1 * height)
+ x2 = round(x2 * width)
+ y2 = round(y2 * height)
+
+ area_session.selection = [[x1, y1], [x2, y2]]
+
+ crop_selection(context)
+
+def crop_selection(context):
+ area_session = get_area_session(context)
+
+ img = context.area.spaces.active.image
+ if not img:
+ return
+
+ width, height = img.size[0], img.size[1]
+
+ if not area_session.selection:
+ return
+
+ [x1, y1], [x2, y2] = area_session.selection
+
+ # clamp
+ x1 = max(min(x1, width), 0)
+ y1 = max(min(y1, height), 0)
+ x2 = max(min(x2, width), 0)
+ y2 = max(min(y2, height), 0)
+
+ # avoid from zero width or height
+ if x2 - x1 <= 0:
+ if x2 < width:
+ x2 = x2 + 1
+ else:
+ x1 = x1 - 1
+
+ if y2 - y1 <= 0:
+ if y2 < height:
+ y2 = y2 + 1
+ else:
+ y1 = y1 - 1
+
+ area_session.selection = [[x1, y1], [x2, y2]]
+
+def cancel_selection(context):
+ global session
+
+ area_session = get_area_session(context)
+
+ area_session.selection = None
+ area_session.selection_region = None
+
+def get_selection(context):
+ global session
+
+ area_session = get_area_session(context)
+
+ return area_session.selection
+
+def get_target_selection(context):
+ global session
+
+ area_session = get_area_session(context)
+
+ selection = area_session.selection
+ if not selection:
+ return None
+
+ img = context.area.spaces.active.image
+ if not img:
+ return selection
+
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+ selected_layer_index = img_props.selected_layer_index
+
+ if selected_layer_index == -1 or selected_layer_index >= len(layers):
+ return selection
+
+ return None
+
+def refresh_image(context):
+ wm = context.window_manager
+
+ img = context.area.spaces.active.image
+ if not img:
+ return
+
+ context.area.spaces.active.image = img
+
+ img.update()
+
+ if not hasattr(wm, 'imagelayersnode_api') or wm.imagelayersnode_api.VERSION < (1, 1, 0):
+ return
+
+ wm.imagelayersnode_api.update_pasted_layer_nodes(img)
+
+def apply_layer_transform(img, rot, scale):
+ global session
+
+ if not session.ui_renderer:
+ session.ui_renderer = UIRenderer()
+
+ buff, width, height = session.ui_renderer.render_image_offscreen(img, rot, scale)
+
+ pixels = np.reshape(buff, (height, width, 4)).astype(np.float32) / 255.0
+
+ # gamma correction
+ utils.convert_colorspace(pixels, 'Linear',
+ 'Linear' if img.is_float else img.colorspace_settings.name)
+
+ return pixels, width, height
+
+def create_layer(base_img, pixels, img_settings, layer_settings):
+ base_width, base_height = base_img.size
+
+ target_width, target_height = pixels.shape[1], pixels.shape[0]
+
+ layer_img_prefix = '#layer'
+ layer_img_name = base_img.name + layer_img_prefix
+ layer_img = bpy.data.images.new(layer_img_name, width=target_width, height=target_height,
+ alpha=True, float_buffer=base_img.is_float)
+ layer_img.colorspace_settings.name = base_img.colorspace_settings.name
+
+ # gamma correction
+ pixels = pixels.copy()
+ utils.convert_colorspace(pixels,
+ 'Linear' if img_settings['is_float']
+ else img_settings['colorspace_name'],
+ 'Linear' if base_img.is_float else base_img.colorspace_settings.name)
+
+ utils.write_pixels_to_image(layer_img, pixels)
+
+ layer_img.use_fake_user = True
+ layer_img.pack()
+
+ img_props = base_img.imageeditorplus_properties
+ layers = img_props.layers
+ layer = layers.add()
+
+ layer.name = layer_img.name
+ layer.location = [int((base_width - target_width) / 2.0),
+ int((base_height - target_height) / 2.0)]
+
+ layer_img_postfix = \
+ layer_img.name[layer_img.name.rfind(layer_img_prefix) + len(layer_img_prefix):]
+ if layer_img_postfix:
+ layer.label = 'Pasted Layer ' + layer_img_postfix
+ else:
+ layer.label = 'Pasted Layer'
+
+ if layer_settings:
+ layer.rotation = layer_settings['rotation']
+ layer.scale = layer_settings['scale']
+ layer.custom_data = layer_settings['custom_data']
+
+ layers.move(len(layers) - 1, 0)
+ img_props.selected_layer_index = 0
+
+ rebuild_image_layers_nodes(base_img)
+
+def rebuild_image_layers_nodes(img):
+ wm = bpy.context.window_manager
+
+ if not hasattr(wm, 'imagelayersnode_api') or wm.imagelayersnode_api.VERSION < (1, 1, 0):
+ return
+
+ wm.imagelayersnode_api.rebuild_image_layers_nodes(img)
+
+def on_layer_placement_changed(self, context):
+ area_session = get_area_session(context)
+ if area_session.prevent_layer_update_event:
+ return
+
+ img = context.area.spaces.active.image
+ if not img:
+ return
+
+ rebuild_image_layers_nodes(img)
+
+def on_layer_visible_changed(self, context):
+ refresh_image(context)
+
+def on_selected_layer_index_changed(self, context):
+ if self.selected_layer_index != -1:
+ cancel_selection(context)
+
+def load_icons():
+ global session
+
+ script_dir = os.path.dirname(os.path.realpath(__file__))
+ icons = bpy.utils.previews.new()
+
+ icons_dir = os.path.join(script_dir, "icons")
+ for file_name in os.listdir(icons_dir):
+ icon_name = os.path.splitext(file_name)[0]
+ icons.load(icon_name, os.path.join(icons_dir, file_name), 'IMAGE')
+
+ session.icons = icons
+
+def dispose_icons():
+ global session
+
+ bpy.utils.previews.remove(session.icons)
+
+def cleanup_scene():
+ node_group = bpy.data.node_groups.get('imageeditorplus')
+ if node_group:
+ bpy.data.node_groups.remove(node_group)
+
+@bpy.app.handlers.persistent
+def save_pre_handler(args):
+ cleanup_scene()
+
+ for img in bpy.data.images:
+ if img.source != 'VIEWER':
+ if img.is_dirty:
+ if img.packed_files or not img.filepath:
+ img.pack()
+ else:
+ img.save()
+
+class IMAGE_EDITOR_PLUS_WindowPropertyGroup(bpy.types.PropertyGroup):
+ foreground_color: bpy.props.FloatVectorProperty(name='Foreground Color', subtype='COLOR_GAMMA',
+ min=0, max=1.0, size=3, default=(1.0, 1.0, 1.0))
+ background_color: bpy.props.FloatVectorProperty(name='Background Color', subtype='COLOR_GAMMA',
+ min=0, max=1.0, size=3, default=(0, 0, 0))
+
+class IMAGE_EDITOR_PLUS_LayerPropertyGroup(bpy.types.PropertyGroup):
+ location: bpy.props.IntVectorProperty(size=2, update=on_layer_placement_changed)
+ rotation: bpy.props.FloatProperty(subtype='ANGLE', update=on_layer_placement_changed)
+ scale: bpy.props.FloatVectorProperty(size=2, default=(1.0, 1.0),
+ update=on_layer_placement_changed)
+ label: bpy.props.StringProperty()
+ hide: bpy.props.BoolProperty(name='Hide', update=on_layer_visible_changed)
+ custom_data: bpy.props.StringProperty(default='{}')
+
+class IMAGE_EDITOR_PLUS_ImagePropertyGroup(bpy.types.PropertyGroup):
+ layers: bpy.props.CollectionProperty(type=IMAGE_EDITOR_PLUS_LayerPropertyGroup)
+ selected_layer_index: bpy.props.IntProperty(update=on_selected_layer_index_changed)
+
+session = Session()
diff --git a/addon/icons/adjust.png b/addon/icons/adjust.png
new file mode 100644
index 0000000..c8b8208
Binary files /dev/null and b/addon/icons/adjust.png differ
diff --git a/addon/icons/check.png b/addon/icons/check.png
new file mode 100644
index 0000000..bac05b3
Binary files /dev/null and b/addon/icons/check.png differ
diff --git a/addon/icons/clear.png b/addon/icons/clear.png
new file mode 100644
index 0000000..fe73a76
Binary files /dev/null and b/addon/icons/clear.png differ
diff --git a/addon/icons/color_curve.png b/addon/icons/color_curve.png
new file mode 100644
index 0000000..19e7f49
Binary files /dev/null and b/addon/icons/color_curve.png differ
diff --git a/addon/icons/crop.png b/addon/icons/crop.png
new file mode 100644
index 0000000..6435440
Binary files /dev/null and b/addon/icons/crop.png differ
diff --git a/addon/icons/cut.png b/addon/icons/cut.png
new file mode 100644
index 0000000..8350ea7
Binary files /dev/null and b/addon/icons/cut.png differ
diff --git a/addon/icons/deselect.png b/addon/icons/deselect.png
new file mode 100644
index 0000000..19cebc1
Binary files /dev/null and b/addon/icons/deselect.png differ
diff --git a/addon/icons/fill.png b/addon/icons/fill.png
new file mode 100644
index 0000000..049bd20
Binary files /dev/null and b/addon/icons/fill.png differ
diff --git a/addon/icons/filter.png b/addon/icons/filter.png
new file mode 100644
index 0000000..1366859
Binary files /dev/null and b/addon/icons/filter.png differ
diff --git a/addon/icons/flip_horz.png b/addon/icons/flip_horz.png
new file mode 100644
index 0000000..fc9b5ca
Binary files /dev/null and b/addon/icons/flip_horz.png differ
diff --git a/addon/icons/flip_vert.png b/addon/icons/flip_vert.png
new file mode 100644
index 0000000..0470876
Binary files /dev/null and b/addon/icons/flip_vert.png differ
diff --git a/addon/icons/move.png b/addon/icons/move.png
new file mode 100644
index 0000000..a515ff2
Binary files /dev/null and b/addon/icons/move.png differ
diff --git a/addon/icons/offset.png b/addon/icons/offset.png
new file mode 100644
index 0000000..9075c59
Binary files /dev/null and b/addon/icons/offset.png differ
diff --git a/addon/icons/rot_left.png b/addon/icons/rot_left.png
new file mode 100644
index 0000000..41f7abc
Binary files /dev/null and b/addon/icons/rot_left.png differ
diff --git a/addon/icons/rot_right.png b/addon/icons/rot_right.png
new file mode 100644
index 0000000..a94a478
Binary files /dev/null and b/addon/icons/rot_right.png differ
diff --git a/addon/icons/rotate.png b/addon/icons/rotate.png
new file mode 100644
index 0000000..bc52b90
Binary files /dev/null and b/addon/icons/rotate.png differ
diff --git a/addon/icons/scale.png b/addon/icons/scale.png
new file mode 100644
index 0000000..0aea174
Binary files /dev/null and b/addon/icons/scale.png differ
diff --git a/addon/icons/select.png b/addon/icons/select.png
new file mode 100644
index 0000000..c195f1d
Binary files /dev/null and b/addon/icons/select.png differ
diff --git a/addon/operators.py b/addon/operators.py
new file mode 100644
index 0000000..10380c8
--- /dev/null
+++ b/addon/operators.py
@@ -0,0 +1,1564 @@
+'''
+ Copyright (C) 2021 - 2023 Akaneyu
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+'''
+
+import math
+import bpy
+import numpy as np
+from . import app
+from . import utils
+
+class IMAGE_EDITOR_PLUS_OT_make_selection(bpy.types.Operator):
+ """Make a selection on the image"""
+ bl_idname = "image_editor_plus.make_selection"
+ bl_label = "Make Selection"
+
+ def __init__(self):
+ self.lmb = False
+
+ def modal(self, context, event):
+ area_session = app.get_area_session(context)
+
+ context.area.tag_redraw()
+
+ img = context.area.spaces.active.image
+ width, height = img.size[0], img.size[1]
+
+ if event.type == 'MOUSEMOVE':
+ if self.lmb:
+ region_pos = [event.mouse_region_x, event.mouse_region_y]
+
+ if area_session.selection_region:
+ area_session.selection_region[1] = region_pos
+ else:
+ area_session.selection_region = [region_pos, region_pos]
+
+ elif event.type == 'LEFTMOUSE':
+ if event.value == 'PRESS':
+ self.lmb = True
+ elif event.value == 'RELEASE':
+ self.lmb = False
+
+ if area_session.selection_region:
+ area_session.selecting = False
+
+ app.convert_selection(context)
+
+ # deselect layer
+ img_props = img.imageeditorplus_properties
+ img_props.selected_layer_index = -1
+
+ return {'FINISHED'}
+
+ elif event.type in {'RIGHTMOUSE', 'ESC'}:
+ area_session.selection = None
+ area_session.selection_region = None
+ area_session.selecting = False
+
+ return {'CANCELLED'}
+
+ return {'RUNNING_MODAL'}
+
+ def invoke(self, context, event):
+ area_session = app.get_area_session(context)
+
+ if context.area.type != 'IMAGE_EDITOR':
+ return {'CANCELLED'}
+
+ area_session.selection = None
+ area_session.selection_region = None
+ area_session.selecting = True
+
+ context.window_manager.modal_handler_add(self)
+
+ return {'RUNNING_MODAL'}
+
+class IMAGE_EDITOR_PLUS_OT_cancel_selection(bpy.types.Operator):
+ """Cancel the selection"""
+ bl_idname = "image_editor_plus.cancel_selection"
+ bl_label = "Cancel Selection"
+
+ def execute(self, context):
+ area_session = app.get_area_session(context)
+
+ if not area_session.selection:
+ return {'CANCELLED'}
+
+ app.cancel_selection(context)
+
+ context.area.tag_redraw()
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_swap_colors(bpy.types.Operator):
+ """Swap foreground and background color"""
+ bl_idname = "image_editor_plus.swap_colors"
+ bl_label = "Swap Colors"
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ props = wm.imageeditorplus_properties
+
+ props.foreground_color, props.background_color = \
+ props.background_color[:], props.foreground_color[:]
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_fill_with_fg_color(bpy.types.Operator):
+ """Fill the image with foreground color"""
+ bl_idname = "image_editor_plus.fill_with_fg_color"
+ bl_label = "Fill with FG Color"
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ props = wm.imageeditorplus_properties
+ color = props.foreground_color[:] + (1.0,)
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ pixels = utils.read_pixels_from_image(img)
+
+ selection = app.get_target_selection(context)
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = color
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ pixels[:] = color
+
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_fill_with_bg_color(bpy.types.Operator):
+ """Fill the image with background color"""
+ bl_idname = "image_editor_plus.fill_with_bg_color"
+ bl_label = "Fill with BG Color"
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ props = wm.imageeditorplus_properties
+ color = props.background_color[:] + (1.0,)
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ pixels = utils.read_pixels_from_image(img)
+
+ selection = app.get_target_selection(context)
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = color
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ pixels[:] = color
+
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_clear(bpy.types.Operator):
+ """Clear the image"""
+ bl_idname = "image_editor_plus.clear"
+ bl_label = "Clear"
+
+ def execute(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ pixels = utils.read_pixels_from_image(img)
+
+ selection = app.get_target_selection(context)
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = (0, 0, 0, 0)
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ pixels[:] = (0, 0, 0, 0)
+
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_cut(bpy.types.Operator):
+ """Cut the image"""
+ bl_idname = "image_editor_plus.cut"
+ bl_label = "Cut"
+
+ def execute(self, context):
+ session = app.get_session()
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ width, height = img.size
+ pixels = utils.read_pixels_from_image(img)
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ session.copied_image_pixels = target_pixels.copy()
+
+ session.copied_image_settings = {
+ 'is_float': img.is_float,
+ 'colorspace_name': img.colorspace_settings.name
+ }
+
+ layer = app.get_active_layer(context)
+ if layer:
+ session.copied_layer_settings = {
+ 'rotation': layer.rotation,
+ 'scale': layer.scale,
+ 'custom_data': layer.custom_data
+ }
+ else:
+ session.copied_layer_settings = None
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = (0, 0, 0, 0)
+ else:
+ pixels[:] = (0, 0, 0, 0)
+
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ self.report({'INFO'}, 'Cut selected image.')
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_copy(bpy.types.Operator):
+ """Copy the image"""
+ bl_idname = "image_editor_plus.copy"
+ bl_label = "Copy"
+
+ def execute(self, context):
+ session = app.get_session()
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ width, height = img.size
+ pixels = utils.read_pixels_from_image(img)
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ session.copied_image_pixels = target_pixels
+
+ session.copied_image_settings = {
+ 'is_float': img.is_float,
+ 'colorspace_name': img.colorspace_settings.name
+ }
+
+ layer = app.get_active_layer(context)
+ if layer:
+ session.copied_layer_settings = {
+ 'rotation': layer.rotation,
+ 'scale': layer.scale,
+ 'custom_data': layer.custom_data
+ }
+ else:
+ session.copied_layer_settings = None
+
+ self.report({'INFO'}, 'Copied selected image.')
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_paste(bpy.types.Operator):
+ """Paste the image"""
+ bl_idname = "image_editor_plus.paste"
+ bl_label = "Paste"
+
+ def execute(self, context):
+ session = app.get_session()
+
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ target_pixels = session.copied_image_pixels
+ if target_pixels is None:
+ return {'CANCELLED'}
+
+ app.create_layer(img, target_pixels, session.copied_image_settings,
+ session.copied_layer_settings)
+
+ app.cancel_selection(context)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_crop(bpy.types.Operator):
+ """Crop the image to the boundary of the selection"""
+ bl_idname = "image_editor_plus.crop"
+ bl_label = "Crop"
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ pixels = utils.read_pixels_from_image(img)
+
+ selection = app.get_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ else:
+ target_pixels = pixels
+
+ target_width, target_height = target_pixels.shape[1], target_pixels.shape[0]
+
+ img.scale(target_width, target_height)
+
+ utils.write_pixels_to_image(img, target_pixels)
+
+ if selection:
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+
+ for layer in reversed(layers):
+ layer_pos = layer.location
+ layer_pos[0] -= selection[0][0]
+ layer_pos[1] -= selection[0][1]
+
+ app.cancel_selection(context)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_deselect_layer(bpy.types.Operator):
+ bl_idname = "image_editor_plus.deselect_layer"
+ bl_label = "Deselect Layer"
+
+ def execute(self, context):
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ img_props = img.imageeditorplus_properties
+ img_props.selected_layer_index = -1
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_move_layer(bpy.types.Operator):
+ """Move the layer"""
+ bl_idname = "image_editor_plus.move_layer"
+ bl_label = "Move Layer"
+
+ def __init__(self):
+ self.start_input_position = [0, 0]
+ self.start_layer_location = [0, 0]
+
+ def modal(self, context, event):
+ area_session = app.get_area_session(context)
+
+ context.area.tag_redraw()
+
+ img = context.area.spaces.active.image
+ width, height = img.size[0], img.size[1]
+
+ layer = app.get_active_layer(context)
+ if not layer:
+ return {'CANCELLED'}
+
+ layer_width, layer_height = 1, 1
+ layer_img = bpy.data.images.get(layer.name, None)
+ if layer_img:
+ layer_width, layer_height = layer_img.size[0], layer_img.size[1]
+
+ if event.type == 'MOUSEMOVE':
+ region_pos = [event.mouse_region_x, event.mouse_region_y]
+ view_x, view_y = context.region.view2d.region_to_view(*region_pos)
+
+ target_x = width * view_x
+ target_y = height * view_y
+
+ layer.location[0] = int(self.start_layer_location[0] \
+ + target_x - self.start_input_position[0])
+ layer.location[1] = int(self.start_layer_location[1] \
+ - (target_y - self.start_input_position[1]))
+ elif event.type == 'LEFTMOUSE':
+ app.rebuild_image_layers_nodes(img)
+
+ area_session.layer_moving = False
+ area_session.prevent_layer_update_event = False
+
+ return {'FINISHED'}
+ elif event.type in {'RIGHTMOUSE', 'ESC'}:
+ layer.location = self.start_layer_location
+
+ area_session.layer_moving = False
+ area_session.prevent_layer_update_event = False
+
+ return {'CANCELLED'}
+
+ return {'RUNNING_MODAL'}
+
+ def invoke(self, context, event):
+ area_session = app.get_area_session(context)
+
+ img = context.area.spaces.active.image
+ width, height = img.size[0], img.size[1]
+
+ layer = app.get_active_layer(context)
+ if not layer:
+ return {'CANCELLED'}
+
+ region_pos = [event.mouse_region_x, event.mouse_region_y]
+ view_x, view_y = context.region.view2d.region_to_view(*region_pos)
+
+ self.start_input_position = [width * view_x, height * view_y]
+ self.start_layer_location = layer.location[:]
+
+ area_session.layer_moving = True
+ area_session.prevent_layer_update_event = True
+
+ context.window_manager.modal_handler_add(self)
+
+ return {'RUNNING_MODAL'}
+
+class IMAGE_EDITOR_PLUS_OT_delete_layer(bpy.types.Operator):
+ bl_idname = "image_editor_plus.delete_layer"
+ bl_label = "Delete Layer"
+
+ def execute(self, context):
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+ selected_layer_index = img_props.selected_layer_index
+
+ if selected_layer_index == -1 or selected_layer_index >= len(layers):
+ return {'CANCELLED'}
+
+ layer = layers[selected_layer_index]
+
+ layer_img = bpy.data.images.get(layer.name, None)
+ if layer_img:
+ bpy.data.images.remove(layer_img)
+
+ layers.remove(selected_layer_index)
+
+ selected_layer_index = min(max(selected_layer_index, 0), len(layers) - 1)
+ img_props.selected_layer_index = selected_layer_index
+
+ app.rebuild_image_layers_nodes(img)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_change_image_layer_order(bpy.types.Operator):
+ bl_idname = "image_editor_plus.change_image_layer_order"
+ bl_label = "Change Image Layer Order"
+ up: bpy.props.BoolProperty()
+
+ def execute(self, context):
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+ selected_layer_index = img_props.selected_layer_index
+
+ if selected_layer_index == -1 or selected_layer_index >= len(layers):
+ return {'CANCELLED'}
+
+ if (self.up and selected_layer_index == 0) \
+ or (not self.up and selected_layer_index >= len(layers) - 1):
+ return {'CANCELLED'}
+
+ layer = layers[selected_layer_index]
+
+ new_layer_index = selected_layer_index + (-1 if self.up else 1)
+ layers.move(selected_layer_index, new_layer_index)
+ img_props.selected_layer_index = new_layer_index
+
+ app.rebuild_image_layers_nodes(img)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_merge_layers(bpy.types.Operator):
+ """Merge all layers"""
+ bl_idname = "image_editor_plus.merge_layers"
+ bl_label = "Merge Layers"
+
+ def execute(self, context):
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ width, height = img.size
+ pixels = utils.read_pixels_from_image(img)
+
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+
+ for layer in reversed(layers):
+ layer_img = bpy.data.images.get(layer.name, None)
+ if not layer_img:
+ continue
+
+ layer_width, layer_height = layer_img.size[0], layer_img.size[1]
+
+ layer_pos = layer.location
+ layer_x1, layer_y1 = layer_pos[0], height - layer_height - layer_pos[1]
+
+ if layer.rotation == 0 and layer.scale[0] == 1.0 and layer.scale[1] == 1.0:
+ layer_pixels = utils.read_pixels_from_image(layer_img)
+ else:
+ layer_pixels, new_layer_width, new_layer_height = \
+ app.apply_layer_transform(layer_img, layer.rotation, layer.scale)
+
+ layer_x1 = int(layer_x1 - (new_layer_width - layer_width) / 2.0)
+ layer_y1 = int(layer_y1 - (new_layer_height - layer_height) / 2.0)
+ layer_width = new_layer_width
+ layer_height = new_layer_height
+
+ layer_x2 = layer_x1 + layer_width
+ layer_y2 = layer_y1 + layer_height
+
+ # clamp
+ target_x1 = max(min(layer_x1, width), 0)
+ target_y1 = max(min(layer_y1, height), 0)
+ target_x2 = max(min(layer_x2, width), 0)
+ target_y2 = max(min(layer_y2, height), 0)
+
+ # completely out of the base image
+ if layer_x1 == layer_x2 or layer_y1 == layer_y2:
+ continue
+
+ src_x1 = target_x1 - layer_x1
+ src_y1 = target_y1 - layer_y1
+ src_x2 = layer_width - (layer_x2 - target_x2)
+ src_y2 = layer_height - (layer_y2 - target_y2)
+
+ target_range = pixels[target_y1:target_y2, target_x1:target_x2]
+ target_color_chan = target_range[:, :, :3]
+ target_alpha_chan = target_range[:, :, 3:4]
+
+ layer_range = layer_pixels[src_y1:src_y2, src_x1:src_x2]
+ layer_color_chan = layer_range[:, :, :3]
+ layer_alpha_chan = layer_range[:, :, 3:4]
+
+ # composite
+ temp_alpha_chan = target_alpha_chan * (1.0 - layer_alpha_chan) \
+ + layer_alpha_chan
+ temp_alpha_chan_safe = \
+ np.where(temp_alpha_chan == 0, 1.0, temp_alpha_chan) # for div by 0
+ pixels[target_y1:target_y2, target_x1:target_x2, :3] \
+ = (target_color_chan * target_alpha_chan * (1.0 - layer_alpha_chan) \
+ + layer_color_chan * layer_alpha_chan) \
+ / temp_alpha_chan_safe
+ pixels[target_y1:target_y2, target_x1:target_x2, 3:4] = temp_alpha_chan
+
+ bpy.data.images.remove(layer_img)
+
+ utils.write_pixels_to_image(img, pixels)
+
+ layers.clear()
+
+ app.rebuild_image_layers_nodes(img)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_flip(bpy.types.Operator):
+ """Flip the image"""
+ bl_idname = "image_editor_plus.flip"
+ bl_label = "Flip"
+ is_vertically: bpy.props.BoolProperty(name="Vertically", default=False)
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ width, height = img.size
+ pixels = utils.read_pixels_from_image(img)
+
+ if self.is_vertically:
+ new_pixels = np.flipud(pixels)
+ else:
+ new_pixels = np.fliplr(pixels)
+
+ utils.write_pixels_to_image(img, new_pixels)
+
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+
+ for layer in reversed(layers):
+ layer_img = bpy.data.images.get(layer.name, None)
+ if not layer_img:
+ continue
+
+ layer_width, layer_height = layer_img.size[0], layer_img.size[1]
+
+ layer_pos = layer.location
+
+ if self.is_vertically:
+ layer_pos[1] = height - layer_pos[1] - layer_height
+ layer.scale[1] *= -1.0
+ else:
+ layer_pos[0] = width - layer_pos[0] - layer_width
+ layer.scale[0] *= -1.0
+
+ layer.rotation = -layer.rotation
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_rotate(bpy.types.Operator):
+ """Rotate the image"""
+ bl_idname = "image_editor_plus.rotate"
+ bl_label = "Rotate"
+ is_left: bpy.props.BoolProperty(name="Left", default=False)
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ width, height = img.size
+ pixels = utils.read_pixels_from_image(img)
+
+ if self.is_left:
+ new_pixels = np.rot90(pixels, 3)
+ else:
+ new_pixels = np.rot90(pixels)
+
+ img.scale(height, width)
+
+ utils.write_pixels_to_image(img, new_pixels)
+
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+
+ for layer in reversed(layers):
+ layer_img = bpy.data.images.get(layer.name, None)
+ if not layer_img:
+ continue
+
+ layer_width, layer_height = layer_img.size[0], layer_img.size[1]
+
+ layer_pos = layer.location
+ prev_layer_x, prev_layer_y = layer_pos[0], layer_pos[1]
+
+ if self.is_left:
+ layer_pos[0] = height - prev_layer_y - layer_height \
+ - (layer_width - layer_height) / 2.0
+ layer_pos[1] = prev_layer_x + (layer_width - layer_height) / 2.0
+ layer.rotation += math.pi / 2.0
+ else:
+ layer_pos[0] = prev_layer_y - (layer_width - layer_height) / 2.0
+ layer_pos[1] = width - prev_layer_x - layer_width \
+ + (layer_width - layer_height) / 2.0
+ layer.rotation -= math.pi / 2.0
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_scale(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = "image_editor_plus.scale"
+ bl_label = "Scale"
+ width: bpy.props.IntProperty()
+ height: bpy.props.IntProperty()
+ scale_layers: bpy.props.BoolProperty()
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ width, height = img.size
+
+ img.scale(self.width, self.height)
+
+ # scaling layers does not work unless the aspect rate is 1:1
+ if self.scale_layers:
+ scale_x = self.width / width
+ scale_y = self.height / height
+
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+
+ for layer in reversed(layers):
+ layer_img = bpy.data.images.get(layer.name, None)
+ if not layer_img:
+ continue
+
+ layer_width, layer_height = layer_img.size[0], layer_img.size[1]
+
+ layer_scale = layer.scale
+ layer_scale[0] *= scale_x
+ layer_scale[1] *= scale_y
+
+ layer_pos = layer.location
+ layer_pos[0] = layer_pos[0] * scale_x - layer_width * (1.0 - scale_x) / 2.0
+ layer_pos[1] = layer_pos[1] * scale_y - layer_height * (1.0 - scale_y) / 2.0
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_flip_layer(bpy.types.Operator):
+ """Flip the layer"""
+ bl_idname = "image_editor_plus.flip_layer"
+ bl_label = "Flip Layer"
+ is_vertically: bpy.props.BoolProperty(name="Vertically", default=False)
+
+ def execute(self, context):
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ layer = app.get_active_layer(context)
+ if not layer:
+ return {'CANCELLED'}
+
+ if self.is_vertically:
+ layer.scale[1] *= -1.0
+ else:
+ layer.scale[0] *= -1.0
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_rotate_layer(bpy.types.Operator):
+ """Rotate the layer"""
+ bl_idname = "image_editor_plus.rotate_layer"
+ bl_label = "Rotate Layer"
+ is_left: bpy.props.BoolProperty(name="Left", default=False)
+
+ def execute(self, context):
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ layer = app.get_active_layer(context)
+ if not layer:
+ return {'CANCELLED'}
+
+ layer.rotation += math.pi / 2.0 if self.is_left else -math.pi / 2.0
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_rotate_layer_arbitrary(bpy.types.Operator):
+ """Rotate the image by a specified angle"""
+ bl_idname = "image_editor_plus.rotate_layer_arbitrary"
+ bl_label = "Rotate Layer Arbitrary"
+
+ def __init__(self):
+ self.start_input_position = [0, 0]
+ self.start_layer_angle = 0
+
+ def modal(self, context, event):
+ area_session = app.get_area_session(context)
+
+ context.area.tag_redraw()
+
+ img = context.area.spaces.active.image
+ width, height = img.size[0], img.size[1]
+
+ layer = app.get_active_layer(context)
+ if not layer:
+ return {'CANCELLED'}
+
+ layer_width, layer_height = 1, 1
+ layer_img = bpy.data.images.get(layer.name, None)
+ if layer_img:
+ layer_width, layer_height = layer_img.size[0], layer_img.size[1]
+
+ if event.type == 'MOUSEMOVE':
+ center_x = layer.location[0] + layer_width / 2.0
+ center_y = height - layer.location[1] - layer_height / 2.0
+
+ region_pos = [event.mouse_region_x, event.mouse_region_y]
+ view_x, view_y = context.region.view2d.region_to_view(*region_pos)
+
+ target_x = width * view_x
+ target_y = height * view_y
+
+ angle1 = math.atan2(self.start_input_position[1] - center_y,
+ self.start_input_position[0] - center_x)
+ angle2 = math.atan2(target_y - center_y, target_x - center_x)
+
+ layer.rotation = self.start_layer_angle + angle2 - angle1
+
+ elif event.type == 'LEFTMOUSE':
+ app.rebuild_image_layers_nodes(img)
+
+ area_session.layer_rotating = False
+ area_session.prevent_layer_update_event = False
+
+ return {'FINISHED'}
+ elif event.type in {'RIGHTMOUSE', 'ESC'}:
+ layer.rotation = self.start_layer_angle
+
+ area_session.layer_rotating = False
+ area_session.prevent_layer_update_event = False
+
+ return {'CANCELLED'}
+
+ return {'RUNNING_MODAL'}
+
+ def invoke(self, context, event):
+ area_session = app.get_area_session(context)
+
+ img = context.area.spaces.active.image
+ width, height = img.size[0], img.size[1]
+
+ layer = app.get_active_layer(context)
+ if not layer:
+ return {'CANCELLED'}
+
+ region_pos = [event.mouse_region_x, event.mouse_region_y]
+ view_x, view_y = context.region.view2d.region_to_view(*region_pos)
+
+ self.start_input_position = [width * view_x, height * view_y]
+ self.start_layer_angle = layer.rotation
+
+ area_session.layer_rotating = True
+ area_session.prevent_layer_update_event = True
+
+ context.window_manager.modal_handler_add(self)
+
+ return {'RUNNING_MODAL'}
+
+class IMAGE_EDITOR_PLUS_OT_scale_layer(bpy.types.Operator):
+ """Scale the layer"""
+ bl_idname = "image_editor_plus.scale_layer"
+ bl_label = "Scale Layer"
+
+ def __init__(self):
+ self.start_input_position = [0, 0]
+ self.start_layer_scale_x = 1.0
+ self.start_layer_scale_y = 1.0
+
+ def modal(self, context, event):
+ area_session = app.get_area_session(context)
+
+ context.area.tag_redraw()
+
+ img = context.area.spaces.active.image
+ width, height = img.size[0], img.size[1]
+
+ layer = app.get_active_layer(context)
+ if not layer:
+ return {'CANCELLED'}
+
+ layer_width, layer_height = 1, 1
+ layer_img = bpy.data.images.get(layer.name, None)
+ if layer_img:
+ layer_width, layer_height = layer_img.size[0], layer_img.size[1]
+
+ if event.type == 'MOUSEMOVE':
+ center_x = layer.location[0] + layer_width / 2.0
+ center_y = height - layer.location[1] - layer_height / 2.0
+
+ region_pos = [event.mouse_region_x, event.mouse_region_y]
+ view_x, view_y = context.region.view2d.region_to_view(*region_pos)
+
+ target_x = width * view_x
+ target_y = height * view_y
+
+ dist1 = math.hypot(self.start_input_position[0] - center_x,
+ self.start_input_position[1] - center_y)
+ dist2 = math.hypot(target_x - center_x, target_y - center_y)
+
+ layer.scale[0] = self.start_layer_scale_x * dist2 / dist1
+ layer.scale[1] = self.start_layer_scale_y * dist2 / dist1
+ elif event.type == 'LEFTMOUSE':
+ app.rebuild_image_layers_nodes(img)
+
+ area_session.layer_scaling = False
+ area_session.prevent_layer_update_event = False
+
+ return {'FINISHED'}
+ elif event.type in {'RIGHTMOUSE', 'ESC'}:
+ layer.scale[0] = self.start_layer_scale_x
+ layer.scale[1] = self.start_layer_scale_y
+
+ area_session.layer_scaling = False
+ area_session.prevent_layer_update_event = False
+
+ return {'CANCELLED'}
+
+ return {'RUNNING_MODAL'}
+
+ def invoke(self, context, event):
+ area_session = app.get_area_session(context)
+
+ img = context.area.spaces.active.image
+ width, height = img.size[0], img.size[1]
+
+ layer = app.get_active_layer(context)
+ if not layer:
+ return {'CANCELLED'}
+
+ region_pos = [event.mouse_region_x, event.mouse_region_y]
+ view_x, view_y = context.region.view2d.region_to_view(*region_pos)
+
+ self.start_input_position = [width * view_x, height * view_y]
+ self.start_layer_scale_x = layer.scale[0]
+ self.start_layer_scale_y = layer.scale[1]
+
+ area_session.layer_scaling = True
+ area_session.prevent_layer_update_event = True
+
+ context.window_manager.modal_handler_add(self)
+
+ return {'RUNNING_MODAL'}
+
+class IMAGE_EDITOR_PLUS_OT_offset(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.offset'
+ bl_label = "Offset"
+ offset_x: bpy.props.IntProperty()
+ offset_y: bpy.props.IntProperty()
+ offset_edge_behavior: bpy.props.EnumProperty(items=(
+ ('wrap', 'Wrap', 'Wrap image around'),
+ ('edge', 'Edge', 'Repeat edge pixels')))
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ offset_x = self.offset_x
+ offset_y = self.offset_y
+ edge_behavior = self.offset_edge_behavior
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ new_pixels = np.roll(target_pixels, offset_y, axis=0)
+
+ if edge_behavior == 'edge':
+ if offset_y > 0:
+ new_pixels[:offset_y] = new_pixels[offset_y]
+ elif offset_y < 0:
+ new_pixels[offset_y:] = new_pixels[offset_y - 1]
+
+ new_pixels = np.roll(new_pixels, offset_x, axis=1)
+
+ if edge_behavior == 'edge':
+ if offset_x > 0:
+ new_pixels[:, :offset_x] = new_pixels[:, offset_x, np.newaxis]
+ elif offset_x < 0:
+ new_pixels[:, offset_x:] = new_pixels[:, offset_x - 1, np.newaxis]
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = new_pixels
+ else:
+ pixels[:,:] = new_pixels
+
+ img = app.get_target_image(context)
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_adjust_color(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.adjust_color'
+ bl_label = "Adjust Color"
+ adjust_hue: bpy.props.FloatProperty()
+ adjust_lightness: bpy.props.FloatProperty()
+ adjust_saturation: bpy.props.FloatProperty()
+
+ def execute(self, context):
+ hue = self.adjust_hue
+ light = self.adjust_lightness
+ sat = self.adjust_saturation
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ target_hsl = hsl[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+ target_hsl = hsl
+
+ new_hsl = np.dstack([
+ (target_hsl[:, :, 0] + hue / 360.0) % 1.0, # hue
+ np.clip(target_hsl[:, :, 1] * sat / 100.0, 0, 1.0), # saturation
+ np.clip(target_hsl[:, :, 2] * light / 100.0, 0, 1.0) # lightness
+ ])
+
+ rgb = np.clip(utils.hsl_to_rgb(new_hsl), 0, None)
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0], 0:3] = rgb
+ else:
+ pixels[:,:,0:3] = rgb
+
+ img = app.get_target_image(context)
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_adjust_brightness(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.adjust_brightness'
+ bl_label = "Adjust Brightness"
+ adjust_brightness: bpy.props.FloatProperty()
+ adjust_contrast: bpy.props.FloatProperty()
+
+ def execute(self, context):
+ bright = self.adjust_brightness
+ cont = self.adjust_contrast
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ rgb = target_pixels[:, :, :3]
+
+ cont_fact = cont / 100.0 - 1.0
+ cont_fact2 = (1.02 * (cont_fact + 1.0)) / (1.0 * (1.02 - cont_fact))
+ bright_fact = bright / 200.0 - 0.5
+
+ new_rgb = (rgb - 0.5) * cont_fact2 + 0.5 + bright_fact
+
+ new_rgb = np.clip(new_rgb, 0, None)
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0], 0:3] = new_rgb
+ else:
+ pixels[:,:,0:3] = new_rgb
+
+ img = app.get_target_image(context)
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_adjust_gamma(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.adjust_gamma'
+ bl_label = "Adjust Gamma"
+ adjust_gamma: bpy.props.FloatProperty()
+
+ def execute(self, context):
+ gamma = self.adjust_gamma
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ rgb = target_pixels[:, :, :3]
+
+ gamma_inv = 1.0 / gamma
+ new_rgb = rgb ** gamma_inv
+
+ new_rgb = np.clip(new_rgb, 0, None)
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0], 0:3] = new_rgb
+ else:
+ pixels[:,:,0:3] = new_rgb
+
+ img = app.get_target_image(context)
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_adjust_color_curve(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.adjust_color_curve'
+ bl_label = "Adjust Color Curve"
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ curve_mapping = app.get_curve_mapping()
+ if not curve_mapping:
+ return {'CANCELLED'}
+
+ curve_mapping.initialize()
+ *curve_rgb, curve_val = curve_mapping.curves
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ rgb = target_pixels[:, :, :3]
+
+ curve_eval = np.vectorize(curve_mapping.evaluate)
+
+ new_rgb = curve_eval(curve_val, rgb)
+
+ new_rgb = np.dstack([
+ curve_eval(curve_rgb[i], new_rgb[:, :, i]) for i in range(3)
+ ])
+
+ new_rgb = np.clip(new_rgb, 0, None)
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0], 0:3] = new_rgb
+ else:
+ pixels[:,:,0:3] = new_rgb
+
+ img = app.get_target_image(context)
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_replace_color(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.replace_color'
+ bl_label = "Replace Color"
+ source_color: bpy.props.FloatVectorProperty(size=3)
+ replace_color: bpy.props.FloatVectorProperty(size=4)
+ color_threshold: bpy.props.FloatProperty()
+
+ def execute(self, context):
+ src_color = self.source_color
+ replace_color = self.replace_color
+ threshold = self.color_threshold
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ if threshold <= 0:
+ new_pixels = np.where(
+ np.all(target_pixels[:, :, :3] == src_color, axis=2)[..., np.newaxis],
+ replace_color, target_pixels)
+ else:
+ max_color_dist = np.sqrt(3) * threshold
+ new_pixels = np.where(
+ (np.linalg.norm(target_pixels[:, :, :3] - src_color, axis=2)
+ < max_color_dist)[..., np.newaxis],
+ replace_color, target_pixels)
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = new_pixels
+ else:
+ pixels[:] = new_pixels
+
+ img = app.get_target_image(context)
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_blur(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.blur'
+ bl_label = "Blur"
+ blur_size: bpy.props.FloatProperty()
+ expand_layer: bpy.props.BoolProperty()
+
+ def execute(self, context):
+ session = app.get_session()
+
+ blur_size = self.blur_size
+ need_expand_layer = self.expand_layer
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ expand_size = int(blur_size)
+ layer = app.get_active_layer(context)
+
+ # expand pixels
+ if layer and need_expand_layer:
+ pixels = np.pad(pixels,
+ ((expand_size, expand_size), (expand_size, expand_size), (0, 0)),
+ mode='constant', constant_values=0)
+
+ target_pixels = np.pad(target_pixels,
+ ((expand_size, expand_size), (expand_size, expand_size), (0, 0)),
+ mode='constant', constant_values=0)
+
+ new_pixels = utils.gaussian_blur_core(target_pixels, blur_size)
+
+ new_pixels = np.clip(new_pixels, 0, None)
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = new_pixels
+ else:
+ pixels[:] = new_pixels
+
+ img = app.get_target_image(context)
+ width, height = img.size[0], img.size[1]
+
+ # resize image
+ layer_resized = False
+ if layer:
+ new_height, new_width, _ = pixels.shape
+ if width != new_width or height != new_height:
+ img.scale(new_width, new_height)
+
+ cached_layer_location = session.cached_layer_location
+
+ if need_expand_layer:
+ layer.location[0] = cached_layer_location[0] - expand_size
+ layer.location[1] = cached_layer_location[1] - expand_size
+ else:
+ layer.location[0] = cached_layer_location[0]
+ layer.location[1] = cached_layer_location[1]
+
+ layer_resized = True
+
+ utils.write_pixels_to_image(img, pixels)
+
+ if layer_resized:
+ base_img = context.area.spaces.active.image
+ app.rebuild_image_layers_nodes(base_img)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_sharpen(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.sharpen'
+ bl_label = "Sharpen"
+ sharpen_radius: bpy.props.FloatProperty()
+ sharpen_amount: bpy.props.FloatProperty()
+ sharpen_threshold: bpy.props.FloatProperty()
+
+ def execute(self, context):
+ radius = self.sharpen_radius
+ amount = self.sharpen_amount
+ threshold = self.sharpen_threshold
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ blurred = utils.gaussian_blur_core(target_pixels, radius)
+ new_pixels = (amount + 1.0) * target_pixels - amount * blurred
+
+ if threshold > 0:
+ low_contrast = np.absolute(target_pixels - blurred) < threshold
+ np.copyto(new_pixels, target_pixels, where=low_contrast)
+
+ new_pixels = np.clip(new_pixels, 0, None)
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = new_pixels
+ else:
+ pixels[:] = new_pixels
+
+ img = app.get_target_image(context)
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_add_noise(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.add_noise'
+ bl_label = "Noise"
+ add_noise_intensity: bpy.props.FloatProperty()
+
+ def execute(self, context):
+ intensity = self.add_noise_intensity
+
+ sigma = (intensity * 0.02) ** 0.5
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ rgb = target_pixels[:, :, :3]
+
+ noise = np.random.normal(0, sigma, (target_pixels.shape[0], target_pixels.shape[1], 3))
+ new_rgb = rgb + noise
+
+ new_rgb = np.clip(new_rgb, 0, None)
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0], 0:3] = new_rgb
+ else:
+ pixels[:,:,0:3] = new_rgb
+
+ img = app.get_target_image(context)
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_pixelize(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.pixelize'
+ bl_label = "Pixelize"
+ pixelize_pixel_size: bpy.props.IntProperty()
+
+ def execute(self, context):
+ wm = context.window_manager
+
+ pixel_size = self.pixelize_pixel_size
+
+ pixels, hsl = app.get_image_cache()
+ if pixels is None:
+ return {'CANCELLED'}
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ height, width = target_pixels.shape[0], target_pixels.shape[1]
+ pad_x = math.ceil(width / pixel_size) * pixel_size - width
+ pad_y = math.ceil(height / pixel_size) * pixel_size - height
+
+ target_pixels = np.pad(target_pixels, ((0, pad_y), (0, pad_x), (0, 0)), 'edge')
+
+ new_pixels = target_pixels[pixel_size // 2::pixel_size, pixel_size // 2::pixel_size]
+ new_pixels = np.repeat(np.repeat(new_pixels, pixel_size, axis=0), pixel_size, axis=1)
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = new_pixels[:height, :width]
+ else:
+ pixels[:] = new_pixels[:height, :width]
+
+ img = app.get_target_image(context)
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
+
+class IMAGE_EDITOR_PLUS_OT_make_seamless(bpy.types.Operator):
+ """Apply settings"""
+ bl_idname = 'image_editor_plus.make_seamless'
+ bl_label = "Make Seamless"
+
+ def execute(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ pixels = utils.read_pixels_from_image(img)
+
+ selection = app.get_target_selection(context)
+ if selection:
+ target_pixels = pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]]
+ elif selection == []:
+ return {'CANCELLED'}
+ else:
+ target_pixels = pixels
+
+ target_width, target_height = target_pixels.shape[1], target_pixels.shape[0]
+ target_half_width = target_width // 2
+ target_half_height = target_height // 2
+ odd_horz = target_width % 2
+ odd_vert = target_height % 2
+
+ for pat in range(2):
+ if pat == 0:
+ region1_x1, region1_x2 = 0, target_half_width
+ region2_x1, region2_x2 = target_half_width + odd_horz, target_width
+ else:
+ region1_x1, region1_x2 = target_half_width + odd_horz, target_width
+ region2_x1, region2_x2 = 0, target_half_width
+
+ region1 = target_pixels[0:target_half_height, region1_x1:region1_x2]
+ region2 = target_pixels[target_half_height + odd_vert:target_height,
+ region2_x1:region2_x2]
+
+ y, x = np.meshgrid(range(target_half_height), range(region1_x1, region1_x2),
+ indexing='ij')
+ a = (np.abs(x - target_half_width) - 1.0) / (target_half_width - 1.0)
+ b = (np.abs(y - target_half_height) - 1.0) / (target_half_height - 1.0)
+ w_denom = a * b + (1.0 - a) * (1.0 - b)
+ w_denom = np.where(w_denom == 0, 1.0, w_denom) # for div by 0
+ w = 1.0 - a * b / w_denom
+
+ new_region_chans = []
+ for i in range(4):
+ new_region_chans.append(w * region1[:,:,i] + (1.0 - w) * region2[:,:,i])
+
+ new_region = np.dstack(new_region_chans)
+
+ region1[:] = new_region
+ region2[:] = new_region
+
+ if selection:
+ pixels[selection[0][1]:selection[1][1],
+ selection[0][0]:selection[1][0]] = target_pixels
+ else:
+ pixels[:] = target_pixels
+
+ utils.write_pixels_to_image(img, pixels)
+
+ app.refresh_image(context)
+
+ return {'FINISHED'}
diff --git a/addon/ui.py b/addon/ui.py
new file mode 100644
index 0000000..bcbdcc0
--- /dev/null
+++ b/addon/ui.py
@@ -0,0 +1,1703 @@
+'''
+ Copyright (C) 2021 - 2023 Akaneyu
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+'''
+import bpy
+from . import operators
+from . import app
+
+def get_icon_id(icon_name):
+ session = app.get_session()
+
+ if icon_name in session.icons:
+ return session.icons[icon_name].icon_id
+ else:
+ return 0
+
+def menu_func(self, context):
+ area_session = app.get_area_session(context)
+
+ layout = self.layout
+
+ if context.area.spaces.active.mode != 'UV' \
+ and context.area.spaces.active.image != None \
+ and context.area.spaces.active.image.source != 'VIEWER':
+
+ layout.separator()
+ layout.label(text='Image Editor+')
+
+ if area_session.selection:
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_make_selection.bl_idname, text='Select Box',
+ icon_value=get_icon_id('select'))
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_cancel_selection.bl_idname, text='Deselect',
+ icon_value=get_icon_id('deselect'))
+ else:
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_make_selection.bl_idname, text='Select Box',
+ icon_value=get_icon_id('select'))
+
+ layout.menu(IMAGE_EDITOR_PLUS_MT_edit_menu.bl_idname, text='Edit')
+ layout.menu(IMAGE_EDITOR_PLUS_MT_layers_menu.bl_idname, text='Pasted Layers')
+ layout.menu(IMAGE_EDITOR_PLUS_MT_transform_menu.bl_idname, text='Transform')
+ layout.menu(IMAGE_EDITOR_PLUS_MT_transform_layer_menu.bl_idname, text='Transform Layer')
+ layout.menu(IMAGE_EDITOR_PLUS_MT_offset_menu.bl_idname, text='Offset')
+ layout.menu(IMAGE_EDITOR_PLUS_MT_adjust_menu.bl_idname, text='Adjust')
+ layout.menu(IMAGE_EDITOR_PLUS_MT_filter_menu.bl_idname, text='Filter')
+
+def reset_scale_properties(self, context):
+ if self.reset:
+ self.width_pixels = self.original_width
+ self.height_pixels = self.original_height
+ self.property_unset('width_percent')
+ self.property_unset('height_percent')
+ self.property_unset('keep_aspect_ratio')
+ self.property_unset('scale_layers')
+
+ self.reset = False
+
+def update_scale_width_properties(self, context):
+ if self.skip_property_update:
+ return
+
+ if self.keep_aspect_ratio:
+ self.skip_property_update = True
+
+ ratio = self.original_width / self.original_height
+ self.height_pixels = int(self.width_pixels / ratio)
+ self.height_percent = self.width_percent
+
+ self.skip_property_update = False
+
+def update_scale_height_properties(self, context):
+ if self.skip_property_update:
+ return
+
+ if self.keep_aspect_ratio:
+ self.skip_property_update = True
+
+ ratio = self.original_width / self.original_height
+ self.width_pixels = int(self.height_pixels * ratio)
+ self.width_percent = self.height_percent
+
+ self.skip_property_update = False
+
+class IMAGE_EDITOR_PLUS_OT_scale_dialog(bpy.types.Operator):
+ """Scale the image"""
+ bl_idname = 'image_editor_plus.scale_image_dialog'
+ bl_label = "Scale Image"
+ reset: bpy.props.BoolProperty(options={'SKIP_SAVE'}, update=reset_scale_properties)
+ unit: bpy.props.EnumProperty(options={'SKIP_SAVE'}, items=(
+ ('pixels', 'Pixels', 'In pixels'),
+ ('percent', 'Percent', 'In percent')))
+ width_pixels: bpy.props.IntProperty(name='Width',
+ min=1, options={'SKIP_SAVE'},
+ update=update_scale_width_properties)
+ height_pixels: bpy.props.IntProperty(name='Height',
+ min=1, options={'SKIP_SAVE'},
+ update=update_scale_height_properties)
+ width_percent: bpy.props.FloatProperty(name='Width', subtype='PERCENTAGE',
+ min=1.0, soft_max=200.0, default=100.0, options={'SKIP_SAVE'},
+ update=update_scale_width_properties)
+ height_percent: bpy.props.FloatProperty(name='Height', subtype='PERCENTAGE',
+ min=1.0, soft_max=200.0, default=100.0, options={'SKIP_SAVE'},
+ update=update_scale_height_properties)
+ keep_aspect_ratio: bpy.props.BoolProperty(name='Keep Aspect Ratio', default=True,
+ options={'SKIP_SAVE'}, update=update_scale_width_properties)
+ scale_layers: bpy.props.BoolProperty(name='Scale Layers', default=True,
+ options={'SKIP_SAVE'}, description='Only if Keep Aspect Ratio is turned on')
+ original_width: bpy.props.IntProperty()
+ original_height: bpy.props.IntProperty()
+ skip_property_update: bpy.props.BoolProperty()
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = context.area.spaces.active.image
+ if not img:
+ return {'CANCELLED'}
+
+ width, height = img.size
+
+ self.original_width = width
+ self.original_height = height
+ self.width_pixels = width
+ self.height_pixels = height
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ if self.unit == 'pixels':
+ width = self.width_pixels
+ height = self.height_pixels
+ else: # percent
+ width = int(self.original_width * self.width_percent / 100.0)
+ height = int(self.original_height * self.height_percent / 100.0)
+
+ if width < 1:
+ width = 1
+ if height < 1:
+ height = 1
+
+ scale_layers = False
+ if self.keep_aspect_ratio:
+ scale_layers = self.scale_layers
+
+ bpy.ops.image_editor_plus.scale('EXEC_DEFAULT', False,
+ width=width, height=height, scale_layers=scale_layers)
+
+ return {'FINISHED'}
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.row()
+ row.prop(self, "unit", expand=True)
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Width')
+
+ if self.unit == 'pixels':
+ row.prop(self, 'width_pixels', text='')
+ else:
+ row.prop(self, 'width_percent', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Height')
+
+ if self.unit == 'pixels':
+ row.prop(self, 'height_pixels', text='')
+ else:
+ row.prop(self, 'height_percent', text='')
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'keep_aspect_ratio')
+
+ row = layout.split(align=True)
+ row.column()
+
+ if self.keep_aspect_ratio:
+ row.prop(self, 'scale_layers')
+ else:
+ row.label(text='No layers scaled.')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_offset_properties(self, context):
+ if self.preview:
+ update_offset_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_offset_properties(self, context):
+ if self.reset:
+ self.offset_properties.property_unset('offset_x')
+ self.offset_properties.property_unset('offset_y')
+ self.property_unset('offset_edge_behavior')
+ update_offset_properties(self, context)
+
+ self.reset = False
+
+def update_offset_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.offset('EXEC_DEFAULT', False,
+ offset_x=self.offset_properties.offset_x,
+ offset_y=self.offset_properties.offset_y,
+ offset_edge_behavior=self.offset_edge_behavior)
+
+# wrap these properties to change their attributes dynamically
+class IMAGE_EDITOR_PLUS_OffsetPropertyGroup(bpy.types.PropertyGroup):
+ offset_x: bpy.props.IntProperty()
+ offset_y: bpy.props.IntProperty()
+
+class IMAGE_EDITOR_PLUS_OT_offset_dialog(bpy.types.Operator):
+ """Offset the image"""
+ bl_idname = 'image_editor_plus.offset_dialog'
+ bl_label = "Offset"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_offset_properties)
+ reset: bpy.props.BoolProperty(options={'SKIP_SAVE'}, update=reset_offset_properties)
+ offset_properties: bpy.props.PointerProperty(options={'SKIP_SAVE'},
+ type=IMAGE_EDITOR_PLUS_OffsetPropertyGroup)
+ offset_edge_behavior: bpy.props.EnumProperty(options={'SKIP_SAVE'}, items=(
+ ('wrap', 'Wrap', 'Wrap image around'),
+ ('edge', 'Edge', 'Repeat edge pixels')),
+ update=update_offset_properties)
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ width, height = img.size
+
+ selection = app.get_target_selection(context)
+ if selection:
+ width = selection[1][0] - selection[0][0]
+ height = selection[1][1] - selection[0][1]
+
+ IMAGE_EDITOR_PLUS_OffsetPropertyGroup.offset_x = \
+ bpy.props.IntProperty(name='Offset X', subtype='FACTOR',
+ min=-width + 1, max=width - 1,
+ update=(lambda _self, context: update_offset_properties(self, context)))
+
+ IMAGE_EDITOR_PLUS_OffsetPropertyGroup.offset_y = \
+ bpy.props.IntProperty(name='Offset Y', subtype='FACTOR',
+ min=-width + 1, max=width - 1,
+ update=(lambda _self, context: update_offset_properties(self, context)))
+
+ update_offset_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.offset('EXEC_DEFAULT', False,
+ offset_x=self.offset_properties.offset_x,
+ offset_y=self.offset_properties.offset_y,
+ offset_edge_behavior=self.offset_edge_behavior)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+ layout.separator()
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Offset X')
+ row.prop(self.offset_properties, 'offset_x', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Offset Y')
+ row.prop(self.offset_properties, 'offset_y', text='')
+
+ row = layout.row()
+ row.prop(self, "offset_edge_behavior", expand=True)
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_adjust_color_properties(self, context):
+ if self.preview:
+ update_adjust_color_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_adjust_color_properties(self, context):
+ if self.reset:
+ self.property_unset('adjust_hue')
+ self.property_unset('adjust_lightness')
+ self.property_unset('adjust_saturation')
+ update_adjust_color_properties(self, context)
+
+ self.reset = False
+
+def update_adjust_color_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.adjust_color('EXEC_DEFAULT', False,
+ adjust_hue=self.adjust_hue,
+ adjust_lightness=self.adjust_lightness,
+ adjust_saturation=self.adjust_saturation)
+
+class IMAGE_EDITOR_PLUS_OT_adjust_color_dialog(bpy.types.Operator):
+ """Adjust hue/saturation/lightness of the image"""
+ bl_idname = 'image_editor_plus.adjust_color_dialog'
+ bl_label = "Hue/Saturation"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_adjust_color_properties)
+ reset: bpy.props.BoolProperty(options={'SKIP_SAVE'},
+ update=reset_adjust_color_properties)
+ adjust_hue: bpy.props.FloatProperty(name='Hue', subtype='FACTOR',
+ min=-180, max=180, default=0, options={'SKIP_SAVE'},
+ update=update_adjust_color_properties)
+ adjust_lightness: bpy.props.FloatProperty(name='Lightness', subtype='PERCENTAGE',
+ min=0, max=200, default=100, options={'SKIP_SAVE'},
+ update=update_adjust_color_properties)
+ adjust_saturation: bpy.props.FloatProperty(name='Saturation', subtype='PERCENTAGE',
+ min=0, max=200, default=100, options={'SKIP_SAVE'},
+ update=update_adjust_color_properties)
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img, need_hsl=True)
+
+ update_adjust_color_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.adjust_color('EXEC_DEFAULT', False,
+ adjust_hue=self.adjust_hue,
+ adjust_lightness=self.adjust_lightness,
+ adjust_saturation=self.adjust_saturation)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+ layout.separator()
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Hue')
+ row.prop(self, 'adjust_hue', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Lightness')
+ row.prop(self, 'adjust_lightness', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Saturation')
+ row.prop(self, 'adjust_saturation', text='')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_adjust_brightness_properties(self, context):
+ if self.preview:
+ update_adjust_brightness_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_adjust_brightness_properties(self, context):
+ if self.reset:
+ self.property_unset('adjust_brightness')
+ self.property_unset('adjust_contrast')
+ update_adjust_brightness_properties(self, context)
+
+ self.reset = False
+
+def update_adjust_brightness_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.adjust_brightness('EXEC_DEFAULT', False,
+ adjust_brightness=self.adjust_brightness,
+ adjust_contrast=self.adjust_contrast)
+
+class IMAGE_EDITOR_PLUS_OT_adjust_brightness_dialog(bpy.types.Operator):
+ """Adjust brightness/contrast of the image"""
+ bl_idname = 'image_editor_plus.adjust_brightness_dialog'
+ bl_label = "Brightness/Contrast"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_adjust_brightness_properties)
+ reset: bpy.props.BoolProperty(options={'SKIP_SAVE'},
+ update=reset_adjust_brightness_properties)
+ adjust_brightness: bpy.props.FloatProperty(name='Brightness', subtype='PERCENTAGE',
+ min=0, max=200, default=100, options={'SKIP_SAVE'},
+ update=update_adjust_brightness_properties)
+ adjust_contrast: bpy.props.FloatProperty(name='Contrast', subtype='PERCENTAGE',
+ min=0, max=200, default=100, options={'SKIP_SAVE'},
+ update=update_adjust_brightness_properties)
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ update_adjust_brightness_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.adjust_brightness('EXEC_DEFAULT', False,
+ adjust_brightness=self.adjust_brightness,
+ adjust_contrast=self.adjust_contrast)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+ layout.separator()
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Brightness')
+ row.prop(self, 'adjust_brightness', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Contrast')
+ row.prop(self, 'adjust_contrast', text='')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_adjust_gamma_properties(self, context):
+ if self.preview:
+ update_adjust_gamma_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_adjust_gamma_properties(self, context):
+ if self.reset:
+ self.property_unset('adjust_gamma')
+ update_adjust_gamma_properties(self, context)
+
+ self.reset = False
+
+def update_adjust_gamma_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.adjust_gamma('EXEC_DEFAULT', False,
+ adjust_gamma=self.adjust_gamma)
+
+class IMAGE_EDITOR_PLUS_OT_adjust_gamma_dialog(bpy.types.Operator):
+ """Adjust gamma of the image"""
+ bl_idname = 'image_editor_plus.adjust_gamma_dialog'
+ bl_label = "Gamma"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_adjust_gamma_properties)
+ reset: bpy.props.BoolProperty(options={'SKIP_SAVE'},
+ update=reset_adjust_gamma_properties)
+ adjust_gamma: bpy.props.FloatProperty(name='Gamma', subtype='FACTOR',
+ min=0.01, soft_max=3.0, default=1.0, options={'SKIP_SAVE'},
+ update=update_adjust_gamma_properties)
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ update_adjust_gamma_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.adjust_gamma('EXEC_DEFAULT', False,
+ adjust_gamma=self.adjust_gamma)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+ layout.separator()
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Gamma')
+ row.prop(self, 'adjust_gamma', text='')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_adjust_color_curve_properties(self, context):
+ if not self.preview:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_adjust_color_curve_properties(self, context):
+ if self.reset:
+ app.reset_curve_mapping()
+
+ self.reset = False
+
+def update_adjust_color_curve_properties(self, context):
+ if self.update_preview:
+ self.update_preview = False
+
+ if self.preview:
+ bpy.ops.image_editor_plus.adjust_color_curve('EXEC_DEFAULT', False)
+
+class IMAGE_EDITOR_PLUS_OT_adjust_color_curve_dialog(bpy.types.Operator):
+ """Adjust color curve of the image"""
+ bl_idname = 'image_editor_plus.adjust_curve_dialog'
+ bl_label = "Adjust Color Curve"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_adjust_color_curve_properties,
+ description='Preview manually (Need an update operation)')
+ update_preview: bpy.props.BoolProperty(options={'SKIP_SAVE'},
+ update=update_adjust_color_curve_properties,
+ description='Update preview')
+ reset: bpy.props.BoolProperty(update=reset_adjust_color_curve_properties,
+ options={'SKIP_SAVE'})
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ app.reset_curve_mapping()
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.adjust_color_curve('EXEC_DEFAULT', False)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+ row.prop(self, 'update_preview', text='Update', toggle=True, icon='FILE_REFRESH')
+
+ layout.separator()
+
+ curve_node = app.get_curve_node()
+
+ col = layout.column(align=True)
+ col.template_curve_mapping(curve_node, 'mapping', type='COLOR')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_replace_color_properties(self, context):
+ if self.preview:
+ update_replace_color_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_replace_color_properties(self, context):
+ if self.reset:
+ self.property_unset('source_color')
+ self.property_unset('replace_color')
+ self.property_unset('color_threshold')
+ update_replace_color_properties(self, context)
+
+ self.reset = False
+
+def update_replace_color_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.replace_color('EXEC_DEFAULT', False,
+ source_color=self.source_color,
+ replace_color=self.replace_color,
+ color_threshold=self.color_threshold)
+
+class IMAGE_EDITOR_PLUS_OT_replace_color_dialog(bpy.types.Operator):
+ """Replace one color in the image with another"""
+ bl_idname = 'image_editor_plus.replace_color_dialog'
+ bl_label = "Replace Color"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_replace_color_properties)
+ reset: bpy.props.BoolProperty(options={'SKIP_SAVE'},
+ update=reset_replace_color_properties)
+ source_color: bpy.props.FloatVectorProperty(name='Source Color', subtype='COLOR_GAMMA',
+ min=0, max=1.0, size=3, default=(1.0, 1.0, 1.0), options={'SKIP_SAVE'},
+ update=update_replace_color_properties) # no alpha
+ replace_color: bpy.props.FloatVectorProperty(name='Replace Color', subtype='COLOR_GAMMA',
+ min=0, max=1.0, size=4, default=(0, 0, 0, 1.0), options={'SKIP_SAVE'},
+ update=update_replace_color_properties)
+ color_threshold: bpy.props.FloatProperty(name='Threshold', subtype='FACTOR',
+ min=0, max=1.0, default=0.1, options={'SKIP_SAVE'},
+ update=update_replace_color_properties)
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ update_replace_color_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.replace_color('EXEC_DEFAULT', False,
+ source_color=self.source_color,
+ replace_color=self.replace_color,
+ color_threshold=self.color_threshold)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+ layout.separator()
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Source Color')
+ row.prop(self, 'source_color', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Replace Color')
+ row.prop(self, 'replace_color', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Threshold')
+ row.prop(self, 'color_threshold', text='')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_blur_properties(self, context):
+ if self.preview:
+ update_blur_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_blur_properties(self, context):
+ if self.reset:
+ self.property_unset('blur_size')
+ self.property_unset('expand_layer')
+ update_blur_properties(self, context)
+
+ self.reset = False
+
+def update_blur_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.blur('EXEC_DEFAULT', False,
+ blur_size=self.blur_size,
+ expand_layer=False)
+
+class IMAGE_EDITOR_PLUS_OT_blur_dialog(bpy.types.Operator):
+ """Blur the image"""
+ bl_idname = 'image_editor_plus.blur_dialog'
+ bl_label = "Blur (Gaussian Blur)"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_blur_properties)
+ reset: bpy.props.BoolProperty(options={'SKIP_SAVE'}, update=reset_blur_properties)
+ blur_size: bpy.props.FloatProperty(name='Size', subtype='FACTOR',
+ min=0, soft_max=10.0, default=3.0, options={'SKIP_SAVE'},
+ update=update_blur_properties)
+ expand_layer: bpy.props.BoolProperty(name='Expand Layer', default=True, options={'SKIP_SAVE'},
+ update=update_blur_properties)
+
+ def invoke(self, context, event):
+ session = app.get_session()
+
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ layer = app.get_active_layer(context)
+ if layer:
+ session.cached_layer_location = layer.location[:]
+
+ update_blur_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.blur('EXEC_DEFAULT', False,
+ blur_size=self.blur_size,
+ expand_layer=self.expand_layer)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+ layout.separator()
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Size')
+ row.prop(self, 'blur_size', text='')
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'expand_layer')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_sharpen_properties(self, context):
+ if self.preview:
+ update_sharpen_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_sharpen_properties(self, context):
+ if self.reset:
+ self.property_unset('sharpen_radius')
+ self.property_unset('sharpen_amount')
+ self.property_unset('sharpen_threshold')
+ update_sharpen_properties(self, context)
+
+ self.reset = False
+
+def update_sharpen_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.sharpen('EXEC_DEFAULT', False,
+ sharpen_radius=self.sharpen_radius,
+ sharpen_amount=self.sharpen_amount,
+ sharpen_threshold=self.sharpen_threshold)
+
+class IMAGE_EDITOR_PLUS_OT_sharpen_dialog(bpy.types.Operator):
+ """Sharpen the image"""
+ bl_idname = 'image_editor_plus.sharpen_dialog'
+ bl_label = "Sharpen (Unsharp Mask)"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_sharpen_properties)
+ reset: bpy.props.BoolProperty(update=reset_sharpen_properties, options={'SKIP_SAVE'})
+ sharpen_radius: bpy.props.FloatProperty(name='Radius', subtype='FACTOR',
+ min=0, soft_max=10.0, default=3.0, options={'SKIP_SAVE'},
+ update=update_sharpen_properties)
+ sharpen_amount: bpy.props.FloatProperty(name='Amount', subtype='FACTOR',
+ min=0, soft_max=10.0, default=0.5, options={'SKIP_SAVE'},
+ update=update_sharpen_properties)
+ sharpen_threshold: bpy.props.FloatProperty(name='Threshold', subtype='FACTOR',
+ min=0, soft_max=1.0, default=0, options={'SKIP_SAVE'},
+ update=update_sharpen_properties)
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ update_sharpen_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.sharpen('EXEC_DEFAULT', False,
+ sharpen_radius=self.sharpen_radius,
+ sharpen_amount=self.sharpen_amount,
+ sharpen_threshold=self.sharpen_threshold)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+ layout.separator()
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Radius')
+ row.prop(self, 'sharpen_radius', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Amount')
+ row.prop(self, 'sharpen_amount', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Threshold')
+ row.prop(self, 'sharpen_threshold', text='')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_add_noise_properties(self, context):
+ if self.preview:
+ update_add_noise_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_add_noise_properties(self, context):
+ if self.reset:
+ self.property_unset('add_noise_intensity')
+ update_add_noise_properties(self, context)
+
+ self.reset = False
+
+def update_add_noise_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.add_noise('EXEC_DEFAULT', False,
+ add_noise_intensity=self.add_noise_intensity)
+
+class IMAGE_EDITOR_PLUS_OT_add_noise_dialog(bpy.types.Operator):
+ """Add some noise to the image"""
+ bl_idname = 'image_editor_plus.add_noise_dialog'
+ bl_label = "Add Noise (Gaussian Noise)"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_add_noise_properties)
+ reset: bpy.props.BoolProperty(options={'SKIP_SAVE'}, update=reset_add_noise_properties)
+ add_noise_intensity: bpy.props.FloatProperty(name='Intensity', subtype='FACTOR',
+ min=0, soft_max=10.0, default=0.1, options={'SKIP_SAVE'},
+ update=update_add_noise_properties)
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ update_add_noise_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.add_noise('EXEC_DEFAULT', False,
+ add_noise_intensity=self.add_noise_intensity)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+ layout.separator()
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Intensity')
+ row.prop(self, 'add_noise_intensity', text='')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_pixelize_properties(self, context):
+ if self.preview:
+ update_pixelize_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def reset_pixelize_properties(self, context):
+ if self.reset:
+ self.property_unset('pixelize_pixel_size')
+ update_pixelize_properties(self, context)
+
+ self.reset = False
+
+def update_pixelize_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.pixelize('EXEC_DEFAULT', False,
+ pixelize_pixel_size=self.pixelize_pixel_size)
+
+class IMAGE_EDITOR_PLUS_OT_pixelize_dialog(bpy.types.Operator):
+ """Pixelize the image"""
+ bl_idname = 'image_editor_plus.pixelize_dialog'
+ bl_label = "Pixelize"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_pixelize_properties)
+ reset: bpy.props.BoolProperty(options={'SKIP_SAVE'}, update=reset_pixelize_properties)
+ pixelize_pixel_size: bpy.props.IntProperty(name='Pixel Size', subtype='FACTOR',
+ min=1, soft_max=64, default=16, options={'SKIP_SAVE'},
+ update=update_pixelize_properties)
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ update_pixelize_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.pixelize('EXEC_DEFAULT', False,
+ pixelize_pixel_size=self.pixelize_pixel_size)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+ layout.separator()
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Pixel Size')
+ row.prop(self, 'pixelize_pixel_size', text='')
+
+ row = layout.split(factor=0.7)
+ row.column()
+ row.prop(self, 'reset', text='Reset', toggle=True)
+
+def preview_make_seamless_properties(self, context):
+ if self.preview:
+ update_make_seamless_properties(self, context)
+ else:
+ img = app.get_target_image(context)
+ if img:
+ app.revert_image_cache(img)
+ app.refresh_image(context)
+
+def update_make_seamless_properties(self, context):
+ if self.preview:
+ bpy.ops.image_editor_plus.make_seamless('EXEC_DEFAULT', False)
+
+class IMAGE_EDITOR_PLUS_OT_make_seamless_dialog(bpy.types.Operator):
+ """Turn the image into seamless tile"""
+ bl_idname = 'image_editor_plus.make_seamless_dialog'
+ bl_label = "Make Seamless"
+ preview: bpy.props.BoolProperty(default=True, options={'SKIP_SAVE'},
+ update=preview_make_seamless_properties)
+
+ def invoke(self, context, event):
+ wm = context.window_manager
+
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.cache_image(img)
+
+ update_make_seamless_properties(self, context)
+
+ return wm.invoke_props_dialog(self)
+
+ def execute(self, context):
+ bpy.ops.image_editor_plus.make_seamless('EXEC_DEFAULT', False)
+
+ return {'FINISHED'}
+
+ def cancel(self, context):
+ img = app.get_target_image(context)
+ if not img:
+ return {'CANCELLED'}
+
+ app.revert_image_cache(img)
+ app.clear_image_cache()
+ app.refresh_image(context)
+
+ def draw(self, context):
+ layout = self.layout
+
+ row = layout.split(align=True)
+ row.column()
+ row.prop(self, 'preview', text='Preview', toggle=True, icon='VIEWZOOM')
+
+class IMAGE_EDITOR_PLUS_UL_layer_list(bpy.types.UIList):
+ def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ icon_value = 0
+ img = bpy.data.images.get(item.name)
+ if img:
+ icon_value = bpy.types.UILayout.icon(img)
+
+ row = layout.row()
+
+ row.alignment = 'EXPAND'
+ row.prop(item, 'label', text='', emboss=False, icon_value=icon_value)
+
+ hide_icon = 'HIDE_ON' if item.hide else 'HIDE_OFF'
+ row.prop(item, 'hide', text='', emboss=False, icon=hide_icon)
+
+class IMAGE_EDITOR_PLUS_MT_edit_menu(bpy.types.Menu):
+ bl_idname = "IMAGE_EDITOR_PLUS_MT_edit_menu"
+ bl_label = "Edit"
+
+ def draw(self, context):
+ layout = self.layout
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_fill_with_fg_color.bl_idname, text='Fill with FG Color',
+ icon_value=get_icon_id('fill'))
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_fill_with_bg_color.bl_idname, text='Fill with BG Color',
+ icon_value=get_icon_id('fill'))
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_clear.bl_idname, text='Clear',
+ icon_value=get_icon_id('clear'))
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_cut.bl_idname, text='Cut',
+ icon_value=get_icon_id('cut'))
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_copy.bl_idname, text='Copy', icon='COPYDOWN')
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_paste.bl_idname, text='Paste', icon='PASTEDOWN')
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_crop.bl_idname, text='Crop',
+ icon_value=get_icon_id('crop'))
+
+class IMAGE_EDITOR_PLUS_MT_layers_menu(bpy.types.Menu):
+ bl_idname = "IMAGE_EDITOR_PLUS_MT_layers_menu"
+ bl_label = "Pasted Layers"
+
+ def draw(self, context):
+ layout = self.layout
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_deselect_layer.bl_idname, text='Deselect Layer',
+ icon_value=get_icon_id('deselect'))
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_move_layer.bl_idname, text='Move',
+ icon_value=get_icon_id('move'))
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate_layer.bl_idname, text='Rotate',
+ icon_value=get_icon_id('rotate'))
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_scale_layer.bl_idname, text='Scale',
+ icon_value=get_icon_id('scale'))
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_delete_layer.bl_idname, text='Delete',
+ icon='TRASH')
+
+ layout.operator(operators.IMAGE_EDITOR_PLUS_OT_merge_layers.bl_idname, text='Merge Layers',
+ icon_value=get_icon_id('check'))
+
+class IMAGE_EDITOR_PLUS_MT_transform_menu(bpy.types.Menu):
+ bl_idname = "IMAGE_EDITOR_PLUS_MT_transform_menu"
+ bl_label = "Transform"
+
+ def draw(self, context):
+ layout = self.layout
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_flip.bl_idname, text='Flip Horizontally',
+ icon_value=get_icon_id('flip_horz'))
+ op.is_vertically = False
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_flip.bl_idname, text='Flip Vertically',
+ icon_value=get_icon_id('flip_vert'))
+ op.is_vertically = True
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate.bl_idname, text="Rotate 90\u00b0 Left",
+ icon_value=get_icon_id('rot_left'))
+ op.is_left = True
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate.bl_idname, text="Rotate 90\u00b0 Right",
+ icon_value=get_icon_id('rot_right'))
+ op.is_left = False
+
+ op = layout.operator(IMAGE_EDITOR_PLUS_OT_scale_dialog.bl_idname, text='Scale...',
+ icon_value=get_icon_id('scale'))
+
+class IMAGE_EDITOR_PLUS_MT_transform_layer_menu(bpy.types.Menu):
+ bl_idname = "IMAGE_EDITOR_PLUS_MT_transform_layer_menu"
+ bl_label = "Transform Layer"
+
+ def draw(self, context):
+ layout = self.layout
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_flip_layer.bl_idname, text='Flip Horizontally',
+ icon_value=get_icon_id('flip_horz'))
+ op.is_vertically = False
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_flip_layer.bl_idname, text='Flip Vertically',
+ icon_value=get_icon_id('flip_vert'))
+ op.is_vertically = True
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate_layer.bl_idname, text="Rotate 90\u00b0 Left",
+ icon_value=get_icon_id('rot_left'))
+ op.is_left = True
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate_layer.bl_idname, text="Rotate 90\u00b0 Right",
+ icon_value=get_icon_id('rot_right'))
+ op.is_left = False
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate_layer_arbitrary.bl_idname,
+ text="Rotate Arbitrary",
+ icon_value=get_icon_id('rotate'))
+
+ op = layout.operator(operators.IMAGE_EDITOR_PLUS_OT_scale_layer.bl_idname, text="Scale",
+ icon_value=get_icon_id('scale'))
+
+class IMAGE_EDITOR_PLUS_MT_offset_menu(bpy.types.Menu):
+ bl_idname = "IMAGE_EDITOR_PLUS_MT_offset_menu"
+ bl_label = "Offset"
+
+ def draw(self, context):
+ layout = self.layout
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_offset_dialog.bl_idname, text='Offset...',
+ icon_value=get_icon_id('offset'))
+
+class IMAGE_EDITOR_PLUS_MT_adjust_menu(bpy.types.Menu):
+ bl_idname = "IMAGE_EDITOR_PLUS_MT_adjust_menu"
+ bl_label = "Adjust"
+
+ def draw(self, context):
+ layout = self.layout
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_adjust_color_dialog.bl_idname, text='Hue/Saturation...',
+ icon_value=get_icon_id('adjust'))
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_adjust_brightness_dialog.bl_idname, text='Brightness/Contrast...',
+ icon_value=get_icon_id('adjust'))
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_adjust_gamma_dialog.bl_idname, text='Gamma...',
+ icon_value=get_icon_id('adjust'))
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_adjust_color_curve_dialog.bl_idname, text='Color Curve...',
+ icon_value=get_icon_id('color_curve'))
+
+ #layout.operator(IMAGE_EDITOR_PLUS_OT_replace_color_dialog.bl_idname, text="Replace Color...",
+ # icon='COLOR')
+
+class IMAGE_EDITOR_PLUS_MT_filter_menu(bpy.types.Menu):
+ bl_idname = "IMAGE_EDITOR_PLUS_MT_filter_menu"
+ bl_label = "Filter"
+
+ def draw(self, context):
+ layout = self.layout
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_blur_dialog.bl_idname, text="Blur...",
+ icon_value=get_icon_id('filter'))
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_sharpen_dialog.bl_idname, text="Sharpen...",
+ icon_value=get_icon_id('filter'))
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_add_noise_dialog.bl_idname, text="Add Noise...",
+ icon_value=get_icon_id('filter'))
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_pixelize_dialog.bl_idname, text="Pixelize...",
+ icon_value=get_icon_id('filter'))
+
+ layout.operator(IMAGE_EDITOR_PLUS_OT_make_seamless_dialog.bl_idname, text='Make Seamless...',
+ icon_value=get_icon_id('filter'))
+
+class IMAGE_EDITOR_PLUS_PT_select_panel(bpy.types.Panel):
+ bl_label = "Select"
+ bl_space_type = "IMAGE_EDITOR"
+ bl_region_type = "UI"
+ bl_category = "Image Editor+"
+
+ @classmethod
+ def poll(cls, context):
+ return context.area.spaces.active.mode != 'UV' \
+ and context.area.spaces.active.image != None \
+ and context.area.spaces.active.image.source != 'VIEWER'
+
+ def draw(self, context):
+ area_session = app.get_area_session(context)
+
+ wm = context.window_manager
+ props = wm.imageeditorplus_properties
+
+ layout = self.layout
+
+ if area_session.selecting:
+ row = layout.row()
+ row.enabled = False
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_make_selection.bl_idname, text='Selecting...')
+ else:
+ if area_session.selection:
+ row = layout.row()
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_make_selection.bl_idname, text='Select Box',
+ icon_value=get_icon_id('select'))
+
+ row = layout.row()
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_cancel_selection.bl_idname,
+ text='Deselect',
+ icon_value=get_icon_id('deselect'))
+ else:
+ row = layout.row()
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_make_selection.bl_idname, text='Select Box',
+ icon_value=get_icon_id('select'))
+
+ if area_session.selection:
+ img = context.area.spaces.active.image
+ width, height = img.size
+
+ selection = app.get_selection(context)
+ x1 = selection[0][0]
+ y1 = height - selection[1][1]
+ x2 = selection[1][0]
+ y2 = height - selection[0][1]
+
+ row = layout.row()
+ row.label(text='({}, {}) - ({}, {})'.format(x1, y1, x2, y2),
+ icon_value=get_icon_id('select'))
+
+class IMAGE_EDITOR_PLUS_PT_edit_panel(bpy.types.Panel):
+ bl_label = "Edit"
+ bl_space_type = "IMAGE_EDITOR"
+ bl_region_type = "UI"
+ bl_category = "Image Editor+"
+
+ @classmethod
+ def poll(cls, context):
+ return context.area.spaces.active.mode != 'UV' \
+ and context.area.spaces.active.image != None \
+ and context.area.spaces.active.image.source != 'VIEWER'
+
+ def draw(self, context):
+ wm = context.window_manager
+ props = wm.imageeditorplus_properties
+
+ layout = self.layout
+
+ row = layout.split(factor=0.9)
+ col = row.column()
+ row2 = col.split(align=True)
+ row2.prop(props, 'foreground_color', text='')
+ row2.prop(props, 'background_color', text='')
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_swap_colors.bl_idname, text='', icon='FILE_REFRESH',
+ emboss=False)
+
+ fill_clear_box = layout.box()
+
+ row = fill_clear_box.row()
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_fill_with_fg_color.bl_idname, text='Fill',
+ icon_value=get_icon_id('fill'))
+
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_clear.bl_idname, text='Clear',
+ icon_value=get_icon_id('clear'))
+
+ copy_paste_box = layout.box()
+
+ row = copy_paste_box.row()
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_cut.bl_idname, text='Cut',
+ icon_value=get_icon_id('cut'))
+
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_copy.bl_idname, text='Copy', icon='COPYDOWN')
+
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_paste.bl_idname, text='Paste', icon='PASTEDOWN')
+
+ row = layout.row()
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_crop.bl_idname, text='Crop',
+ icon_value=get_icon_id('crop'))
+
+class IMAGE_EDITOR_PLUS_PT_layers_panel(bpy.types.Panel):
+ bl_label = "Pasted Layers"
+ bl_space_type = "IMAGE_EDITOR"
+ bl_region_type = "UI"
+ bl_category = "Image Editor+"
+
+ @classmethod
+ def poll(cls, context):
+ return context.area.spaces.active.mode != 'UV' \
+ and context.area.spaces.active.image != None \
+ and context.area.spaces.active.image.source != 'VIEWER'
+
+ def draw(self, context):
+ layout = self.layout
+
+ img = context.area.spaces.active.image
+ if img:
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+
+ row = layout.row()
+ row.template_list("IMAGE_EDITOR_PLUS_UL_layer_list", "",
+ img_props, "layers",
+ img_props, "selected_layer_index", rows=2)
+
+ if layers:
+ row = layout.row()
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_deselect_layer.bl_idname, text='Deselect Layer',
+ icon_value=get_icon_id('deselect'))
+
+ row = layout.split(align=True)
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_move_layer.bl_idname, text='',
+ icon_value=get_icon_id('move'))
+
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_change_image_layer_order.bl_idname, text='',
+ icon="TRIA_UP")
+ op.up = True
+
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_change_image_layer_order.bl_idname, text='',
+ icon="TRIA_DOWN")
+ op.up = False
+
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_delete_layer.bl_idname, text='',
+ icon='TRASH')
+
+ row = layout.row()
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_merge_layers.bl_idname, text='Merge Layers',
+ icon_value=get_icon_id('check'))
+
+class IMAGE_EDITOR_PLUS_PT_transform_panel(bpy.types.Panel):
+ bl_label = "Transform"
+ bl_space_type = "IMAGE_EDITOR"
+ bl_region_type = "UI"
+ bl_category = "Image Editor+"
+ bl_options = {'DEFAULT_CLOSED'}
+
+ @classmethod
+ def poll(cls, context):
+ return context.area.spaces.active.mode != 'UV' \
+ and context.area.spaces.active.image != None \
+ and context.area.spaces.active.image.source != 'VIEWER'
+
+ def draw(self, context):
+ wm = context.window_manager
+
+ layout = self.layout
+
+ row = layout.split(factor=0.8)
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_flip.bl_idname, text='Flip',
+ icon_value=get_icon_id('flip_horz'))
+ op.is_vertically = False
+
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_flip.bl_idname, text='',
+ icon_value=get_icon_id('flip_vert'))
+ op.is_vertically = True
+
+ row = layout.split(factor=0.8)
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate.bl_idname, text='Rotate',
+ icon_value=get_icon_id('rot_left'))
+ op.is_left = True
+
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate.bl_idname, text='',
+ icon_value=get_icon_id('rot_right'))
+ op.is_left = False
+
+ row = layout.row()
+ op = row.operator(IMAGE_EDITOR_PLUS_OT_scale_dialog.bl_idname, text='Scale...',
+ icon_value=get_icon_id('scale'))
+
+class IMAGE_EDITOR_PLUS_PT_transform_layer_panel(bpy.types.Panel):
+ bl_label = "Transform Layer"
+ bl_space_type = "IMAGE_EDITOR"
+ bl_region_type = "UI"
+ bl_category = "Image Editor+"
+ bl_options = {'DEFAULT_CLOSED'}
+
+ @classmethod
+ def poll(cls, context):
+ return context.area.spaces.active.mode != 'UV' \
+ and context.area.spaces.active.image != None \
+ and context.area.spaces.active.image.source != 'VIEWER'
+
+ def draw(self, context):
+ wm = context.window_manager
+
+ layout = self.layout
+
+ img = context.area.spaces.active.image
+ if img:
+ img_props = img.imageeditorplus_properties
+ layers = img_props.layers
+ selected_layer_index = img_props.selected_layer_index
+
+ if selected_layer_index != -1 and selected_layer_index < len(layers):
+ layer = layers[selected_layer_index]
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Location')
+ row.prop(layer, 'location', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Rotation')
+ row.prop(layer, 'rotation', text='')
+
+ row = layout.split(align=True)
+ row.alignment = 'RIGHT'
+ row.label(text='Scale')
+ row.prop(layer, 'scale', text='')
+
+ row = layout.split(factor=0.8)
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_flip_layer.bl_idname, text='Flip',
+ icon_value=get_icon_id('flip_horz'))
+ op.is_vertically = False
+
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_flip_layer.bl_idname, text='',
+ icon_value=get_icon_id('flip_vert'))
+ op.is_vertically = True
+
+ row = layout.split(factor=0.6)
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate_layer.bl_idname, text='Rotate',
+ icon_value=get_icon_id('rot_left'))
+ op.is_left = True
+
+ row = row.split(factor=0.5)
+ op = row.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate_layer.bl_idname, text='',
+ icon_value=get_icon_id('rot_right'))
+ op.is_left = False
+
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_rotate_layer_arbitrary.bl_idname, text='',
+ icon_value=get_icon_id('rotate'))
+
+ row = layout.row()
+ row.operator(operators.IMAGE_EDITOR_PLUS_OT_scale_layer.bl_idname, text='Scale',
+ icon_value=get_icon_id('scale'))
+
+class IMAGE_EDITOR_PLUS_PT_offset_panel(bpy.types.Panel):
+ bl_label = "Offset"
+ bl_space_type = "IMAGE_EDITOR"
+ bl_region_type = "UI"
+ bl_category = "Image Editor+"
+ bl_options = {'DEFAULT_CLOSED'}
+
+ @classmethod
+ def poll(cls, context):
+ return context.area.spaces.active.mode != 'UV' \
+ and context.area.spaces.active.image != None \
+ and context.area.spaces.active.image.source != 'VIEWER'
+
+ def draw(self, context):
+ wm = context.window_manager
+
+ layout = self.layout
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_offset_dialog.bl_idname, text='Offset...',
+ icon_value=get_icon_id('offset'))
+
+class IMAGE_EDITOR_PLUS_PT_adjust_panel(bpy.types.Panel):
+ bl_label = "Adjust"
+ bl_space_type = "IMAGE_EDITOR"
+ bl_region_type = "UI"
+ bl_category = "Image Editor+"
+ bl_options = {'DEFAULT_CLOSED'}
+
+ @classmethod
+ def poll(cls, context):
+ return context.area.spaces.active.mode != 'UV' \
+ and context.area.spaces.active.image != None \
+ and context.area.spaces.active.image.source != 'VIEWER'
+
+ def draw(self, context):
+ wm = context.window_manager
+
+ layout = self.layout
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_adjust_color_dialog.bl_idname, text='Hue/Saturation...',
+ icon_value=get_icon_id('adjust'))
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_adjust_brightness_dialog.bl_idname, text='Brightness/Contrast...',
+ icon_value=get_icon_id('adjust'))
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_adjust_gamma_dialog.bl_idname, text='Gamma...',
+ icon_value=get_icon_id('adjust'))
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_adjust_color_curve_dialog.bl_idname, text='Color Curve...',
+ icon_value=get_icon_id('color_curve'))
+
+ #row = layout.row()
+ #row.operator(IMAGE_EDITOR_PLUS_OT_replace_color_dialog.bl_idname, text='Replace Color...',
+ # icon='COLOR')
+
+class IMAGE_EDITOR_PLUS_PT_filter_panel(bpy.types.Panel):
+ bl_label = "Filter"
+ bl_space_type = "IMAGE_EDITOR"
+ bl_region_type = "UI"
+ bl_category = "Image Editor+"
+ bl_options = {'DEFAULT_CLOSED'}
+
+ @classmethod
+ def poll(cls, context):
+ return context.area.spaces.active.mode != 'UV' \
+ and context.area.spaces.active.image != None \
+ and context.area.spaces.active.image.source != 'VIEWER'
+
+ def draw(self, context):
+ wm = context.window_manager
+
+ layout = self.layout
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_blur_dialog.bl_idname, text='Blur...',
+ icon_value=get_icon_id('filter'))
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_sharpen_dialog.bl_idname, text='Sharpen...',
+ icon_value=get_icon_id('filter'))
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_add_noise_dialog.bl_idname, text='Add Noise...',
+ icon_value=get_icon_id('filter'))
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_pixelize_dialog.bl_idname, text='Pixelize...',
+ icon_value=get_icon_id('filter'))
+
+ row = layout.row()
+ row.operator(IMAGE_EDITOR_PLUS_OT_make_seamless_dialog.bl_idname, text='Make Seamless...',
+ icon_value=get_icon_id('filter'))
diff --git a/addon/ui_renderer.py b/addon/ui_renderer.py
new file mode 100644
index 0000000..14286bf
--- /dev/null
+++ b/addon/ui_renderer.py
@@ -0,0 +1,277 @@
+'''
+ Copyright (C) 2020 Akaneyu
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+'''
+
+import sys
+import math
+import time
+import bpy
+import bgl
+import blf
+import gpu
+from gpu_extras.batch import batch_for_shader
+from mathutils import Matrix, Vector
+import numpy as np
+
+default_vertex_shader = '''
+uniform mat4 ModelViewProjectionMatrix;
+
+in vec2 pos;
+
+void main()
+{
+ gl_Position = ModelViewProjectionMatrix * vec4(pos, 0, 1.0);
+}
+'''
+
+default_fragment_shader = '''
+uniform vec4 color;
+
+out vec4 fragColor;
+
+void main()
+{
+ fragColor = color;
+}
+'''
+
+dotted_line_vertex_shader = '''
+uniform mat4 ModelViewProjectionMatrix;
+
+in vec2 pos;
+in float arcLength;
+
+out float arcLengthOut;
+
+void main()
+{
+ arcLengthOut = arcLength;
+
+ gl_Position = ModelViewProjectionMatrix * vec4(pos, 0, 1.0);
+}
+'''
+
+dotted_line_fragment_shader = '''
+uniform float scale;
+uniform float offset;
+uniform vec4 color1;
+uniform vec4 color2;
+
+in float arcLengthOut;
+
+out vec4 fragColor;
+
+void main()
+{
+ if (step(sin((arcLengthOut + offset) * scale), 0.5) == 1) {
+ fragColor = color1;
+ } else {
+ fragColor = color2;
+ }
+}
+'''
+
+image_vertex_shader = '''
+uniform mat4 ModelViewProjectionMatrix;
+
+in vec2 pos;
+in vec2 texCoord;
+
+out vec2 texCoordOut;
+
+void main()
+{
+ gl_Position = ModelViewProjectionMatrix * vec4(pos, 0, 1.0);
+ texCoordOut = texCoord;
+}
+'''
+
+image_fragment_shader = '''
+uniform sampler2D image;
+
+in vec2 texCoordOut;
+
+out vec4 fragColor;
+
+void main()
+{
+ fragColor = texture(image, texCoordOut);
+}
+'''
+
+def make_scale_matrix(scale):
+ return Matrix([
+ [scale[0], 0, 0, 0],
+ [0, scale[1], 0, 0],
+ [0, 0, 1.0, 0],
+ [0, 0, 0, 1.0]
+ ])
+
+class UIRenderer:
+ def __init__(self):
+ self.default_shader = gpu.types.GPUShader(default_vertex_shader,
+ default_fragment_shader)
+ self.default_shader_u_color = self.default_shader.uniform_from_name("color")
+
+ self.dotted_line_shader = gpu.types.GPUShader(dotted_line_vertex_shader,
+ dotted_line_fragment_shader)
+ self.dotted_line_shader_u_color1 = self.dotted_line_shader.uniform_from_name("color1")
+ self.dotted_line_shader_u_color2 = self.dotted_line_shader.uniform_from_name("color2")
+
+ #self.image_shader = gpu.shader.from_builtin('2D_IMAGE')
+ self.image_shader = gpu.types.GPUShader(image_vertex_shader,
+ image_fragment_shader)
+
+ def render_selection_frame(self, pos, size, rot=0, scale=(1.0, 1.0)):
+ width, height = size[0], size[1]
+
+ bgl.glEnable(bgl.GL_BLEND)
+ bgl.glLineWidth(2.0)
+
+ with gpu.matrix.push_pop():
+ verts = [[0, 0], [0, height], [width, height], [width, 0], [0, 0]]
+ # T <= R <= S <= centering
+ mat = Matrix.Translation([pos[0] + width / 2.0, pos[1] + height / 2.0, 0]) \
+ @ Matrix.Rotation(rot, 4, 'Z') \
+ @ make_scale_matrix(scale) \
+ @ Matrix.Translation([-width / 2.0, -height / 2.0, 0])
+
+ for i, vert in enumerate(verts):
+ verts[i] = (mat @ Vector(vert + [0, 1]))[:2]
+
+ verts = np.array(verts, 'f')
+
+ arc_lengths = [0]
+ for a, b in zip(verts[:-1], verts[1:]):
+ arc_lengths.append(arc_lengths[-1] + np.linalg.norm(a - b))
+
+ batch = batch_for_shader(self.dotted_line_shader, 'LINE_STRIP',
+ {"pos": verts, "arcLength": arc_lengths})
+
+ self.dotted_line_shader.bind()
+
+ self.dotted_line_shader.uniform_float("scale", 0.6)
+ self.dotted_line_shader.uniform_float("offset", 0)
+ self.dotted_line_shader.uniform_vector_float(self.dotted_line_shader_u_color1,
+ np.array([1.0, 1.0, 1.0, 0.5], 'f'), 4)
+ self.dotted_line_shader.uniform_vector_float(self.dotted_line_shader_u_color2,
+ np.array([0.0, 0.0, 0.0, 0.5], 'f'), 4)
+
+ batch.draw(self.dotted_line_shader)
+
+ err = bgl.glGetError()
+ if err != bgl.GL_NO_ERROR:
+ print('render_selection_frame')
+ print('OpenGL error:', err)
+
+ def render_image_sub(self, img, pos, size, rot, scale):
+ width, height = size[0], size[1]
+
+ img.gl_load()
+
+ bgl.glActiveTexture(bgl.GL_TEXTURE0)
+ bgl.glBindTexture(bgl.GL_TEXTURE_2D, img.bindcode)
+ bgl.glTexParameteri(bgl.GL_TEXTURE_2D, bgl.GL_TEXTURE_MIN_FILTER, bgl.GL_NEAREST)
+ bgl.glTexParameteri(bgl.GL_TEXTURE_2D, bgl.GL_TEXTURE_MAG_FILTER, bgl.GL_NEAREST)
+
+ with gpu.matrix.push_pop():
+ gpu.matrix.translate([pos[0] + width / 2.0, pos[1] + height / 2.0])
+ gpu.matrix.multiply_matrix(
+ Matrix.Rotation(rot, 4, 'Z'))
+ gpu.matrix.scale(scale)
+ gpu.matrix.translate([-width / 2.0, -height / 2.0])
+
+ batch = batch_for_shader(self.image_shader, 'TRI_FAN',
+ {
+ "pos": [
+ (0, 0),
+ (width, 0),
+ size,
+ (0, height)
+ ],
+ "texCoord": [(0, 0), (1, 0), (1, 1), (0, 1)]
+ })
+
+ self.image_shader.bind()
+
+ self.image_shader.uniform_int('image', 0)
+
+ batch.draw(self.image_shader)
+
+ err = bgl.glGetError()
+ if err != bgl.GL_NO_ERROR:
+ print('render_image')
+ print('OpenGL error:', err)
+
+ def render_image(self, img, pos, size, rot=0, scale=(1.0, 1.0)):
+ bgl.glEnable(bgl.GL_BLEND)
+ # FIXME: glBlendFuncSeparate does not seem to be implemented,
+ # but we believe this blend mode was already applied
+ #bgl.glBlendFuncSeparate(bgl.GL_SRC_ALPHA, bgl.GL_ONE_MINUS_SRC_ALPHA,
+ # bgl.GL_ONE, bgl.GL_ONE_MINUS_SRC_ALPHA);
+
+ self.render_image_sub(img, pos, size, rot, scale)
+
+ def render_image_offscreen(self, img, rot=0, scale=(1.0, 1.0)):
+ width, height = img.size[0], img.size[1]
+
+ box = [[0, 0], [width, 0], [0, height], [width, height]]
+ mat = Matrix.Rotation(rot, 4, 'Z') \
+ @ make_scale_matrix(scale) \
+ @ Matrix.Translation([-width / 2.0, -height / 2.0, 0])
+ min_x, min_y = sys.float_info.max, sys.float_info.max
+ max_x, max_y = -sys.float_info.max, -sys.float_info.max
+
+ # calculate bounding box
+ for pos in box:
+ pos = mat @ Vector(pos + [0, 1])
+ min_x = min(min_x, pos[0])
+ min_y = min(min_y, pos[1])
+ max_x = max(max_x, pos[0])
+ max_y = max(max_y, pos[1])
+
+ ofs_width = math.ceil(max_x - min_x)
+ ofs_height = math.ceil(max_y - min_y)
+
+ ofs = gpu.types.GPUOffScreen(ofs_width, ofs_height)
+ with ofs.bind():
+ bgl.glDisable(bgl.GL_BLEND)
+
+ bgl.glClearColor(0, 0, 0, 0)
+ bgl.glClear(bgl.GL_COLOR_BUFFER_BIT)
+
+ with gpu.matrix.push_pop():
+ gpu.matrix.load_projection_matrix(Matrix.Identity(4))
+
+ gpu.matrix.load_identity()
+ gpu.matrix.scale([1.0 / (ofs_width / 2.0), 1.0 / (ofs_height / 2.0)])
+ gpu.matrix.translate([-width / 2.0, -height / 2.0])
+
+ self.render_image_sub(img, [0, 0], [width, height], rot, scale)
+
+ buff = bgl.Buffer(bgl.GL_BYTE, ofs_width * ofs_height * 4)
+ bgl.glReadBuffer(bgl.GL_BACK)
+ bgl.glReadPixels(0, 0, ofs_width, ofs_height, bgl.GL_RGBA,
+ bgl.GL_UNSIGNED_BYTE, buff)
+
+ ofs.free()
+
+ err = bgl.glGetError()
+ if err != bgl.GL_NO_ERROR:
+ print('render_image_offscreen')
+ print('OpenGL error:', err)
+
+ return buff, ofs_width, ofs_height
diff --git a/addon/utils.py b/addon/utils.py
new file mode 100644
index 0000000..79ab5b8
--- /dev/null
+++ b/addon/utils.py
@@ -0,0 +1,145 @@
+'''
+ Copyright (C) 2021 Akaneyu
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+'''
+
+import bpy
+import numpy as np
+
+def read_pixels_from_image(img):
+ width, height = img.size[0], img.size[1]
+
+ if bpy.app.version >= (2, 83, 0):
+ pixels = np.empty(len(img.pixels), dtype=np.float32);
+ img.pixels.foreach_get(pixels)
+ return np.reshape(pixels, (height, width, 4))
+ else:
+ return np.reshape(img.pixels[:], (height, width, 4))
+
+def write_pixels_to_image(img, pixels):
+ if bpy.app.version >= (2, 83, 0):
+ img.pixels.foreach_set(np.reshape(pixels, -1))
+ else:
+ img.pixels = np.reshape(pixels, -1)
+
+ if img.preview:
+ img.preview.reload()
+
+def rgb_to_hsl(rgb):
+ red = rgb[:, :, 0]
+ green = rgb[:, :, 1]
+ blue = rgb[:, :, 2]
+
+ max_chan = np.maximum(np.maximum(red, green), blue)
+ min_chan = np.minimum(np.minimum(red, green), blue)
+ sum = max_chan + min_chan
+ light = sum / 2.0
+
+ diff = max_chan - min_chan
+
+ sat_denom = 1.0 - np.abs(sum - 1.0)
+ sat_denom_safe = np.where(sat_denom == 0, 1.0, sat_denom) # for div by 0
+
+ sat = diff / sat_denom_safe
+
+ diff_safe = np.where(diff == 0, 1.0, diff) # for div by 0
+
+ hue_0 = np.zeros((rgb.shape[0], rgb.shape[1]))
+ hue_0 = np.where(np.equal(max_chan, red), (green - blue) / diff_safe, hue_0)
+ hue_0 = np.where(np.equal(max_chan, green), 2.0 + (blue - red) / diff_safe, hue_0)
+ hue_0 = np.where(np.equal(max_chan, blue), 4.0 + (red - green) / diff_safe, hue_0)
+
+ hue = (hue_0 / 6.0) % 1.0
+
+ return np.dstack((hue, sat, light))
+
+def hsl_to_rgb(hsl):
+ hue = hsl[:, :, 0]
+ sat = hsl[:, :, 1]
+ light = hsl[:, :, 2]
+
+ c = (1.0 - np.abs(light * 2.0 - 1.0)) * sat
+
+ index_f = hue * 6.0
+ index = (hue * 6.0).astype(int)
+
+ x = c * (1.0 - np.abs(index_f % 2.0 - 1.0))
+
+ zeros = np.zeros((index.shape[0], index.shape[1]))
+ red_0 = index.choose((c, x, zeros, zeros, x, c))
+ green_0 = index.choose((x, c, c, x, zeros, zeros))
+ blue_0 = index.choose((zeros, zeros, x, c, c, x))
+
+ m = light - c / 2.0
+
+ return np.dstack((red_0 + m, green_0 + m, blue_0 + m))
+
+def straight_to_premul_alpha(pixels):
+ alpha_chan = pixels[:, :, 3:]
+
+ return np.dstack((pixels[:, :, :3] * alpha_chan, alpha_chan))
+
+def premul_to_straight_alpha(pixels):
+ alpha_chan = pixels[:, :, 3:]
+ new_color_chan = pixels[:, :, :3] \
+ / np.where(alpha_chan == 0, 1.0, alpha_chan) # for div by 0
+
+ return np.dstack((new_color_chan, alpha_chan))
+
+def gaussian_blur_core(pixels, blur_size):
+
+ # straight => premul alpha
+ pixels = straight_to_premul_alpha(pixels)
+
+ height, width = pixels.shape[0], pixels.shape[1]
+
+ blur_size_safe = 1.0 if blur_size == 0 else blur_size
+
+ kernel_size = 2 * int(4 * blur_size + 0.5) + 1
+
+ kernel = np.zeros((kernel_size))
+
+ h_kernel_size = kernel_size // 2
+ for x in range(-h_kernel_size, h_kernel_size + 1):
+ kernel[x + h_kernel_size] = np.exp(-(x ** 2)/(2 * blur_size_safe ** 2))
+ kernel = kernel / np.sum(kernel)
+
+ pixels_pad = np.pad(pixels, ((kernel_size // 2, kernel_size // 2),
+ (kernel_size // 2, kernel_size // 2), (0, 0)), 'edge')
+ height_pad, width_pad = pixels_pad.shape[0], pixels_pad.shape[1]
+
+ gaus_y = np.zeros((height_pad, width, 4))
+ for x, v in enumerate(kernel):
+ gaus_y += v * pixels_pad[:, x:width + x]
+
+ gaus_x = np.zeros((height, width, 4))
+ for y, v in enumerate(kernel):
+ gaus_x += v * gaus_y[y:height + y]
+
+ new_pixels = np.clip(gaus_x, 0, 1.0)
+
+ # premul => straight alpha
+ return premul_to_straight_alpha(new_pixels)
+
+def convert_colorspace(pixels, src_colorspace, dest_colorspace):
+ if src_colorspace == dest_colorspace:
+ return
+
+ if src_colorspace == 'Linear' and dest_colorspace == 'sRGB':
+ pixels[:, :, 0:3] = pixels[:, :, :3] ** (1.0 / 2.2)
+ elif src_colorspace == 'sRGB' and dest_colorspace == 'Linear':
+ pixels[:, :, 0:3] = pixels[:, :, :3] ** 2.2
+
+ # unsupported conversion