Skip to content

Commit

Permalink
New operator: line art from image
Browse files Browse the repository at this point in the history
  • Loading branch information
chsh2 committed Oct 28, 2022
1 parent d7a1c6e commit ba82f11
Show file tree
Hide file tree
Showing 3 changed files with 213 additions and 7 deletions.
2 changes: 1 addition & 1 deletion nijigp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
bl_info = {
"name" : "nijiGPen",
"author" : "https://github.com/chsh2/nijiGPen",
"description" : "Tools modifying Grease Pencil strokes in the 2D (XZ) plane",
"description" : "Tools modifying Grease Pencil strokes in a 2D plane",
"blender" : (3, 3, 0),
"version" : (0, 2, 0),
"location" : "View3D > Sidebar > NijiGP, in Draw and Edit mode of Grease Pencil objects",
Expand Down
202 changes: 202 additions & 0 deletions nijigp/operator_io_lineart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import bpy
from bpy_extras.io_utils import ImportHelper
from bpy_extras import image_utils
from numpy import size
from .utils import *

class ExtractLineartOperator(bpy.types.Operator, ImportHelper):
"""Generate strokes from a raster image of line art using medial axis algorithm"""
bl_idname = "gpencil.nijigp_extract_lineart"
bl_label = "Line Art from Image"
bl_category = 'View'
bl_options = {'REGISTER', 'UNDO'}

#directory: bpy.props.StringProperty(subtype='DIR_PATH')
#files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement)
filepath = bpy.props.StringProperty(name="File Path", subtype='FILE_PATH')
filter_glob: bpy.props.StringProperty(
default='*.jpg;*.jpeg;*.png;*.tif;*.tiff;*.bmp',
options={'HIDDEN'}
)

threshold: bpy.props.FloatProperty(
name='Color Threshold',
default=0.75, min=0, max=1,
description='Threshold on lightness, below which the pixels are regarded as a part of a stroke'
)
median_radius: bpy.props.IntProperty(
name='Median Filter Radius',
default=0, min=0, soft_max=15,
description='Denoise the image with a median filter. Disabled when the value is 0'
)
size: bpy.props.FloatProperty(
name='Size',
default=2, min=0.001, soft_max=10,
unit='LENGTH',
description='Dimension of the generated strokes'
)
sample_length: bpy.props.IntProperty(
name='Sample Length',
default=4, min=1, soft_max=32,
description='Number of pixels of the original image between two generated stroke points'
)
min_length: bpy.props.IntProperty(
name='Min Stroke Length',
default=4, min=0, soft_max=32,
description='Number of pixels of a line, below which a stroke will not be generated'
)
max_length: bpy.props.IntProperty(
name='Max Stroke Length',
default=512, min=0, soft_max=2048,
description='Number of pixels of a line, above which a stroke may be cut into two or more'
)
generate_color: bpy.props.BoolProperty(
name='Generate Vertex Color',
default=False,
description='Extract color information from the image and apply it to generated strokes'
)
generate_strength: bpy.props.BoolProperty(
name='Generate Pen Strength',
default=False,
description='Convert the lightness of the image as strength of stroke points'
)

def draw(self, context):
layout = self.layout
layout.label(text = "Image Options:")
box1 = layout.box()
box1.prop(self, "threshold")
box1.prop(self, "median_radius")
layout.label(text = "Stroke Options:")
box2 = layout.box()
box2.prop(self, "size")
box2.prop(self, "sample_length")
box2.prop(self, "min_length")
box2.prop(self, "max_length")
box2.prop(self, "generate_color")
box2.prop(self, "generate_strength")

def execute(self, context):
gp_obj = context.object
gp_layer = gp_obj.data.layers.active
if not gp_layer.active_frame:
gp_layer.frames.new(context.scene.frame_current)
strokes = gp_layer.frames[-1].strokes
else:
strokes = gp_layer.active_frame.strokes

try:
import skimage.morphology
import skimage.filters
import skimage.io
import numpy as np
except:
self.report({"ERROR"}, "Please install Scikit-Image in the Preferences panel.")

# Import the image file and read pixels
img_obj = image_utils.load_image(self.filepath, check_existing=True) # type: bpy.types.Image
img_W = img_obj.size[0]
img_H = img_obj.size[1]
img_mat = np.array(img_obj.pixels).reshape(img_H,img_W, img_obj.channels)
img_mat = np.flipud(img_mat)

# Preprocessing: binarization and denoise
lumi_mat = img_mat
if img_obj.channels > 2:
lumi_mat = 0.2126 * img_mat[:,:,0] + 0.7152 * img_mat[:,:,1] + 0.0722 * img_mat[:,:,2]
bin_mat = lumi_mat < self.threshold
if img_obj.channels > 3:
bin_mat = bin_mat * (img_mat[:,:,3]>0)

denoised_mat = img_mat
denoised_lumi_mat = lumi_mat
denoised_bin_mat = bin_mat
if self.median_radius > 0:
footprint = skimage.morphology.disk(self.median_radius)
denoised_mat = np.zeros(img_mat.shape)
for channel in range(img_obj.channels):
denoised_mat[:,:,channel] = skimage.filters.median(img_mat[:,:,channel], footprint)
denoised_lumi_mat = skimage.filters.median(lumi_mat, footprint)
denoised_bin_mat = skimage.filters.median(bin_mat, footprint)

# Get skeleton and distance information
skel_mat, dist_mat = skimage.morphology.medial_axis(denoised_bin_mat, return_distance=True)
line_thickness = dist_mat.max()
dist_mat /= line_thickness
dist_mat = dist_mat

# Convert skeleton into line segments
search_mat = np.zeros(skel_mat.shape)
def line_point_dfs(v, u):
"""
Traverse a 2D matrix to get connected pixels as a line
"""
def get_info(v, u):
if v<0 or v>=img_H:
return None
if u<0 or u>=img_W:
return None
if search_mat[v,u]>0:
return None
if skel_mat[v,u]==0:
return None
return (v,u)

line_points = []
# Search along the same direction if possible, otherwise choose a similar direction
deltas = ((0,-1), (-1,-1), (-1,0), (-1,1), (0,1), (1,1), (1,0), (1,-1))
search_indices = (0, 1, -1, 2, -2, 3, -3, 4)
idx0 = 0
pos = (v,u)
next_pos = None
while len(line_points) <= self.max_length:
line_points.append(pos)
search_mat[pos[0],pos[1]] = 1
for idx1 in search_indices:
true_idx = (idx0+idx1)%8
d = deltas[true_idx]
ret = get_info(pos[0]+d[0], pos[1]+d[1])
if ret:
next_pos = ret
idx0 = true_idx
break
if not next_pos:
break

pos = next_pos
next_pos = None

return line_points

lines = []
for v in range(img_H):
for u in range(img_W):
if search_mat[v,u]==0 and skel_mat[v,u]>0:
lines.append(line_point_dfs(v,u))

# Generate strokes according to line segments
scale_factor = min(img_H, img_W) / self.size
for line in lines:
if len(line) < self.min_length:
continue
point_count = len(line) // self.sample_length
if len(line)%self.sample_length != 1:
point_count += 1

strokes.new()
strokes[-1].line_width = int(line_thickness / scale_factor * 2000)
strokes[-1].points.add(point_count)

for i,point in enumerate(strokes[-1].points):
img_co = line[min(i*self.sample_length, len(line)-1)]
point.co = vec2_to_vec3( (img_co[1] - img_W/2, img_co[0] - img_H/2), 0, scale_factor)
point.pressure = dist_mat[img_co]
if self.generate_strength:
point.strength = 1 - denoised_lumi_mat[img_co]
if self.generate_color:
point.vertex_color[3] = 1
point.vertex_color[0] = denoised_mat[img_co[0], img_co[1], min(0, img_obj.channels-1)]
point.vertex_color[1] = denoised_mat[img_co[0], img_co[1], min(1, img_obj.channels-1)]
point.vertex_color[2] = denoised_mat[img_co[0], img_co[1], min(2, img_obj.channels-1)]

return {'FINISHED'}
16 changes: 10 additions & 6 deletions nijigp/ui_panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ def draw(self, context):
scene = context.scene
obj = context.object

layout.label(text="Shapes from Clipboard:")
layout.label(text="Clipboard Utilities:")
row = layout.row()
row.operator("gpencil.nijigp_paste_svg", text="Paste SVG Codes", icon="PASTEDOWN")

layout.label(text="Palettes and Colors:")
row = layout.row()
row.operator("gpencil.nijigp_paste_xml_palette", text="Paste XML Palette", icon="PASTEDOWN")

layout.label(text="File Import:")
row = layout.row()
row.operator("gpencil.nijigp_extract_lineart", text="Line Art from Image", icon="LINE_DATA")


class NIJIGP_PT_edit_panel_io(bpy.types.Panel):
Expand All @@ -68,14 +70,16 @@ def draw(self, context):
scene = context.scene
obj = context.object

layout.label(text="Shapes from Clipboard:")
layout.label(text="Clipboard Utilities:")
row = layout.row()
row.operator("gpencil.nijigp_paste_svg", text="Paste SVG Codes", icon="PASTEDOWN")

layout.label(text="Palettes and Colors:")
row = layout.row()
row.operator("gpencil.nijigp_paste_xml_palette", text="Paste XML Palette", icon="PASTEDOWN")

layout.label(text="File Import:")
row = layout.row()
row.operator("gpencil.nijigp_extract_lineart", text="Line Art from Image", icon="LINE_DATA")

class NIJIGP_PT_draw_panel_polygon(bpy.types.Panel):
bl_idname = 'NIJIGP_PT_draw_panel_polygon'
bl_label = "Polygon Operations"
Expand Down

0 comments on commit ba82f11

Please sign in to comment.