55 lines
2.8 KiB
Python
55 lines
2.8 KiB
Python
"""Generic oriented ridge-width and continuity estimator; NumPy/Pillow only."""
|
|
import math
|
|
import numpy as np
|
|
from PIL import Image,ImageFilter
|
|
|
|
def smooth(lo,hi,x):
|
|
t=np.clip((x-lo)/(hi-lo),0,1)
|
|
return t*t*(3-2*t)
|
|
|
|
def shift(a,dx,dy):
|
|
"""Sample at (x+dx,y+dy), clamping at source edges."""
|
|
h,w=a.shape
|
|
p=np.pad(a,((abs(dy),abs(dy)),(abs(dx),abs(dx))),mode='edge')
|
|
return p[abs(dy)+dy:abs(dy)+dy+h,abs(dx)+dx:abs(dx)+dx+w]
|
|
|
|
def offset(direction,distance):
|
|
return (round(direction[0]*distance),round(direction[1]*distance))
|
|
|
|
def detect(image,recipe,diagnostics=False):
|
|
# Luminance rather than brightest RGB channel handles dark colored leading.
|
|
image=image.convert('RGB').filter(ImageFilter.GaussianBlur(recipe['lineAnalysisSmoothingRadius']))
|
|
gray=np.asarray(image.convert('L'),dtype=np.float32)/255.
|
|
darkness=1-smooth(*recipe['darkLuminanceSmoothstep'],gray)
|
|
bridge_darkness=1-smooth(*recipe['bridgeLuminanceSmoothstep'],gray)
|
|
result=np.zeros_like(gray);raw_max=np.zeros_like(gray);bridge_max=np.zeros_like(gray)
|
|
for index in range(recipe['orientations']):
|
|
angle=math.pi*index/recipe['orientations']
|
|
normal=(math.cos(angle),math.sin(angle));tangent=(-normal[1],normal[0])
|
|
# The same shoulder distance is used along each candidate line: support
|
|
# at a different width cannot rescue this orientation/scale's texture.
|
|
for radius in recipe['normalSampleRadii']:
|
|
dx,dy=offset(normal,radius)
|
|
shoulders=np.minimum(shift(gray,dx,dy),shift(gray,-dx,-dy))
|
|
shape=smooth(*recipe['ridgeContrastSmoothstep'],shoulders-gray)
|
|
ridge=shape*darkness
|
|
support=np.zeros_like(gray)
|
|
for distance in recipe['tangentSupportDistances']:
|
|
tx,ty=offset(tangent,distance)
|
|
support+=shift(ridge,tx,ty)+shift(ridge,-tx,-ty)
|
|
support/=2*len(recipe['tangentSupportDistances'])
|
|
coherent=ridge*smooth(*recipe['continuitySmoothstep'],support)
|
|
# Restore only short interruptions bracketed by matching ridges.
|
|
# Both shoulders and longer tangent support must agree at the gap.
|
|
for distance in recipe['bridgeDistances']:
|
|
tx,ty=offset(tangent,distance)
|
|
bracket=np.minimum(shift(ridge,tx,ty),shift(ridge,-tx,-ty))
|
|
bridge=bracket*np.maximum(shape,darkness)*bridge_darkness*smooth(*recipe['bridgeSupportSmoothstep'],support)
|
|
np.maximum(coherent,bridge,out=coherent)
|
|
if diagnostics: np.maximum(bridge_max,bridge,out=bridge_max)
|
|
np.maximum(result,coherent,out=result)
|
|
if diagnostics: np.maximum(raw_max,ridge,out=raw_max)
|
|
result[result>=recipe['highConfidenceProtectionThreshold']]=1.
|
|
if diagnostics: return result,{'raw-ridges':raw_max,'bridge-candidates':bridge_max}
|
|
return result
|