commit 2f43e5a6c7ee88ea1ce44955a3c97f2e34b6646d Author: Patrick Morgan Date: Sun Dec 15 20:23:15 2024 -0800 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d56e2d6 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68292a9 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..05d6653 --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a9d4d19 --- /dev/null +++ b/__init__.py @@ -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") \ No newline at end of file diff --git a/nodes/nodes.py b/nodes/nodes.py new file mode 100644 index 0000000..ab930b4 --- /dev/null +++ b/nodes/nodes.py @@ -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" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce3d10e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +numpy>=1.20.0 +torch>=1.7.0 +scikit-image \ No newline at end of file diff --git a/tags b/tags new file mode 100644 index 0000000..0c48043 --- /dev/null +++ b/tags @@ -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 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..37c4ac9 --- /dev/null +++ b/web/index.html @@ -0,0 +1,177 @@ + + + + + + Mask Contour Processor v0.5 + + + + +
+ +
+ + + +
+ + + 1 +
+ + +
+ + + 10 +
+ + +
+ + + 0.010 +
+
+ + +
+
+ +
+
+ + + +

Drop image here

+
+
+
+
+ + +
+
+
+ + + + + + \ No newline at end of file diff --git a/web/js/index.js b/web/js/index.js new file mode 100644 index 0000000..6708ec7 --- /dev/null +++ b/web/js/index.js @@ -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(); +} \ No newline at end of file diff --git a/web/js/maskProcessor.js b/web/js/maskProcessor.js new file mode 100644 index 0000000..c913132 --- /dev/null +++ b/web/js/maskProcessor.js @@ -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} 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; +} +