This commit is contained in:
Patrick Morgan
2024-12-15 20:23:15 -08:00
commit 2f43e5a6c7
10 changed files with 1702 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
yarn.lock
# Environment variables
.env
.env.local
.env.*.local
# IDE/Editor specific
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# Build/Distribution
dist/
build/
*.min.js
# Logs
logs/
*.log
# Cache
.cache/
.temp/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 codeprimate
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+48
View File
@@ -0,0 +1,48 @@
# ComfyUI Mask Contour Processor
A ComfyUI node that improves inpainting results by extending mask boundaries with geometric patterns, helping create smoother transitions and better context for AI-driven image completion.
## Installation
1. Navigate to your ComfyUI custom nodes directory:
```bash
cd ComfyUI/custom_nodes/
git clone https://github.com/codeprimate/ComfyUI-MaskContourProcessor
cd ComfyUI-MaskContourProcessor
pip install -r requirements.txt
```
## Usage
### Parameters
- **Line Length** (0.0 - 3.0): Controls pattern extension distance. Start with 1.0
- **Line Count** (1 - 40): Sets pattern density. Recommended: 20-30
- **Line Width** (0.0 - 0.1): Adjusts pattern thickness. Start with 0.01
### Recommended Settings
**Large Areas:**
- Line Length: 1.5-2.0
- Line Count: 30-40
- Line Width: 0.015-0.02
**Detail Work:**
- Line Length: 0.5-1.0
- Line Count: 25-35
- Line Width: 0.005-0.01
## Tips
- Start with conservative settings
- Adjust Line Length based on image scale
- Use higher Line Count for complex textures
- Maximum recommended image size: 4096x4096
## License
MIT License - see [LICENSE](LICENSE) file.
## Contributing
Contributions welcome. Please open an issue first for major changes.
+6
View File
@@ -0,0 +1,6 @@
from .nodes.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
# Uncomment to print loading message
print("\033[34mMask Contour Processor: \033[92mLoaded\033[0m")
+450
View File
@@ -0,0 +1,450 @@
import torch
import numpy as np
from skimage.draw import line_aa, circle_perimeter_aa
class MaskContourProcessor:
"""A class that adds decorative flame-like contour effects to binary masks.
This processor detects mask edges and generates flame-ray effects along the contour,
creating visually appealing decorative patterns that radiate from the mask's edges.
"""
# Effect generation constants
AMPLITUDE_INITIAL_MOD = 0.2
AMPLITUDE_DECAY_MOD = 0.8
MIN_LINE_WIDTH = 1.0
DEFAULT_STROKE_COLOR = 'black'
DEFAULT_ELEMENT_COLOR = 'black'
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"mask": ("MASK",),
"line_length": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 3.0,
"step": 0.01
}),
"line_count": ("INT", {
"default": 20,
"min": 1,
"max": 40,
"step": 1
}),
"line_width": ("FLOAT", {
"default": 0.01,
"min": 0.0,
"max": 0.1,
"step": 0.001
})
}
}
RETURN_TYPES = ("MASK",)
FUNCTION = "process_mask"
CATEGORY = "mask"
def __init__(self):
self.stroke_color = self.DEFAULT_STROKE_COLOR
self.element_color = self.DEFAULT_ELEMENT_COLOR
def set_colors(self, stroke_color, element_color):
"""Configure the colors used for rendering effects.
Args:
stroke_color (str): Color to use for the main flame strokes
element_color (str): Color to use for decorative elements
"""
self.stroke_color = stroke_color
self.element_color = element_color
def calculate_mask_centroid(self, mask_array):
"""Calculate the center of mass (centroid) of a binary mask.
Args:
mask_array (ndarray): Binary mask array with values between 0 and 1
Returns:
tuple: (x, y) coordinates of the mask's centroid
"""
# Create binary mask by thresholding at 0.5
binary_mask = (mask_array >= 0.5).astype(np.float32)
# Find coordinates of mask pixels
y_coords, x_coords = np.nonzero(binary_mask)
if len(x_coords) == 0: # Handle empty mask case
return (mask_array.shape[1] // 2, mask_array.shape[0] // 2)
# Calculate average x and y coordinates
center_x = np.mean(x_coords)
center_y = np.mean(y_coords)
return (center_x, center_y)
def detect_edge_points(self, mask_array, center):
"""Detect and sort edge points of a mask in clockwise order.
Args:
mask_array (ndarray): Binary mask array
center (tuple): (x, y) coordinates of the mask's centroid
Returns:
list: Edge points as [(x, y), ...] sorted clockwise around the center
"""
edges = set() # Use set to avoid duplicates
height, width = mask_array.shape
# Scan horizontally (left to right)
for y in range(height):
last_pixel = 0 # Start with black
for x in range(width):
current_pixel = mask_array[y, x]
if last_pixel != current_pixel:
edges.add((x, y))
last_pixel = current_pixel
# Scan vertically (top to bottom)
for x in range(width):
last_pixel = 0 # Start with black
for y in range(height):
current_pixel = mask_array[y, x]
if last_pixel != current_pixel:
edges.add((x, y))
last_pixel = current_pixel
# Convert to list and sort points clockwise around center
edge_points = list(edges)
center_x, center_y = center
# Sort points clockwise using arctan2
edge_points.sort(key=lambda p: np.arctan2(p[1] - center_y, p[0] - center_x))
return edge_points
def redistribute_points(self, points, target_count):
"""Redistribute points evenly along a contour path.
Args:
points (list): List of (x, y) points defining the contour
target_count (int): Desired number of evenly spaced points
Returns:
list: New list of (x, y) points evenly distributed along the contour
"""
# Calculate total perimeter length
total_length = 0
segments = []
for i in range(len(points)):
next_point = points[(i + 1) % len(points)]
length = np.sqrt(
(next_point[0] - points[i][0])**2 +
(next_point[1] - points[i][1])**2
)
total_length += length
segments.append({
'start': points[i],
'end': next_point,
'length': length
})
# Redistribute points evenly
spacing = total_length / target_count
new_points = []
current_dist = 0
current_segment = 0
for i in range(target_count):
# Skip to next segment if we've exceeded current segment length
while current_dist >= segments[current_segment]['length']:
current_dist -= segments[current_segment]['length']
current_segment = (current_segment + 1) % len(segments)
# Calculate interpolation factor for current position
seg = segments[current_segment]
t = current_dist / seg['length']
# Interpolate position
new_points.append((
seg['start'][0] + (seg['end'][0] - seg['start'][0]) * t,
seg['start'][1] + (seg['end'][1] - seg['start'][1]) * t
))
current_dist += spacing
return new_points
def calculate_base_line_width(self, edge_points, line_width):
"""Calculate the base width for effect lines based on contour size.
Args:
edge_points (list): List of (x, y) edge points
line_width (float): Width parameter for scaling
Returns:
float: Calculated base line width
"""
if not edge_points:
return 10 # Default fallback value
# Calculate circumference by summing distances between consecutive points
circumference = 0
for i in range(len(edge_points)):
current = edge_points[i]
next_point = edge_points[(i + 1) % len(edge_points)]
distance = np.sqrt(
(next_point[0] - current[0])**2 +
(next_point[1] - current[1])**2
)
circumference += distance
# Return max of 1 or circumference * line_width
return max(1.0, circumference * line_width)
def generate_flame_ray_effect(self, point, next_point, line_length, center, base_line_width):
"""Generate data for a flame-like ray effect emanating from an edge point.
Args:
point (tuple): (x, y) starting point on the contour
next_point (tuple): (x, y) next point on the contour
line_length (float): Length of the flame effect
center (tuple): (x, y) mask centroid
base_line_width (float): Base width for effect lines
Returns:
dict: Effect data containing path segments and decorative elements
"""
# Calculate direction vectors for current point
dx1 = point[0] - center[0]
dy1 = point[1] - center[1]
length1 = np.sqrt(dx1 * dx1 + dy1 * dy1)
normalized_dx1 = dx1 / length1
normalized_dy1 = dy1 / length1
# Calculate direction vectors for next point
dx2 = next_point[0] - center[0]
dy2 = next_point[1] - center[1]
length2 = np.sqrt(dx2 * dx2 + dy2 * dy2)
normalized_dx2 = dx2 / length2
normalized_dy2 = dy2 / length2
# Calculate midpoint between current and next point
mid_point = {
'x': (point[0] + next_point[0]) / 2,
'y': (point[1] + next_point[1]) / 2
}
num_waves = max(5, line_length / 5)
initial_amplitude = line_length * self.AMPLITUDE_INITIAL_MOD
min_line_width = self.MIN_LINE_WIDTH
segments = []
decorative_elements = []
# Generate path segments for main flame rays
for i in range(int(num_waves)):
t = i / num_waves
next_t = (i + 1) / num_waves
# Calculate current wave points
wave_x = point[0] + normalized_dx1 * line_length * t
wave_y = point[1] + normalized_dy1 * line_length * t
next_wave_x = point[0] + normalized_dx1 * line_length * next_t
next_wave_y = point[1] + normalized_dy1 * line_length * next_t
# Calculate wave amplitudes and offsets
current_amplitude = initial_amplitude * (1 - t * self.AMPLITUDE_DECAY_MOD)
offset_x = -normalized_dy1 * np.sin(t * np.pi * 2) * current_amplitude
offset_y = normalized_dx1 * np.sin(t * np.pi * 2) * current_amplitude
next_amplitude = initial_amplitude * (1 - next_t * self.AMPLITUDE_DECAY_MOD)
next_offset_x = -normalized_dy1 * np.sin(next_t * np.pi * 2) * next_amplitude
next_offset_y = normalized_dx1 * np.sin(next_t * np.pi * 2) * next_amplitude
# Interpolate between direction vectors for virtual flame
interpolation_factor = 0.5
virtual_dx = normalized_dx1 * (1 - interpolation_factor) + normalized_dx2 * interpolation_factor
virtual_dy = normalized_dy1 * (1 - interpolation_factor) + normalized_dy2 * interpolation_factor
# Normalize interpolated direction
virtual_length = np.sqrt(virtual_dx * virtual_dx + virtual_dy * virtual_dy)
normalized_virtual_dx = virtual_dx / virtual_length
normalized_virtual_dy = virtual_dy / virtual_length
# Calculate virtual flame point
virtual_flame_x = mid_point['x'] + normalized_virtual_dx * line_length * t
virtual_flame_y = mid_point['y'] + normalized_virtual_dy * line_length * t
# Add sinusoidal pattern to virtual flame
virtual_amplitude = current_amplitude
virtual_offset_x = -normalized_virtual_dy * np.sin(t * np.pi * 2) * virtual_amplitude
virtual_offset_y = normalized_virtual_dx * np.sin(t * np.pi * 2) * virtual_amplitude
segments.append({
'startPoint': {
'x': wave_x + offset_x,
'y': wave_y + offset_y
},
'endPoint': {
'x': next_wave_x + next_offset_x,
'y': next_wave_y + next_offset_y
},
'properties': {
'width': base_line_width - (base_line_width - min_line_width) * t,
'strokeColor': self.stroke_color
}
})
# Add decorative elements along virtual flame path
start_step = 2
if i >= start_step and i % 3 == 0:
progress = (i - start_step) / (num_waves - start_step)
min_radius = min_line_width / 2
max_radius = base_line_width * 0.6
current_radius = min_radius + (max_radius - min_radius) * progress
decorative_elements.append({
'position': {
'x': virtual_flame_x + virtual_offset_x,
'y': virtual_flame_y + virtual_offset_y
},
'type': 'circle',
'properties': {
'radius': current_radius,
'color': self.element_color
}
})
return {
'origin': {'x': point[0], 'y': point[1]},
'path': {
'direction': {'dx': normalized_dx1, 'dy': normalized_dy1},
'length': line_length,
'segments': segments
},
'decorativeElements': decorative_elements
}
def render_effect_to_mask(self, mask_array, effect_data):
"""Render a flame effect onto a mask using anti-aliased drawing.
Args:
mask_array (ndarray): Target mask array
effect_data (dict): Effect specification data
Returns:
ndarray: Updated mask with rendered effect
"""
# Create a temporary canvas for anti-aliased drawing
height, width = mask_array.shape
canvas = np.zeros((height, width), dtype=np.float32)
# Render path segments
for segment in effect_data['path']['segments']:
start = segment['startPoint']
end = segment['endPoint']
width = segment['properties']['width']
# Draw anti-aliased line
rr, cc, val = line_aa(
int(start['y']), int(start['x']),
int(end['y']), int(end['x'])
)
# Filter valid coordinates
mask = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width)
rr, cc, val = rr[mask], cc[mask], val[mask]
# Apply line width
canvas[rr, cc] = np.maximum(canvas[rr, cc], val * width)
# Render decorative elements
if 'decorativeElements' in effect_data:
for element in effect_data['decorativeElements']:
if element['type'] == 'circle':
pos = element['position']
radius = element['properties']['radius']
# Draw anti-aliased circle
rr, cc, val = circle_perimeter_aa(
int(pos['y']), int(pos['x']),
int(radius)
)
# Filter valid coordinates
mask = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width)
rr, cc, val = rr[mask], cc[mask], val[mask]
canvas[rr, cc] = np.maximum(canvas[rr, cc], val)
# Combine with original mask
return np.maximum(mask_array, canvas)
def process_mask(self, mask, line_length, line_count, line_width):
"""Process a mask by adding flame-like contour effects.
Args:
mask (tensor): Input mask tensor
line_length (float): Relative length of flame effects
line_count (int): Number of flame effects to generate
line_width (float): Width of effect lines
Returns:
tuple: Single-element tuple containing the processed mask tensor
"""
# Convert mask to numpy
mask_np = mask.cpu().numpy() if isinstance(mask, torch.Tensor) else np.array(mask)
# Create output mask
output_mask = mask_np.copy()
# Calculate centroid
center = self.calculate_mask_centroid(mask_np)
# Detect edge points
edge_points = self.detect_edge_points(mask_np, center)
# Calculate base line width
base_line_width = self.calculate_base_line_width(edge_points, line_width)
# Select evenly distributed points
selected_points = self.redistribute_points(edge_points, line_count)
# Generate all effects data first
effects_data = []
for i in range(len(selected_points)):
point = selected_points[i]
next_point = selected_points[(i + 1) % len(selected_points)]
# Calculate effect length based on distance from center
dx = point[0] - center[0]
dy = point[1] - center[1]
distance_from_center = np.sqrt(dx*dx + dy*dy)
effect_length = distance_from_center * line_length
effect = self.generate_flame_ray_effect(
point,
next_point,
effect_length,
center,
base_line_width
)
effects_data.append(effect)
# Render all effects
for effect in effects_data:
output_mask = self.render_effect_to_mask(output_mask, effect)
return (torch.from_numpy(output_mask),)
# Node class mappings
NODE_CLASS_MAPPINGS = {
"MaskContourProcessor": MaskContourProcessor
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MaskContourProcessor": "Mask Contour Processor"
}
+3
View File
@@ -0,0 +1,3 @@
numpy>=1.20.0
torch>=1.7.0
scikit-image
+54
View File
@@ -0,0 +1,54 @@
!_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/
!_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/
!_TAG_PROGRAM_AUTHOR Darren Hiebert /dhiebert@users.sourceforge.net/
!_TAG_PROGRAM_NAME Exuberant Ctags //
!_TAG_PROGRAM_URL http://ctags.sourceforge.net /official site/
!_TAG_PROGRAM_VERSION 5.8 //
AMPLITUDE_DECAY_MOD ./nodes/nodes.py /^ AMPLITUDE_DECAY_MOD = 0.8$/;" v class:MaskContourProcessor
AMPLITUDE_INITIAL_MOD ./nodes/nodes.py /^ AMPLITUDE_INITIAL_MOD = 0.2$/;" v class:MaskContourProcessor
CATEGORY ./nodes/nodes.py /^ CATEGORY = "mask"$/;" v class:MaskContourProcessor
DEFAULT_ELEMENT_COLOR ./nodes/nodes.py /^ DEFAULT_ELEMENT_COLOR = 'black'$/;" v class:MaskContourProcessor
DEFAULT_STROKE_COLOR ./nodes/nodes.py /^ DEFAULT_STROKE_COLOR = 'black'$/;" v class:MaskContourProcessor
FUNCTION ./nodes/nodes.py /^ FUNCTION = "process_mask"$/;" v class:MaskContourProcessor
HTMLCanvasElement ./web/js/maskProcessor.test.js /^HTMLCanvasElement.prototype.getContext = () => mockContext;$/;" c
HTMLCanvasElement.getContext ./web/js/maskProcessor.test.js /^HTMLCanvasElement.prototype.getContext = () => mockContext;$/;" m
INPUT_TYPES ./nodes/nodes.py /^ def INPUT_TYPES(s):$/;" m class:MaskContourProcessor
MIN_LINE_WIDTH ./nodes/nodes.py /^ MIN_LINE_WIDTH = 1.0$/;" v class:MaskContourProcessor
MaskContourProcessor ./nodes/nodes.py /^class MaskContourProcessor:$/;" c
NODE_CLASS_MAPPINGS ./__init__.py /^from .nodes.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS$/;" i
NODE_CLASS_MAPPINGS ./nodes/nodes.py /^NODE_CLASS_MAPPINGS = {$/;" v
NODE_DISPLAY_NAME_MAPPINGS ./__init__.py /^from .nodes.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS$/;" i
NODE_DISPLAY_NAME_MAPPINGS ./nodes/nodes.py /^NODE_DISPLAY_NAME_MAPPINGS = {$/;" v
RETURN_TYPES ./nodes/nodes.py /^ RETURN_TYPES = ("MASK",)$/;" v class:MaskContourProcessor
__all__ ./__init__.py /^__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']$/;" v
__init__ ./nodes/nodes.py /^ def __init__(self):$/;" m class:MaskContourProcessor
calculate_base_line_width ./nodes/nodes.py /^ def calculate_base_line_width(self, edge_points, line_width):$/;" m class:MaskContourProcessor
calculate_mask_centroid ./nodes/nodes.py /^ def calculate_mask_centroid(self, mask_array):$/;" m class:MaskContourProcessor
circle_perimeter_aa ./nodes/nodes.py /^from skimage.draw import line_aa, circle_perimeter_aa$/;" i
const.clearRect ./web/js/maskProcessor.test.js /^ clearRect: jest.fn(),$/;" p
const.fillStyle ./web/js/maskProcessor.test.js /^ fillStyle: null,$/;" p
const.height ./web/js/maskProcessor.test.js /^ height: 0,$/;" p
const.lineCap ./web/js/maskProcessor.test.js /^ lineCap: null,$/;" p
const.lineWidth ./web/js/maskProcessor.test.js /^ lineWidth: null,$/;" p
const.strokeStyle ./web/js/maskProcessor.test.js /^ strokeStyle: null,$/;" p
const.style ./web/js/maskProcessor.test.js /^ style: {$/;" p
const.width ./web/js/maskProcessor.test.js /^ width: 0,$/;" p
const.x ./web/js/maskProcessor.js /^ x: (point.x + nextPoint.x) \/ 2,$/;" p
detect_edge_points ./nodes/nodes.py /^ def detect_edge_points(self, mask_array, center):$/;" m class:MaskContourProcessor
downloadCanvas ./web/js/index.js /^function downloadCanvas(type) {$/;" f
generate_flame_ray_effect ./nodes/nodes.py /^ def generate_flame_ray_effect(self, point, next_point, line_length, center, base_line_width):$/;" m class:MaskContourProcessor
line_aa ./nodes/nodes.py /^from skimage.draw import line_aa, circle_perimeter_aa$/;" i
maskInfo.center ./web/js/maskProcessor.js /^ center: center,$/;" p
maskInfo.center ./web/js/maskProcessor.js /^ center: { x: centerX, y: centerY },$/;" p
maskInfo.edgePoints ./web/js/maskProcessor.js /^ edgePoints: this.redistributePoints(edges, 100) \/\/ Maintain consistent point count$/;" p
np ./nodes/nodes.py /^import numpy as np$/;" i
process_mask ./nodes/nodes.py /^ def process_mask(self, mask, line_length, line_count, line_width):$/;" m class:MaskContourProcessor
redistribute_points ./nodes/nodes.py /^ def redistribute_points(self, points, target_count):$/;" m class:MaskContourProcessor
regenerateMask ./web/js/index.js /^function regenerateMask() {$/;" f
render_effect_to_mask ./nodes/nodes.py /^ def render_effect_to_mask(self, mask_array, effect_data):$/;" m class:MaskContourProcessor
set_colors ./nodes/nodes.py /^ def set_colors(self, stroke_color, element_color):$/;" m class:MaskContourProcessor
torch ./nodes/nodes.py /^import torch$/;" i
updateControl ./web/js/index.js /^function updateControl(controlId, value, processorProperty, formatValue = v => v) {$/;" f
updateLineLength ./web/js/index.js /^function updateLineLength(value) {$/;" f
updateLineWidth ./web/js/index.js /^function updateLineWidth(value) {$/;" f
updateNumLines ./web/js/index.js /^function updateNumLines(value) {$/;" f
+177
View File
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mask Contour Processor v0.5</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@keyframes errorFlash {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
50% { box-shadow: 0 0 40px 10px rgba(239, 68, 68, 0.7); }
}
@keyframes successFlash {
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
50% { box-shadow: 0 0 20px 0 rgba(34, 197, 94, 0.5); }
}
.error-flash {
animation: errorFlash 0.8s ease-out;
}
.success-flash {
animation: successFlash 0.8s ease-out;
}
#dropZone {
transition: all 0.3s ease;
opacity: 0;
background: rgba(255, 255, 255, 0.9);
border: 2px solid transparent;
}
#dropZone:hover,
#dropZone.drag-active {
opacity: 1;
border-color: #3b82f6; /* Tailwind's blue-500 */
box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.1); /* Subtle inner glow */
}
.download-btn {
position: absolute;
bottom: 1rem;
right: 1rem;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.download-btn:hover {
transform: scale(1.2);
}
@keyframes explode {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
.exploding {
animation: explode 0.5s ease-out forwards;
pointer-events: none;
}
</style>
</head>
<body class="p-5 font-sans bg-gray-100">
<main class="container max-w-[850px] mx-auto">
<!-- Controls Section -->
<section class="flex items-center gap-4 mb-4">
<button
onclick="regenerateMask()"
class="px-5 py-2.5 bg-blue-600 text-white rounded hover:bg-blue-700 text-base cursor-pointer transition-colors"
>
Generate New Mask
</button>
<!-- Line Length Control -->
<div class="flex items-center gap-2">
<label for="lineLength" class="text-sm text-gray-700">Line Length:</label>
<input
type="range"
id="lineLength"
min="0"
max="3"
step="0.01"
value="1"
class="w-32 cursor-pointer"
oninput="updateLineLength(this.value)"
>
<span id="lineLengthValue" class="text-sm text-gray-700 font-mono">1</span>
</div>
<!-- Line Spacing Control -->
<div class="flex items-center gap-2">
<label for="numLines" class="text-sm text-gray-700">Line Count:</label>
<input
type="range"
id="numLines"
min="1"
max="40"
value="10"
class="w-32 cursor-pointer"
oninput="updateNumLines(this.value)"
>
<span id="numLinesValue" class="text-sm text-gray-700 font-mono">10</span>
</div>
<!-- Line Width Control -->
<div class="flex items-center gap-2">
<label for="lineWidth" class="text-sm text-gray-700">Line Width:</label>
<input
type="range"
id="lineWidth"
min="0"
max="100"
value="1"
step="0.1"
class="w-32 cursor-pointer"
oninput="updateLineWidth(this.value)"
>
<span id="lineWidthValue" class="text-sm text-gray-700 font-mono">0.010</span>
</div>
</section>
<!-- Canvas Section -->
<section class="flex gap-5">
<div class="relative group">
<canvas
id="inputCanvas"
width="400"
height="400"
class="bg-white border border-gray-300 rounded shadow-sm"
aria-label="Input mask canvas"
></canvas>
<div
id="dropZone"
class="absolute inset-0 rounded flex items-center justify-center"
>
<div class="text-center">
<svg class="w-8 h-8 mx-auto mb-2 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-sm text-gray-600">Drop image here</p>
</div>
</div>
</div>
<div class="relative group">
<canvas
id="outputCanvas"
width="400"
height="400"
class="bg-white border border-gray-300 rounded shadow-sm"
aria-label="Output mask canvas with contour lines"
></canvas>
<button
onclick="downloadCanvas('output')"
class="download-btn opacity-0 group-hover:opacity-100"
aria-label="Download output canvas"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</div>
</section>
</main>
<!-- Scripts -->
<script src="js/maskProcessor.js"></script>
<script src="js/index.js"></script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
// Initialize processor
const processor = new MaskProcessor(400, 400, 1.0, 20, 0.01);
// Control handlers
function updateControl(controlId, value, processorProperty, formatValue = v => v) {
document.getElementById(`${controlId}Value`).textContent = formatValue(value);
processor[processorProperty] = value;
processor.processMask();
}
function updateLineLength(value) {
updateControl('lineLength', value, 'lineLengthRatio', v => (v/1.0).toFixed(2));
}
function updateNumLines(value) {
updateControl('numLines', value, 'numLines', v => parseInt(v));
}
function updateLineWidth(value) {
updateControl('lineWidth', (value/1000).toFixed(3), 'lineWidthRatio', v => (v*1000));
}
function regenerateMask() {
console.log('regenerateMask called');
try {
processor.generateRandomMask();
processor.processMask();
} catch (error) {
console.error('Error in regenerateMask:', error);
}
}
// Initial mask generation
console.log('Running initial mask generation');
regenerateMask();
// Drag and drop handlers
const dropZone = document.getElementById('dropZone');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-active');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-active');
});
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
dropZone.classList.remove('drag-active');
// Remove any existing animation classes
dropZone.classList.remove('error-flash');
dropZone.classList.remove('success-flash');
// Force a reflow to reset the animation
void dropZone.offsetWidth;
const file = e.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) {
dropZone.classList.add('error-flash');
return;
}
try {
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
// Use the new loadMask method
processor.loadMask(img);
dropZone.classList.add('success-flash');
} catch (error) {
console.error('Error loading image:', error);
dropZone.classList.add('error-flash');
}
});
// Show drop zone on page load
dropZone.style.display = 'flex';
// Add download functionality
function downloadCanvas(type) {
const button = event.currentTarget;
const canvas = type === 'input' ? processor.domInputCanvas : processor.domOutputCanvas;
// Create and trigger download
const link = document.createElement('a');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
link.download = `mask-${type}-${timestamp}.png`;
link.href = canvas.toDataURL('image/png');
// Add explosion animation
button.classList.add('exploding');
// Remove animation class after it completes
button.addEventListener('animationend', () => {
button.classList.remove('exploding');
}, { once: true });
link.click();
}
+806
View File
@@ -0,0 +1,806 @@
/**
* @typedef {Object} Point
* @property {number} x - X coordinate
* @property {number} y - Y coordinate
*/
/**
* @typedef {Object} PathSegment
* @property {Point} startPoint - Starting point of the segment
* @property {Point} endPoint - Ending point of the segment
* @property {Object} properties - Segment properties
* @property {number} properties.width - Line width
* @property {string} properties.strokeColor - Stroke color
*/
/**
* @typedef {Object} DecorativeElement
* @property {Point} position - Element position
* @property {string} type - Element type (e.g., 'circle', 'dot')
* @property {Object} properties - Element properties
* @property {number} properties.radius - Element radius
* @property {string} properties.color - Element color
*/
/**
* @typedef {Object} RadiatingEffect
* @property {Point} origin - Starting point on the boundary
* @property {Object} path - Path information
* @property {Object} path.direction - Direction vector
* @property {number} path.direction.dx - X component of direction
* @property {number} path.direction.dy - Y component of direction
* @property {number} path.length - Path length
* @property {PathSegment[]} path.segments - Path segments
* @property {DecorativeElement[]} [decorativeElements] - Optional decorative elements
*/
/**
* @typedef {Object} MaskInfo
* @property {Point} center - Centroid of the mask
* @property {Point[]} edgePoints - Array of points along the mask's edge
*/
/**
* @typedef {Object} OverlayEffect
* @property {Object} baseShape - Base shape information
* @property {Point} baseShape.center - Center point
* @property {Point[]} baseShape.boundaryPoints - Boundary points
* @property {Object} baseShape.bounds - Shape bounds
* @property {number} baseShape.bounds.width - Width
* @property {number} baseShape.bounds.height - Height
* @property {RadiatingEffect[]} radiatingEffects - Array of radiating effects
*/
/**
* @class MaskProcessor
* @description Processes bitmap masks to generate decorative flame-like effects radiating from edges.
*/
class MaskProcessor {
/**
* @constructor
* @param {number} width - Canvas width in pixels
* @param {number} height - Canvas height in pixels
* @param {number} [lineLengthRatio=1.0] - Ratio controlling the length of radiating lines
* @param {number} [numLines=10] - Number of radiating lines to draw
* @param {number} [lineWidthRatio=0.01] - Ratio of line width to contour circumference
*/
constructor(width, height, lineLengthRatio = 1.0, numLines = 20, lineWidthRatio = 0.01) {
this.debug = false;
this.width = width;
this.height = height;
this.inputCanvas = document.createElement('canvas');
this.outputCanvas = document.createElement('canvas');
this.inputCanvas.width = width;
this.inputCanvas.height = height;
this.outputCanvas.width = width;
this.outputCanvas.height = height;
this.inputCtx = this.inputCanvas.getContext('2d');
this.outputCtx = this.outputCanvas.getContext('2d');
this.maskInfo = null;
this.lineLengthRatio = lineLengthRatio;
this.numLines = numLines;
this.lineWidthRatio = lineWidthRatio;
this.strokeColor = 'black';
this.elementColor = 'black';
this.domInputCanvas = document.getElementById('inputCanvas');
this.domOutputCanvas = document.getElementById('outputCanvas');
if (!this.domInputCanvas || !this.domOutputCanvas) {
console.error('Failed to find canvas elements');
}
}
/**
* @method generateRandomMask
* @description Generates a random organic mask shape using quadratic curves and enhanced bulge calculations
* @returns {MaskInfo} Contains center point and edge points of the generated mask
*/
generateRandomMask() {
this.inputCanvas.width = 400;
this.inputCanvas.height = 400;
this.width = 400;
this.height = 400;
// Reset DOM canvas dimensions
this.domInputCanvas.width = 400;
this.domInputCanvas.height = 400;
this.domOutputCanvas.width = 400;
this.domOutputCanvas.height = 400;
// Reset display styles
this.domInputCanvas.style.width = '400px';
this.domInputCanvas.style.height = '400px';
this.domOutputCanvas.style.width = '400px';
this.domOutputCanvas.style.height = '400px';
// Clear canvas
this.inputCtx.fillStyle = 'white';
this.inputCtx.fillRect(0, 0, this.width, this.height);
const centerX = this.width / 2;
const centerY = this.height / 2;
const baseRadius = Math.min(this.width, this.height) * 0.15;
const numPoints = 6 + Math.floor(Math.random() * 3);
const points = [];
// Generate base points with smoother distribution
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * Math.PI * 2;
// Smoother radius variation
const radiusVariation = baseRadius * (0.8 + Math.random() * 0.4);
const x = centerX + Math.cos(angle) * radiusVariation;
const y = centerY + Math.sin(angle) * radiusVariation;
points.push({ x, y });
}
// Draw the mask with enhanced curves
this.inputCtx.fillStyle = 'black';
this.inputCtx.beginPath();
this.inputCtx.moveTo(points[0].x, points[0].y);
const curvePoints = [];
const numCurvePoints = 240; // Increased for smoother sampling
// Draw and sample points along curves between vertices
for (let i = 0; i < points.length; i++) {
const current = points[i];
const next = points[(i + 1) % points.length];
// Enhanced bulge calculation
const midX = (current.x + next.x) / 2;
const midY = (current.y + next.y) / 2;
const bulgeX = midX - centerX;
const bulgeY = midY - centerY;
const bulgeLength = Math.sqrt(bulgeX * bulgeX + bulgeY * bulgeY);
const segmentLength = Math.sqrt(
Math.pow(next.x - current.x, 2) +
Math.pow(next.y - current.y, 2)
);
// Scale bulge factor based on segment length
const bulgeFactor = baseRadius * (0.2 + Math.random() * 0.2) *
(segmentLength / baseRadius) * 0.5;
const controlX = midX + (bulgeX / bulgeLength) * bulgeFactor;
const controlY = midY + (bulgeY / bulgeLength) * bulgeFactor;
// Sample points with adaptive density
const pointsPerSegment = Math.ceil((numCurvePoints / points.length) *
(segmentLength / (baseRadius * 2)));
for (let t = 0; t < 1; t += 1/pointsPerSegment) {
const t1 = 1 - t;
const x = t1 * t1 * current.x + 2 * t1 * t * controlX + t * t * next.x;
const y = t1 * t1 * current.y + 2 * t1 * t * controlY + t * t * next.y;
curvePoints.push({ x, y });
}
this.inputCtx.quadraticCurveTo(controlX, controlY, next.x, next.y);
}
this.inputCtx.closePath();
this.inputCtx.fill();
// Store mask info with evenly distributed points
this.maskInfo = {
center: { x: centerX, y: centerY },
edgePoints: this.redistributePoints(curvePoints, 100)
};
return this.maskInfo;
}
/**
* @method redistributePoints
* @description Redistributes points evenly along a path defined by a series of points
* @param {Point[]} points - Original array of points defining the path
* @param {number} targetCount - Desired number of evenly spaced points
* @returns {Point[]} Array of evenly distributed points
* @private
*/
redistributePoints(points, targetCount) {
// Calculate total perimeter length
let totalLength = 0;
const segments = [];
for (let i = 0; i < points.length; i++) {
const next = points[(i + 1) % points.length];
const length = Math.sqrt(
Math.pow(next.x - points[i].x, 2) +
Math.pow(next.y - points[i].y, 2)
);
totalLength += length;
segments.push({ start: points[i], end: next, length });
}
// Redistribute points evenly
const spacing = totalLength / targetCount;
const newPoints = [];
let currentDist = 0;
let currentSegment = 0;
let segmentPos = 0;
for (let i = 0; i < targetCount; i++) {
while (currentDist >= segments[currentSegment].length) {
currentDist -= segments[currentSegment].length;
currentSegment = (currentSegment + 1) % segments.length;
}
const seg = segments[currentSegment];
const t = currentDist / seg.length;
newPoints.push({
x: seg.start.x + (seg.end.x - seg.start.x) * t,
y: seg.start.y + (seg.end.y - seg.start.y) * t
});
currentDist += spacing;
}
return newPoints;
}
/**
* @private
* @method drawRadiatingLine
* @description Draws a single radiating line from a point on the mask's edge
* @param {Object} point - The starting point on the mask's edge
* @param {number} lineLength - The length of the line to draw
*/
drawRadiatingLine(point, lineLength) {
const dx = point.x - this.maskInfo.center.x;
const dy = point.y - this.maskInfo.center.y;
const length = Math.sqrt(dx * dx + dy * dy);
const normalizedDx = dx / length;
const normalizedDy = dy / length;
this.outputCtx.beginPath();
this.outputCtx.moveTo(point.x, point.y);
this.outputCtx.lineTo(
point.x + normalizedDx * lineLength,
point.y + normalizedDy * lineLength
);
this.outputCtx.stroke();
}
/**
* @private
* @method calculateBaseLineWidth
* @description Calculates the base line width based on the contour circumference
* @returns {number} Calculated base line width
*/
calculateBaseLineWidth() {
if (!this.maskInfo || !this.maskInfo.edgePoints) {
return 10; // Default fallback value
}
let circumference = 0;
const points = this.maskInfo.edgePoints;
for (let i = 0; i < points.length; i++) {
const current = points[i];
const next = points[(i + 1) % points.length];
const distance = Math.sqrt(
Math.pow(next.x - current.x, 2) +
Math.pow(next.y - current.y, 2)
);
circumference += distance;
}
return Math.max(1, circumference * this.lineWidthRatio);
}
/**
* @method generateFlameRayEffect
* @description Generates data for a flame-like effect radiating from an edge point
* @param {Point} point - Starting point on the mask's edge
* @param {Point} nextPoint - Next point on the mask's edge
* @param {number} lineLength - Length of the flame effect
* @returns {RadiatingEffect} Generated flame effect data
* @private
*/
generateFlameRayEffect(point, nextPoint, lineLength) {
const amplitudeInitialMod = 0.2;
const amplitudeDecayMod = 0.8;
const baseLineWidth = this.calculateBaseLineWidth();
// Calculate direction vectors for both current and next points
const dx1 = point.x - this.maskInfo.center.x;
const dy1 = point.y - this.maskInfo.center.y;
const length1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
const normalizedDx1 = dx1 / length1;
const normalizedDy1 = dy1 / length1;
const dx2 = nextPoint.x - this.maskInfo.center.x;
const dy2 = nextPoint.y - this.maskInfo.center.y;
const length2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
const normalizedDx2 = dx2 / length2;
const normalizedDy2 = dy2 / length2;
// Calculate midpoint between current and next flame
const midPoint = {
x: (point.x + nextPoint.x) / 2,
y: (point.y + nextPoint.y) / 2
};
const numWaves = Math.max(5, lineLength / 5);
const initialAmplitude = lineLength * amplitudeInitialMod;
const minLineWidth = 1;
const segments = [];
const decorativeElements = [];
// Generate path segments for the main flame rays
for (let i = 0; i < numWaves; i++) {
const t = i / numWaves;
const nextT = (i + 1) / numWaves;
// Calculate current wave points for both flames
const waveX = point.x + normalizedDx1 * lineLength * t;
const waveY = point.y + normalizedDy1 * lineLength * t;
const nextWaveX = point.x + normalizedDx1 * lineLength * nextT;
const nextWaveY = point.y + normalizedDy1 * lineLength * nextT;
// Calculate wave amplitudes and offsets
const currentAmplitude = initialAmplitude * (1 - t * amplitudeDecayMod);
const offsetX = -normalizedDy1 * Math.sin(t * Math.PI * 2) * currentAmplitude;
const offsetY = normalizedDx1 * Math.sin(t * Math.PI * 2) * currentAmplitude;
const nextAmplitude = initialAmplitude * (1 - nextT * amplitudeDecayMod);
const nextOffsetX = -normalizedDy1 * Math.sin(nextT * Math.PI * 2) * nextAmplitude;
const nextOffsetY = normalizedDx1 * Math.sin(nextT * Math.PI * 2) * nextAmplitude;
// Interpolate between the two direction vectors for virtual flame
const interpolationFactor = 0.5; // Can be adjusted for different effects
const virtualDx = normalizedDx1 * (1 - interpolationFactor) + normalizedDx2 * interpolationFactor;
const virtualDy = normalizedDy1 * (1 - interpolationFactor) + normalizedDy2 * interpolationFactor;
// Normalize the interpolated direction
const virtualLength = Math.sqrt(virtualDx * virtualDx + virtualDy * virtualDy);
const normalizedVirtualDx = virtualDx / virtualLength;
const normalizedVirtualDy = virtualDy / virtualLength;
// Calculate virtual flame point with interpolated direction and sinusoidal pattern
const virtualFlameX = midPoint.x + normalizedVirtualDx * lineLength * t;
const virtualFlameY = midPoint.y + normalizedVirtualDy * lineLength * t;
// Add sinusoidal pattern to virtual flame position
const virtualAmplitude = currentAmplitude;
const virtualOffsetX = -normalizedVirtualDy * Math.sin(t * Math.PI * 2) * virtualAmplitude;
const virtualOffsetY = normalizedVirtualDx * Math.sin(t * Math.PI * 2) * virtualAmplitude;
segments.push({
startPoint: {
x: waveX + offsetX,
y: waveY + offsetY
},
endPoint: {
x: nextWaveX + nextOffsetX,
y: nextWaveY + nextOffsetY
},
properties: {
width: baseLineWidth - (baseLineWidth - minLineWidth) * t,
strokeColor: this.strokeColor
}
});
// Add decorative elements along virtual flame path with sinusoidal pattern
const startStep = 2;
if (i >= startStep && i % 3 === 0) {
const progress = (i - startStep) / (numWaves - startStep);
const minRadius = minLineWidth / 2;
const maxRadius = baseLineWidth * 0.6;
const currentRadius = minRadius + (maxRadius - minRadius) * progress;
decorativeElements.push({
position: {
x: virtualFlameX + virtualOffsetX,
y: virtualFlameY + virtualOffsetY
},
type: 'circle',
properties: {
radius: currentRadius,
color: this.elementColor
}
});
}
}
return {
origin: point,
path: {
direction: { dx: normalizedDx1, dy: normalizedDy1 },
length: lineLength,
segments
},
decorativeElements
};
}
/**
* @method renderFlameRayEffect
* @description Renders a flame ray effect including path segments and decorative elements
* @param {RadiatingEffect} effect - The effect data to render
* @private
*/
renderFlameRayEffect(effect) {
// Render main path segments
effect.path.segments.forEach(segment => {
this.outputCtx.beginPath();
this.outputCtx.lineWidth = segment.properties.width;
this.outputCtx.strokeStyle = segment.properties.strokeColor || this.strokeColor;
this.outputCtx.moveTo(segment.startPoint.x, segment.startPoint.y);
this.outputCtx.lineTo(segment.endPoint.x, segment.endPoint.y);
this.outputCtx.stroke();
});
// Render decorative elements
if (effect.decorativeElements) {
effect.decorativeElements.forEach(element => {
if (element.type === 'circle') {
this.outputCtx.fillStyle = element.properties.color || this.elementColor;
this.outputCtx.beginPath();
this.outputCtx.arc(
element.position.x,
element.position.y,
element.properties.radius,
0,
Math.PI * 2
);
this.outputCtx.fill();
}
});
}
}
/**
* @private
* @method setupOutputCanvas
* @description Prepares the output canvas for drawing
*/
setupOutputCanvas() {
this.outputCtx.fillStyle = 'white';
this.outputCtx.fillRect(0, 0, this.width, this.height);
this.outputCtx.drawImage(this.inputCanvas, 0, 0);
this.outputCtx.strokeStyle = 'black';
this.outputCtx.lineCap = 'round';
// Line width will be set per segment in renderFlameRayEffect
}
/**
* @private
* @method copyToDOM
* @description Copies the internal canvases to the DOM canvases
*/
copyToDOM() {
const domInputCtx = this.domInputCanvas.getContext('2d');
const domOutputCtx = this.domOutputCanvas.getContext('2d');
domInputCtx.clearRect(0, 0, this.width, this.height);
domOutputCtx.clearRect(0, 0, this.width, this.height);
domInputCtx.drawImage(this.inputCanvas, 0, 0);
domOutputCtx.drawImage(this.outputCanvas, 0, 0);
}
/**
* @method processMask
* @description Generates decorative effects based on the analyzed mask.
*/
processMask() {
if (!this.maskInfo) {
this.generateRandomMask();
}
this.setupOutputCanvas();
// Debug visualization
if (this.debug) {
this.outputCtx.fillStyle = 'red';
this.maskInfo.edgePoints.forEach(point => {
this.outputCtx.beginPath();
this.outputCtx.arc(point.x, point.y, 2, 0, Math.PI * 2);
this.outputCtx.fill();
});
}
// Select evenly distributed points around the contour
const selectedPoints = this.selectEvenlyDistributedPoints(
this.maskInfo.edgePoints,
this.numLines
);
// Process each selected point
for (let i = 0; i < selectedPoints.length; i++) {
const point = selectedPoints[i];
const nextPoint = selectedPoints[(i + 1) % selectedPoints.length];
const lineLength = Math.sqrt(
Math.pow(point.x - this.maskInfo.center.x, 2) +
Math.pow(point.y - this.maskInfo.center.y, 2)
) * this.lineLengthRatio;
const effect = this.generateFlameRayEffect(point, nextPoint, lineLength);
this.renderFlameRayEffect(effect);
}
this.copyToDOM();
}
/**
* @private
* @method selectEvenlyDistributedPoints
* @description Selects points that are evenly distributed along the contour
* @param {Point[]} points - Array of points along the contour
* @param {number} count - Number of points to select
* @returns {Point[]} Array of selected points
*/
selectEvenlyDistributedPoints(points, count) {
if (points.length <= count) return points;
// Calculate the total contour length
let totalLength = 0;
const distances = [];
for (let i = 0; i < points.length; i++) {
const current = points[i];
const next = points[(i + 1) % points.length];
const distance = Math.sqrt(
Math.pow(next.x - current.x, 2) +
Math.pow(next.y - current.y, 2)
);
distances.push(distance);
totalLength += distance;
}
// Calculate the ideal spacing between points
const spacing = totalLength / count;
const selectedPoints = [];
let currentDistance = 0;
let currentIndex = 0;
let accumulatedDistance = 0;
// Select points based on accumulated distance
for (let i = 0; i < count; i++) {
const targetDistance = (i * totalLength) / count;
// Find the point closest to the target distance
while (accumulatedDistance < targetDistance && currentIndex < distances.length) {
accumulatedDistance += distances[currentIndex];
currentIndex++;
}
// Get the actual point
selectedPoints.push(points[currentIndex % points.length]);
}
return selectedPoints;
}
/**
* @method detectMaskFromBitmap
* @description Analyzes bitmap data to extract mask information
* @returns {MaskInfo} Analyzed mask data including center and edge points
*/
detectMaskFromBitmap() {
const imageData = this.inputCtx.getImageData(0, 0, this.width, this.height);
const pixels = imageData.data;
// Find mask boundaries and center
const center = this.calculateMaskCentroid(pixels);
const edges = this.detectEdgePoints(pixels, center);
// Store and return mask info
this.maskInfo = {
center: center,
edgePoints: this.redistributePoints(edges, 100) // Maintain consistent point count
};
return this.maskInfo;
}
/**
* @private
* @method calculateMaskCentroid
* @description Calculates the centroid (center of mass) of the mask
* @param {Uint8ClampedArray} pixels - Canvas pixel data
* @returns {Object} Center point {x, y}
*/
calculateMaskCentroid(pixels) {
let sumX = 0, sumY = 0, total = 0;
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const idx = (y * this.width + x) * 4;
// Check if pixel is black (part of mask)
if (pixels[idx] === 0) {
sumX += x;
sumY += y;
total++;
}
}
}
return {
x: sumX / total,
y: sumY / total
};
}
/**
* @private
* @method detectEdgePoints
* @description Detects edge points of the mask using a simple edge detection algorithm
* @param {Uint8ClampedArray} pixels - Canvas pixel data
* @returns {Array<Object>} Array of edge points
*/
detectEdgePoints(pixels, center) {
const edges = [];
// Scan from each direction to find edges
// Top to bottom
for (let x = 0; x < this.width; x++) {
let lastPixel = 255; // Start with white
for (let y = 0; y < this.height; y++) {
const idx = (y * this.width + x) * 4;
const currentPixel = pixels[idx];
if (lastPixel !== currentPixel) {
edges.push({ x, y });
}
lastPixel = currentPixel;
}
}
// Left to right
for (let y = 0; y < this.height; y++) {
let lastPixel = 255; // Start with white
for (let x = 0; x < this.width; x++) {
const idx = (y * this.width + x) * 4;
const currentPixel = pixels[idx];
if (lastPixel !== currentPixel) {
edges.push({ x, y });
}
lastPixel = currentPixel;
}
}
// Remove duplicates
const uniqueEdges = [];
const seen = new Set();
for (const point of edges) {
const key = `${point.x},${point.y}`;
if (!seen.has(key)) {
seen.add(key);
uniqueEdges.push(point);
}
}
// Sort points clockwise around the center
uniqueEdges.sort((a, b) => {
const angleA = Math.atan2(a.y - center.y, a.x - center.x);
const angleB = Math.atan2(b.y - center.y, b.x - center.x);
return angleA - angleB;
});
return uniqueEdges;
}
/**
* @method updateCanvasDimensions
* @description Updates the dimensions of both internal and DOM canvases
* @param {number} width - New width for the canvases
* @param {number} height - New height for the canvases
*/
updateCanvasDimensions(width, height) {
this.width = width;
this.height = height;
// Update internal canvases
this.inputCanvas.width = width;
this.inputCanvas.height = height;
this.outputCanvas.width = width;
this.outputCanvas.height = height;
// Update DOM canvases
this.domInputCanvas.width = width;
this.domInputCanvas.height = height;
this.domOutputCanvas.width = width;
this.domOutputCanvas.height = height;
// Update display styles
this.domInputCanvas.style.width = `${width}px`;
this.domInputCanvas.style.height = `${height}px`;
this.domOutputCanvas.style.width = `${width}px`;
this.domOutputCanvas.style.height = `${height}px`;
}
/**
* @method loadMask
* @description Primary entry point for processing an existing mask image.
* Handles the complete workflow of loading, detecting edges, and generating effects.
* @param {HTMLImageElement} img - The mask image to process
*/
loadMask(img) {
// Update canvas dimensions
this.updateCanvasDimensions(img.width, img.height);
// Clear the input canvas
this.inputCtx.clearRect(0, 0, this.width, this.height);
// Draw the image onto the input canvas
this.inputCtx.drawImage(img, 0, 0, img.width, img.height);
// Detect the mask from the bitmap
this.detectMaskFromBitmap();
// Process the mask
this.processMask();
}
// /**
// * @private
// * @method generateOverlayData
// * @description Generates complete overlay effect data structure
// * @returns {OverlayEffect} Generated overlay effect data
// * @private
// */
// generateOverlayData() {
// if (!this.maskInfo) {
// this.generateRandomMask();
// }
// const totalEdgePoints = this.maskInfo.edgePoints.length;
// const step = totalEdgePoints >= this.numLines ?
// Math.floor(totalEdgePoints / this.numLines) : 1;
// const radiatingEffects = [];
// // Generate data for each radiating effect
// for (let i = 0; i < totalEdgePoints; i += step) {
// const point = this.maskInfo.edgePoints[i];
// const dx = point.x - this.maskInfo.center.x;
// const dy = point.y - this.maskInfo.center.y;
// const length = Math.sqrt(dx * dx + dy * dy) * this.lineLengthRatio;
// radiatingEffects.push({
// origin: point,
// path: {
// direction: {
// dx: dx / length,
// dy: dy / length
// },
// length: length,
// segments: [] // Would be populated based on effect type
// }
// });
// }
// return {
// baseShape: {
// center: this.maskInfo.center,
// boundaryPoints: this.maskInfo.edgePoints,
// bounds: {
// width: this.width,
// height: this.height
// }
// },
// radiatingEffects
// };
// }
// /**
// * @method setColors
// * @description Sets the base colors for strokes and decorative elements
// * @param {string} strokeColor - Color for line strokes
// * @param {string} elementColor - Color for decorative elements
// */
// setColors(strokeColor, elementColor) {
// this.strokeColor = strokeColor;
// this.elementColor = elementColor;
// }
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = MaskProcessor;
}