From 039ed6b60b6b749295c7b022b96070d98b1116b5 Mon Sep 17 00:00:00 2001 From: akaneyu Date: Tue, 5 Mar 2024 21:22:24 +0900 Subject: [PATCH] Initial commit --- .gitignore | 2 + addon/__init__.py | 205 +++++ addon/api.py | 103 +++ addon/app.py | 531 +++++++++++ addon/icons/adjust.png | Bin 0 -> 473 bytes addon/icons/check.png | Bin 0 -> 645 bytes addon/icons/clear.png | Bin 0 -> 654 bytes addon/icons/color_curve.png | Bin 0 -> 622 bytes addon/icons/crop.png | Bin 0 -> 428 bytes addon/icons/cut.png | Bin 0 -> 779 bytes addon/icons/deselect.png | Bin 0 -> 679 bytes addon/icons/fill.png | Bin 0 -> 806 bytes addon/icons/filter.png | Bin 0 -> 642 bytes addon/icons/flip_horz.png | Bin 0 -> 496 bytes addon/icons/flip_vert.png | Bin 0 -> 537 bytes addon/icons/move.png | Bin 0 -> 605 bytes addon/icons/offset.png | Bin 0 -> 579 bytes addon/icons/rot_left.png | Bin 0 -> 634 bytes addon/icons/rot_right.png | Bin 0 -> 649 bytes addon/icons/rotate.png | Bin 0 -> 806 bytes addon/icons/scale.png | Bin 0 -> 553 bytes addon/icons/select.png | Bin 0 -> 571 bytes addon/operators.py | 1564 ++++++++++++++++++++++++++++++++ addon/ui.py | 1703 +++++++++++++++++++++++++++++++++++ addon/ui_renderer.py | 277 ++++++ addon/utils.py | 145 +++ 26 files changed, 4530 insertions(+) create mode 100644 .gitignore create mode 100644 addon/__init__.py create mode 100644 addon/api.py create mode 100644 addon/app.py create mode 100644 addon/icons/adjust.png create mode 100644 addon/icons/check.png create mode 100644 addon/icons/clear.png create mode 100644 addon/icons/color_curve.png create mode 100644 addon/icons/crop.png create mode 100644 addon/icons/cut.png create mode 100644 addon/icons/deselect.png create mode 100644 addon/icons/fill.png create mode 100644 addon/icons/filter.png create mode 100644 addon/icons/flip_horz.png create mode 100644 addon/icons/flip_vert.png create mode 100644 addon/icons/move.png create mode 100644 addon/icons/offset.png create mode 100644 addon/icons/rot_left.png create mode 100644 addon/icons/rot_right.png create mode 100644 addon/icons/rotate.png create mode 100644 addon/icons/scale.png create mode 100644 addon/icons/select.png create mode 100644 addon/operators.py create mode 100644 addon/ui.py create mode 100644 addon/ui_renderer.py create mode 100644 addon/utils.py 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 0000000000000000000000000000000000000000..c8b82087ad4d9763c5f63a95c46cc798003b1c0f GIT binary patch literal 473 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA)5&NV_OdXt}40V~EG`@ZJ3;suwr! z^q)sPG46nTr?`Z~Mu!#a4hnVrXkBN?kzTuEC-;M5Rn|Z1wB-|!{GB9LdSU+9I zE_Ome7SPG6C9V-ADTyViR>?)FK#IZ0z{o(?&_LI~IK;@n%GAut$XMIJ(#pW#^2No# zP(#v?o1c=IR*74~q8@1rpaw~h4Z-Bu OF?hQAxvXk4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA$j?)88u?7#M>+T^vI^jxU{R>vz~dqHS&& zr;uom>juS*i;WZtJG?~86BJL~V69@Q;<8fQ!OR>q@y0}nO)b(3v}7N(FZ7#ZZ{3@$ z|8e=Ae!Knab*nq-k}m|m=lO6X)BZs753%b`9Et@qjf0ZhIOZ@+&uMs>cQw{djV*3- zY530bQa>J_;eGIt$6-bGT?TH3eGxSXgVvONm790!Ywc6@KTHC}FPJ_=DmyN} z&pV+~zw}q>0omtn2Nq8fzqFnyuYs4bneC@;&{lb~hnxGw8SHjse6VA^R0LFID9FJ1 z*-1V*DQLy=`zen^qaB#upXz6}3;n#o#4;p?t=X1vvzI{QwI z^!wTG>ckroQoZB-&;PLct#tGC#5ce9{AV`bc$FzQ)%O`N#8pdNBT7;dOH!?pi&B9U zgOP!efv%x}u7Pogk%5(|nU#^5wt=OUfkETT9%U2_x%nxXX_dG&eAr?#AE-eRWJ7R% qT1k0gQ7S`udAVL@UUqSEVnM22eo^}DcQ#T$MGT&k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA$u%@b+c~2F4gq7sn8f<4333dNDhSG{n2; z^60R?yUvftqWdmn(rD> zURG9D=J_zk@XWsNwLH7??_HVj^4OUvd(KQdvGVk~oYTupTvVnt-Z|mW`Ka!hnqW+dUf;Pe-N9H~enr=+NGxw*7#)cGMTH z8%3N!F#?N|m@T$)hr|f1Ym~oMWGQcRi^VGMp6m2|kqL)k524cU4L-B!GoVx1=>A3;yTM(S@q|!Za&mC+b=or;eoS%71kF# z+SWHk(`LtB^){EDjGqPJ;e4G3+IPQiolq>_cje#TvVW|Xj(tjYPyE#d40_cP*NBpo z#FA92Dyr(~v8;?}_O zTc8K1K@wy`aDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0uvp00i_>zopr04~At AJpcdz literal 0 HcmV?d00001 diff --git a/addon/icons/color_curve.png b/addon/icons/color_curve.png new file mode 100644 index 0000000000000000000000000000000000000000..19e7f49f6b7e901c5f85c0ab86627a5d0d9dda4d GIT binary patch literal 622 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA#|{>sde(8ErgW978;gFP(Z`+a*wh+bL63QWFo))f9W<7WT#@b)mN$_t_

Am-J7=DHxyu3as?Rblp*2;L@t16y5)(d9YTweF0{Q6ay4F|0+ zB+M4|`m$Bjytd@%Cskr`?Y_&)FWB{_Ci%TRJWowzr^>Q9;uj7ZZg~3X zj%u!6G5Z_A6lNZSb6TPA8pU4w^}m&Oxbo9r)*FKtS<8i&_)&-{%e|U56{{)V z-~VA=@5Z!C_tEm1z@Swvag8WRNi0dVN-jzTQVd20Mh3cu2D%2uAw~vPre;<~CfWv; zRt5%QWphDk0HGl_KP5A*61RqQZPjf+4U!-mg7ec#$`gxH8OqDc^)mCai<1)zQuXqS V(r3T3kpe1W@O1TaS?83{1OOqR>p=hj literal 0 HcmV?d00001 diff --git a/addon/icons/crop.png b/addon/icons/crop.png new file mode 100644 index 0000000000000000000000000000000000000000..64354406cdfcba61f992d5f8ef5f6b0aada3dc81 GIT binary patch literal 428 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA$KsTijfrP?e{PV~EG`M zbAbKP3dY53Ji^Qdn>Vu^j}r^Jq$^cm8vBxIof3&ISrP&F zxkaQI7~UNgT-yI?<{zLdR7+eVN>UO_QmvAUQh^kMk%5tcuAzahfpLhDft9J5m63(E zfu)s!!NqIe>rgc0=BH$)RpQpLpxgftP=h4MhT#0PlJdl&REF~Ma=pyF?Be9af>gcy WqV(DCY@~pS7(8A5T-G@yGywo+s)$Sg literal 0 HcmV?d00001 diff --git a/addon/icons/cut.png b/addon/icons/cut.png new file mode 100644 index 0000000000000000000000000000000000000000..8350ea720e819b7bb0966bb0ba97bf53dbf5c292 GIT binary patch literal 779 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA*^h#yoBY2F9D7E{-7{$KOuf?Jp82a%_L% zbhak%hg=;}!Uk(vnY^7HJdWH7h?Jl)W>>st5XzdTDv)i+r$uV0H; zBO8?`Ojp}CIbL#-#*8QC{5KOP%Vn)tI$hUC=poaYjpvo0m)o+RY2gg$JG#T&u+#NT z(Df59zfWn+a-H(cVe_JncQG6>G5IF%mlZl+itRsX>lqpU+=w^Z*Y;Lm=j_(=DLbDo z2|vi@ZI>y2`E2|H<+rU0WeF3XaUNRG8=4a*tXoijj{kwQzQMW83UQN|eS8#-=PNib zU!41ldBW2525Wu?9AyahF?3j}ev?I0;W&r6-a2M`&872W4?OEI)cU)&$#k;d67eIs zY@Q$c99PZJ*16;+tNErreqpz5g0%3B$Nmul^Ak5ZaXW39bS%w8ruXz3;qM}smK(H& zFz)#@qpxYT(cPe929`PZf{TtF^7Y(rbK~C>k?*dx;m*6(Z|}61(T{v#0R{D^sLq#%u#kxxpXJjSUE!SPO+>5LB zYExgcU+LptxmP$3+MVZ-_@b-@jCa)%*NBpo#FA92mmtT}V`<;yxP!WTttDnm{r-UW|68tIg literal 0 HcmV?d00001 diff --git a/addon/icons/deselect.png b/addon/icons/deselect.png new file mode 100644 index 0000000000000000000000000000000000000000..19cebc1fd53e2d8237c2452cbfc6e5a760549f5e GIT binary patch literal 679 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA$KsTijd*2F6lP7sn8f<7=nx^^*=1Xx+c{ z#hN9H%tTifZ1o5^XT9W@P!QK|&W>(fr{*Kd8>R`xIJAp&YOUQUePoi@(xnXEdzj^q z?%l>ZRsZ$Oq`cz3v~z#%_`Hf-tTvCA;oA?s1D3*G41JAE4R-N2+2t&jd9&|$*3=}n zePDzkfb-*5VLGR=!E{NpE<{EB6AYj^c__{v{(Z0WyZEUn4%!~3cu(;rzz zGuhQwPkm@@zp(MSdV1DYqdn>Ao&l?rOSfH6`nV+Z0HgA@ozGevUn!XWS-)|nRo})V*r==lVoujh}~9+ie&>G;3P>{N(mI(V+REz5al$feQ1yo3Uvh&OVfW zX;+9|CHS1lbUrpH@mmtT}V`<;yxP!WTttDnm{r-UW|;AH}~ literal 0 HcmV?d00001 diff --git a/addon/icons/fill.png b/addon/icons/fill.png new file mode 100644 index 0000000000000000000000000000000000000000..049bd20b4ce4096f4c3df4abf555cb48d2e7d55c GIT binary patch literal 806 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA$u%@b+c~2F5R*E{-7{$B#}m%n)%DY5Tud zW=>OUhO$P*L04TT7in(C3mZb6S{gVu@;gOwD^6W`#7)FeMf5(!Mzj%{-t*65hYbm`GAD_QA ziFRmLjNj(NY94Y`G&l2Kyw-}q#RsjAK8r#RpLUJ+ zM8)Iv>bh=X-qP3VIl{tG+HuRyKE89zvbSXUyV+t8^|#!A^Dkk)O($+O$gX@KC7$_wRl$0$&j)0tu4#QO zmcaJa)#6j^feow`9lsQcKE*mX!C8<`)MX5lF!N|bKK-bVf*T6W$$iT|f%*x0}+rZMwz@T*53ShEB(vX{< zl9^VCTLa5)fgYd+NstY}`DrEPiAAXl<>lpinR(g8$%zH2dih1^v)|cB0TnTLy85}S Ib4q9e0EfXvSO5S3 literal 0 HcmV?d00001 diff --git a/addon/icons/filter.png b/addon/icons/filter.png new file mode 100644 index 0000000000000000000000000000000000000000..1366859b976b9f49e4eea5ff5629f97f6add5a45 GIT binary patch literal 642 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA#|{>sde(83R0B978;gAHBNKn<?db4kb#!8#RU2(% z>-%}{^9y^Q-rM`TuI+K5e#^1lHp{av@mYlHP4LlJzDvxi*sY)G!wDs)BRg65&G;1V z+n~eXyD6pOx$urx>t-=n9ir|VEN?J&z7di#Or7AphrZ>xs}V5VP5B=3-hBb*K)U7 zZ;O}Gl^;t3bOpQds9DX*PPmT?t|D-pZ`OWxxk9fg}=7!*VtTM&&1?OrN zj&(=h`>T93mmtT}V`<;yxP!WTttDnm{r-UW|F}&u$ literal 0 HcmV?d00001 diff --git a/addon/icons/flip_horz.png b/addon/icons/flip_horz.png new file mode 100644 index 0000000000000000000000000000000000000000..fc9b5ca344c24c8c22bcf8ccf72b83dc508a6b94 GIT binary patch literal 496 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA((jr85suXrHHxV~EG`w^KIq9x@Pc-QTDb zq~d+E@$QQ`B~v09H?e46xb&b+FoIc=_g@Hy-oA#3!3%%vy8KuD=fCTcO7C@zz8XJ> zXXJbPqJ&%gtHx==3!4?DXuXhpkrLjQBT=y|^r=F`ry~cdcrMgTv=@95#=PW&=#llG zI1)J+Ov`pW?5bW=?!6{((ZRYVnLY3SAJTfsJ?-Dq)-49Cicc;b6FK6rj`gMRp<|I} z`aWfCxXkC!8h5w6UzzVk%H=cZwQN3&6%4^!tNag1T7LriM76{2OC z7#SED=o%X68W@Kd8CaQ`Ss7Yt8(3Ny82r@xt%;%`H$NpatrE9}j$-GjKn;>08-nxG qO3D+9QW?t2%k?tzvWt@w3sUv+i_&MmvylQSV(@hJb6Mw<&;$SmW~^@j literal 0 HcmV?d00001 diff --git a/addon/icons/flip_vert.png b/addon/icons/flip_vert.png new file mode 100644 index 0000000000000000000000000000000000000000..0470876166a849b28930760b1d917b3168067b06 GIT binary patch literal 537 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA$8^?6vhkq3511jv*e$-%hpVI^-bIqOKId z!L4cA-WtB)qfFVdEnm6hj=OCMWDzgsw&w0^^|j-!5172i#YLw?d|GP7=Zzck)0w~A zTe0`0KC4TYm)3?f7KfCpH@;12X}RKb;~T4?^s@sWLLKH$Vdd*zesa$bk!`URHk$;t z{pB(csJwpTl2`0o`7O;=QJQDO*musoapB{p&y#LRZrw7m@9Ne5?Vsd7oNjO{HRU(B za58swi~M!zr1|9c-D%=v3{kA3)XPvS3ZcCWK>~#=~_$0p49D5Uwb+_b$`1j z?|pPU$b5H7&33V#2e0>k=6rHxt(J-O4t}7QRZCnWN>UO_QmvAUQh^kMk%5tcuAzah zfpLhDft9J5m63tAfu)s!!6VC^k|-K-^HVa@DsgL=oWi&is6i5BLvVgtNqJ&XDnogB hxn5>oc5!lIL8@MUQTpt6Hc~)E44$rjF6*2UngGDS!mt1U literal 0 HcmV?d00001 diff --git a/addon/icons/move.png b/addon/icons/move.png new file mode 100644 index 0000000000000000000000000000000000000000..a515ff24f7e09432514a7d6d1ca226b338ebd7d8 GIT binary patch literal 605 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA$j?)88u?7#OuZT^vI^j&Gf6>vz~dqU}CY zw5oT^!qkN494BKX?)GM1P~JF&wL2%(Va_5R$rFbITFth7-Wk}f>)Q2oZ~vdaRc+^@ z!_!}NGw8=}IB-jjA^+x$Pkpl1R@{bq@fSBPd^lJ9L8exMemM(MG55sj`k6N$2sAbn zcZ=DL;*U|HTdDiTk{$=8vxYvHgYq#uku>O9%YRTi?+mmAo_Q*-SDVXEF zq@l4U;*_NePsfu&{fek8;egA0%bnlcU-ZH$MKJy0YKgBWl5~2f-g$6;<)+IO9~&>F zs|Z{=F`wy$AaAeg|LYs~xr@k8lQvF$z4-kJE#9k-tai_>XOvoc;Rt8;pUc3IR4s9h zC`m~yNwrEYN(E93Mg~R(x`qb22F4*q23DqKRz_yp29{O^28}O!luBuF?hQAxvXk4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA*spH>-pg7#IaTT^vI^jvt+B7sVr&-i|dlzB|fvAHK1A$qANtiJ5N z*J_2DvZprZO&Q8ab>hu)vN#C48&F9O! zEm{3$PP4$OsX2`rGntOPU@!Fg61s4~7FMmM^*yY@Qiros-~8o0GAm`d0oxn*jqVPv zmw!I>+41nH!UopK2OBSbUw^;v%GxSJi>>-c|MBQeE9@vWPp@E6ZS_|$`pWsh_hGYc zg~GFY9Cvu`gdUjfbh)hap@4QiOXn{Q1%umuoGJ;80)iFl$t6CCl^a*~MKWtM<+j&6 zo~-Kdss(U}b7% zWn`*tU}k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA$8^?6vg_42&M0E{-7{$G2WRugy{@as1zTWDicHGsBdoJGZ923^ z_F9_Q@4It-{s{bzrmA?mqco9w1Jm-=SCo&QmpNo;oibtc0{ zi3(wVq&pCRP3uaQ%{liqZ(b7Xi5C%lW+j^8>#9sw ze7SBmrDTC^?(2OQ<(6m$q*;C0k*5}!)ULkZjGA!ovJbOYlxPR%eDrEKKB1ETm7vp? z&F7^1&&)Qz=+aTB*I+8FVDuyA`d_h0ZTMYY_FiJlN3!2aSL1*P< zwu(T%@}RFvBCpv0iBZ0$9;n#g^TT2iBQTg%OI#yLQW8s2t&)pUffR$0fsui(p@FV} zafp$Dm8qGPk%6{>rImrfBg>tVC>nC}Q!>*kach{I!nhQuK@wy`aDG}zd16s2LwR|* gUS?i)adKios$PCk`s{Z$Qb0uvp00i_>zopr0HX!(6951J literal 0 HcmV?d00001 diff --git a/addon/icons/rot_right.png b/addon/icons/rot_right.png new file mode 100644 index 0000000000000000000000000000000000000000..a94a4783a542915eea1781c951dfc719761362ce GIT binary patch literal 649 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA*%~sArZ842XkXLf-QCR(UcGzbe&OJv#Shdtn)tdNC~1U>IC8Jm zxT;v+ek*IzvfQHPlM^2IJwG1%=~=JBX6>vQ{2X6Se>!vVr|jb0 zb+tJzG{{p@cl9FAyFacUWZ7NAB+9kM=;@DN#x({0$NtOm1Rq#-if?mCb(rkOv;#t^ z`);URElFRuLp*|co#bcB8S3xY%o>~@?fdU^QMR0k`-aTL%Zu2G-u;L_@Mxm-BuDPo znC}Q!>*kaceLY<`M*I wkObKfoS#-wo>-L1P+nfHmzkGcoSayYs+V7sKKq@G6i^X^r>mdKI;Vst0K!A{CIA2c literal 0 HcmV?d00001 diff --git a/addon/icons/rotate.png b/addon/icons/rotate.png new file mode 100644 index 0000000000000000000000000000000000000000..bc52b907ec826cc0dc2c92a7f9f96d1115e452e0 GIT binary patch literal 806 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA*^h#yoBY2F5R*E{-7{$B$0+^$1B6X`4Uu zqNj0|g2CZ{ldd~BPHA{>DRo5c=rhYZ6YAt*9X(~W8C#&lMW+^p#BF+7w>%{TT|{)# zCN2^b5qV|8nRaKM*iz$jcdBJ)x;|fX?(ezZ^X9*=d9Jp^%=22u`ne1AzLl`QHTlq7 z8KzXrbG;|L_Vp~5XM0{xG&t{)A^ck+k-yl-=rEV{%O@IlRVq#?Y@VGu;nTif2jhgL z_ieAf9Xj1+@5TKx^9)woZBpE_AyftZBLVY*h zx10@RIj?&|hw%`7)I&!V|9; zILvD>W=Pp7Ik}GCch58N-zPpb*}RX8dc5v)@2u@xyUgsqEbpDVz2M9N@6ZFUV?PxB zkl~rJrfGry-iR&2(J`xfpZxF*KiQ`BFt6;;Ki^0FE5#SxbGkLByX@$J_A4J>9B*mY z{c%)y;e#!UUro9Fh~vYO`#dt=18)9)ZMr;7+dMQ?IC|&N%1Jd(L^tb9Sk}CG%crT? zJ%KkrUnsHO`)lpinR(g8$%zH2dih1^v)|cB0TnTLy85}S Ib4q9e0Nf!?@&Et; literal 0 HcmV?d00001 diff --git a/addon/icons/scale.png b/addon/icons/scale.png new file mode 100644 index 0000000000000000000000000000000000000000..0aea17494436942f97f42fc67f93b68998324307 GIT binary patch literal 553 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA$*Chy6yN(05N4#}JR>Z>QR3F*!=K&6muz zX)WLIkt=G(p)kdZ4gy6i{z{Br*_v8=qdlw~v$HQ(G&)RD+9$UA-Madlh1(}I+&3^d z^TEd8zPY_y3is@Z>iiBf%ec0kuhGx=ILT>&M?zDbY~CVWmGo7PiW1T~#$oN29$mW9 z#}+chDK}<|+Q|KITR0(`^+6!7<5Izhc_%)0_bre8XxGoP^3!Cu4aeut40`-Jf3E9B>$fN@{fHNTSCdRladTGX8%8D{ntO{*Oa2-5F-OCQ!^`L18oCKD+2?)$vfLoH00)|WTsW())0S=(Hf{h v5@bVgep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&9mu6{1-oD!Mk4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SV3z?~q>DERQKI#7_k#M9T6{RtNftA(`uqtkW_42+zfE{-7{$Io6i^gHYz!g3+~ zh~*(!1EJ6z94jYy@I90&Y_(uD2`FZFYA(pQy!J zT;M6*y4+hh&^P%}(v3YVYgzBhZSnlbs`#M#&zk=rWrd; z{`;#PyZ2HdcBk0J!%;`r@|D7Vh!mzhKTtmR{arr(#QbLFZCZOCoV5OSW5tJuIhW!k z8ui_Tr0?i2j{5=(C)E!VcSO12ss3YzWRzD=AMbN@XZ7FW1Y=%Pvk%EJ)SMFG`>N S&PEETh{4m<&t;ucLK6TFPtQ;Q literal 0 HcmV?d00001 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