diff --git a/README.md b/README.md index 0f11857..f162c13 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ https://en.wikipedia.org/wiki/Seam_carving +## Feature wishlist + +* Drag & drop custom images +* Make it run well in debug mode +* Incremental/partial updating of energy field +* Multi-threaded carving +* Seam insertion/generation +* Marking areas to preserve or remove +* Realtime (a.k.a. 60fps) seam carving +* Fix web build + ## Building & run Windows & linux @@ -9,7 +20,7 @@ Windows & linux zig build run ``` -Web +Web (currently broken) ```shell emsdk install latest zig build -Dtarget=wasm32-emscripten --sysroot [path to emsdk]/upstream/emscripten run diff --git a/build.zig b/build.zig index b9bb932..480fb83 100644 --- a/build.zig +++ b/build.zig @@ -106,6 +106,9 @@ pub fn build(b: *std.Build) !void { const raylib = raylib_dep.module("raylib"); const raylib_artifact = raylib_dep.artifact("raylib"); + raylib_artifact.defineCMacro("SUPPORT_FILEFORMAT_JPG", null); + + const run_label = try std.fmt.allocPrint(b.allocator, "Run {s}", .{project_name}); //web exports are completely separate if (target.query.os_tag == .emscripten) { @@ -121,7 +124,6 @@ pub fn build(b: *std.Build) !void { const run_step = try rlz.emcc.emscriptenRunStep(b); run_step.step.dependOn(&link_step.step); - const run_label = try std.fmt.allocPrint(b.allocator, "Run {s}", .{project_name}); const run_option = b.step("run", run_label); run_option.dependOn(&run_step.step); return; @@ -133,7 +135,6 @@ pub fn build(b: *std.Build) !void { exe.root_module.addImport("raylib", raylib); const run_cmd = b.addRunArtifact(exe); - const run_label = try std.fmt.allocPrint(b.allocator, "Run {s}", .{project_name}); const run_step = b.step("run", run_label); run_step.dependOn(&run_cmd.step); diff --git a/build.zig.zon b/build.zig.zon index 60d7ddb..47e42a5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -15,10 +15,10 @@ // Once all dependencies are fetched, `zig build` no longer requires // internet connectivity. .dependencies = .{ - .@"raylib-zig" = .{ + .@"raylib-zig" = .{ .url = "https://github.com/Not-Nik/raylib-zig/archive/43d15b05c2b97cab30103fa2b46cff26e91619ec.tar.gz", .hash = "12204a223b19043e17b79300413d02f60fc8004c0d9629b8d8072831e352a78bf212", - }, + } }, // Specifies the set of files and directories that are included in this package. diff --git a/src/assets/example.jpg b/src/assets/example.jpg new file mode 100644 index 0000000..540125f Binary files /dev/null and b/src/assets/example.jpg differ diff --git a/src/main.zig b/src/main.zig index efef316..a7a1951 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,8 @@ const std = @import("std"); const rl = @import("raylib"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const SeamCarver = @import("./seam-carver.zig"); fn toTraceLogLevel(log_level: std.log.Level) rl.TraceLogLevel { return switch (log_level) { @@ -10,23 +13,241 @@ fn toTraceLogLevel(log_level: std.log.Level) rl.TraceLogLevel { }; } +fn convertF32ImageToU8(allocator: Allocator, image: []f32, width: usize, height: usize) ![]u8 { + const image_u8 = try allocator.alloc(u8, width * height); + errdefer allocator.free(image_u8); + + var min_value: f32 = image[0]; + var max_value: f32 = image[0]; + for (image[1..]) |value| { + min_value = @min(min_value, value); + max_value = @max(max_value, value); + } + + for (0.., image) |i, value| { + const value_01 = (value - min_value) / (max_value - min_value); + image_u8[i] = @intFromFloat(value_01 * 255); + } + + return image_u8; +} + +fn insetRect(rect: rl.Rectangle, margin: f32) rl.Rectangle{ + return rl.Rectangle.init(rect.x + margin, rect.y + margin, rect.width - 2*margin, rect.height - 2*margin); +} + +fn insideRect(container: rl.Rectangle, child_size: rl.Vector2) rl.Rectangle { + const scale_x = container.width / child_size.x; + const scale_y = container.height / child_size.y; + + const min_scale = @min(scale_x, scale_y); + return centerRect(container, child_size.scale(min_scale)); +} + +fn centerRect(container: rl.Rectangle, child_size: rl.Vector2) rl.Rectangle { + return rl.Rectangle{ + .x = container.x + (container.width - child_size.x)/2, + .y = container.y + (container.height - child_size.y)/2, + .width = child_size.x, + .height = child_size.y + }; +} + +fn centerOfRect(rect: rl.Rectangle) rl.Vector2 { + return rl.Vector2.init(rect.x + rect.width/2, rect.y + rect.height/2); +} + +fn distanceToLine(lineA: rl.Vector2, lineB: rl.Vector2, point: rl.Vector2) f32 { + const ab = lineB.subtract(lineA); + const ap = point.subtract(lineA); + const proj = ap.dotProduct(ab); + const d = proj / ab.lengthSqr(); + + if (d <= 0) { + return point.distance(lineA); + } else if (d >= 1) { + return point.distance(lineB); + } else { + const pointOnLine = lineA.add(ab.scale(d)); + return point.distance(pointOnLine); + } +} + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; const allocator = gpa.allocator(); defer _ = gpa.deinit(); - _ = allocator; rl.setTraceLogLevel(toTraceLogLevel(std.log.default_level)); - rl.initWindow(800, 450, "Seam carving"); + rl.setConfigFlags(.{ .window_resizable = true }); + rl.initWindow(1024, 720, "Seam carving"); defer rl.closeWindow(); rl.setTargetFPS(60); + const source_image = rl.loadImageFromMemory(".jpg", @embedFile("./assets/example.jpg")); + assert(@intFromPtr(source_image.data) != 0); + assert(source_image.format == .pixelformat_uncompressed_r8g8b8); + defer rl.unloadImage(source_image); + + var image = rl.imageCopy(source_image); + defer rl.unloadImage(image); + + const source_texture = rl.loadTextureFromImage(source_image); + defer rl.unloadTexture(source_texture); + + var resized_texture = rl.loadTextureFromImage(image); + defer rl.unloadTexture(resized_texture); + + var seam_carver = SeamCarver.init(allocator, source_image); + defer seam_carver.deinit(); + + var resized_width: u32 = @intCast(image.width); + var resized_height: u32 = @intCast(image.height); + + var resizing_vertically = false; + var resizing_horizontally = false; + while (!rl.windowShouldClose()) { rl.beginDrawing(); defer rl.endDrawing(); - rl.clearBackground(rl.Color.white); + const window_width: f32 = @floatFromInt(rl.getScreenWidth()); + const window_height: f32 = @floatFromInt(rl.getScreenHeight()); + + const margin = @min(window_width, window_height)*0.02; + + const left_side = insetRect(rl.Rectangle.init(0, 0, window_width/2, window_height), margin); + const right_side = insetRect(rl.Rectangle.init(window_width/2, 0, window_width/2, window_height), margin); + + const left_image_rect = insideRect(left_side, .{ .x = @floatFromInt(source_image.width), .y = @floatFromInt(source_image.height) }); + const right_image_rect = insideRect(right_side, .{ .x = @floatFromInt(source_image.width), .y = @floatFromInt(source_image.height) }); + + rl.clearBackground(rl.Color.ray_white); + + const scale = left_image_rect.width / @as(f32, @floatFromInt(source_image.width)); + + if (seam_carver.resized_image) |resized_image| { + rl.unloadImage(image); + image = resized_image; + + rl.unloadTexture(resized_texture); + resized_texture = rl.loadTextureFromImage(image); + + seam_carver.resized_image = null; + } + + rl.drawTextureEx( + source_texture, + .{ .x = left_image_rect.x, .y = left_image_rect.y }, + 0, + scale, + rl.Color.white + ); + + rl.drawRectangleRec( + right_image_rect, + rl.Color.light_gray + ); + + const resized_visual_size = rl.Vector2{ + .x = scale * @as(f32, @floatFromInt(resized_width)), + .y = scale * @as(f32, @floatFromInt(resized_height)), + }; + const resized_rect = centerRect(right_image_rect, resized_visual_size); + + if (seam_carver.work_thread == null) { + var pos = rl.Vector2{ .x = resized_rect.x, .y = resized_rect.y }; + if (resizing_horizontally or resizing_vertically) { + pos = rl.Vector2{ .x = right_image_rect.x, .y = right_image_rect.y }; + } + + const half_image_size = rl.Vector2.init( + @as(f32, @floatFromInt(image.width))/2, + @as(f32, @floatFromInt(image.height))/2 + ); + rl.drawTextureEx( + resized_texture, + centerOfRect(right_image_rect).subtract(half_image_size.scale(scale)), + 0, + scale, + rl.Color.white + ); + } else { + const label: [:0]u8 = try std.fmt.allocPrintZ(allocator, "Resizing {d:.2}%", .{seam_carver.resizing_progress * 100}); + defer allocator.free(label); + + rl.drawTextEx( + rl.getFontDefault(), + label, + centerOfRect(right_image_rect), + 30, + 3, + rl.Color.black + ); + } + + rl.drawRectangleLinesEx( + resized_rect, + 3, + rl.Color.black + ); + + if (seam_carver.work_thread == null) { + const center = centerOfRect(resized_rect); + + const top_left = rl.Vector2.init(resized_rect.x, resized_rect.y); + const top_right = rl.Vector2.init(resized_rect.x + resized_rect.width, resized_rect.y); + const bottom_left = rl.Vector2.init(resized_rect.x, resized_rect.y + resized_rect.height); + const bottom_right = rl.Vector2.init(resized_rect.x + resized_rect.width, resized_rect.y + resized_rect.height); + + const grab_distance = 10; + const mouse = rl.getMousePosition(); + + if (rl.isMouseButtonDown(.mouse_button_left) and resizing_vertically) { + resized_height = @intFromFloat(@abs(center.y - mouse.y)/scale*2); + resized_height = @min(resized_height, @as(u32, @intCast(source_image.height))); + } + if (rl.isMouseButtonDown(.mouse_button_left) and resizing_horizontally) { + resized_width = @intFromFloat(@abs(center.x - mouse.x)/scale*2); + resized_width = @min(resized_width, @as(u32, @intCast(source_image.width))); + } + + if (distanceToLine(top_left, top_right, mouse) < grab_distance) { + rl.drawLineEx(top_right, top_left, 3, rl.Color.red); + if (rl.isMouseButtonPressed(.mouse_button_left)) { + resizing_vertically = true; + } + + } else if (distanceToLine(bottom_left, bottom_right, mouse) < grab_distance) { + rl.drawLineEx(bottom_right, bottom_left, 3, rl.Color.red); + if (rl.isMouseButtonPressed(.mouse_button_left)) { + resizing_vertically = true; + } + } + + if (distanceToLine(top_left, bottom_left, mouse) < grab_distance) { + rl.drawLineEx(top_left, bottom_left, 3, rl.Color.red); + if (rl.isMouseButtonPressed(.mouse_button_left)) { + resizing_horizontally = true; + } + + } else if (distanceToLine(bottom_right, top_right, mouse) < grab_distance) { + rl.drawLineEx(bottom_right, top_right, 3, rl.Color.red); + if (rl.isMouseButtonPressed(.mouse_button_left)) { + resizing_horizontally = true; + } + } + + if (rl.isMouseButtonReleased(.mouse_button_left) and (resizing_horizontally or resizing_vertically)) { + try seam_carver.startResizing(resized_width, resized_height); + } + } + + if (rl.isMouseButtonReleased(.mouse_button_left)) { + resizing_vertically = false; + resizing_horizontally = false; + } } } diff --git a/src/seam-carver.zig b/src/seam-carver.zig new file mode 100644 index 0000000..48c629f --- /dev/null +++ b/src/seam-carver.zig @@ -0,0 +1,354 @@ +const Allocator = std.mem.Allocator; +const rl = @import("raylib"); +const std = @import("std"); +const assert = std.debug.assert; + +const SeamCarver = @This(); + +const seam_window_size = 3; +comptime { + assert(seam_window_size % 2 == 1); +} + +allocator: Allocator, +source_image: rl.Image, + +work_thread: ?std.Thread = null, +resized_image: ?rl.Image = null, + +resizing_progress: f32 = 0, + +pub fn init(allocator: Allocator, source_image: rl.Image) SeamCarver { + return SeamCarver{ + .source_image = source_image, + .allocator = allocator + }; +} + +pub fn startResizing(self: *SeamCarver, new_width: u32, new_height: u32) !void { + if (self.work_thread != null) { + return; + } + + const thread = try std.Thread.spawn(.{ .allocator = self.allocator }, resizeImageThread, .{self, new_width, new_height}); + thread.detach(); // TODO: Because of this, thread might leak memory when closing program. Small issue. + + self.work_thread = thread; +} + +fn rgbToLuminocity(r: u8, g: u8, b: u8) f32 { + return 0.2126*@as(f32, @floatFromInt(r)) + 0.7152*@as(f32, @floatFromInt(g)) + 0.0722*@as(f32, @floatFromInt(b)); +} + +fn applyLuminance(allocator: Allocator, image: rl.Image) ![]f32 { + assert(image.format == .pixelformat_uncompressed_r8g8b8); + + const pixel_count: usize = @intCast(image.width * image.height); + const luminance = try allocator.alloc(f32, pixel_count); + errdefer allocator.free(luminance); + + const image_data: [*]u8 = @ptrCast(image.data); + for (0..pixel_count) |i| { + const r = image_data[i*3 + 0]; + const g = image_data[i*3 + 1]; + const b = image_data[i*3 + 2]; + luminance[i] = rgbToLuminocity(r, g, b); + } + + return luminance; +} + +fn applySobelPixel(image: []f32, width: u32, height: u32, x: u32, y: u32) f32 { + const sobel_center_x = 1; + const sobel_center_y = 1; + const sobel_size_x = 3; + const sobel_size_y = 3; + const sobel_x: []const []const f32 = &[_][]const f32{ + &[_]f32{ -1, 0, 1 }, + &[_]f32{ -2, 0, 2 }, + &[_]f32{ -1, 0, 1 }, + }; + const sobel_y: []const []const f32 = &[_][]const f32{ + &[_]f32{ -1, -2, -1 }, + &[_]f32{ 0, 0, 0 }, + &[_]f32{ 1, 2, 1 }, + }; + + var sum_x: f32 = 0; + var sum_y: f32 = 0; + + for (0..sobel_size_y) |oy| { + for (0..sobel_size_x) |ox| { + var pixel_x: i32 = @as(i32, @intCast(x + ox)) - sobel_center_x; + var pixel_y: i32 = @as(i32, @intCast(y + oy)) - sobel_center_y; + + pixel_x = std.math.clamp(pixel_x, 0, @as(i32, @intCast(width))-1); + pixel_y = std.math.clamp(pixel_y, 0, @as(i32, @intCast(height))-1); + + const pixel_index = @as(usize, @intCast(pixel_y)) * width + @as(usize, @intCast(pixel_x)); + + sum_x += image[pixel_index] * sobel_x[oy][ox]; + sum_y += image[pixel_index] * sobel_y[oy][ox]; + + // sum_x += @as(f32, @floatFromInt(image_data[3*pixel_index + 0])) * sobel_x[oy][ox]; + // sum_x += @as(f32, @floatFromInt(image_data[3*pixel_index + 1])) * sobel_x[oy][ox]; + // sum_x += @as(f32, @floatFromInt(image_data[3*pixel_index + 2])) * sobel_x[oy][ox]; + + // sum_y += @as(f32, @floatFromInt(image_data[3*pixel_index + 0])) * sobel_y[oy][ox]; + // sum_y += @as(f32, @floatFromInt(image_data[3*pixel_index + 1])) * sobel_y[oy][ox]; + // sum_y += @as(f32, @floatFromInt(image_data[3*pixel_index + 2])) * sobel_y[oy][ox]; + } + } + + return @sqrt(sum_x*sum_x + sum_y*sum_y); +} + +fn applySobel(allocator: Allocator, image: []f32, width: u32, height: u32) ![]f32 { + const image_sobel = try allocator.alloc(f32, width * height); + errdefer allocator.free(image_sobel); + + for (0..height) |y| { + for (0..width) |x| { + image_sobel[y * width + x] = applySobelPixel(image, width, height, @intCast(x), @intCast(y)); + } + } + + return image_sobel; +} + +fn calculateEnergy(allocator: Allocator, image: rl.Image) ![]f32 { + const luminance = try applyLuminance(allocator, image); + defer allocator.free(luminance); + + return try applySobel(allocator, luminance, @intCast(image.width), @intCast(image.height)); +} + +const ClampedIterator = struct { + min: i32, + max: i32, + + value: i32, + count: usize, + + fn initCentered(min: i32, max: i32, value: i32, radius: u32) ClampedIterator { + return ClampedIterator{ + .min = min, + .max = max, + .value = value - @as(i32, @intCast(radius / 2)), + .count = radius + }; + } + + fn next(self: *ClampedIterator) ?i32 { + if (self.count == 0) { + return null; + } + + defer { + self.value += 1; + self.count -= 1; + } + return std.math.clamp(self.value, self.min, self.max); + } +}; + +fn removeVerticalSeam(image: *rl.Image, vertical_seam: []u32) void { + const image_width: u32 = @intCast(image.width); + const image_height: u32 = @intCast(image.height); + const image_data: [*]u8 = @ptrCast(image.data); + + assert(image_height == vertical_seam.len); + + var new_index: usize = 0; + for (0..image_height) |y| { + for (0..image_width) |x| { + if (vertical_seam[y] == x) { + continue; + } + + image_data[3*new_index+0] = image_data[3*(y * image_width + x)+0]; + image_data[3*new_index+1] = image_data[3*(y * image_width + x)+1]; + image_data[3*new_index+2] = image_data[3*(y * image_width + x)+2]; + new_index += 1; + } + } + + image.width -= 1; +} + +fn findVerticalSeam(result_buffer: []u32, energy_field: []f32, image_width: u32, image_height: u32) []u32 { + var seam_x: u32 = 0; + for (0..image_width) |x| { + if (energy_field[(image_height-1) * image_height + seam_x] > energy_field[(image_height-1) * image_height + x]) { + seam_x = @intCast(x); + } + } + + var vertical_seam = result_buffer[0..image_height]; + + var seam_y: usize = image_height-1; + while (true) { + vertical_seam[seam_y] = seam_x; + + if (seam_y == 0) break; + seam_y -= 1; + + var x_iter = ClampedIterator.initCentered(0, @as(i32, @intCast(image_width))-1, @intCast(seam_x), seam_window_size); + while (x_iter.next()) |signed_next_x| { + const next_x: usize = @intCast(signed_next_x); + const next_energy = energy_field[seam_y * image_width + next_x]; + const current_energy = energy_field[seam_y * image_width + seam_x]; + if (current_energy > next_energy) { + seam_x = @intCast(next_x); + } + } + } + + return vertical_seam; +} + +fn removeHorizontalSeam(image: *rl.Image, horizontal_seam: []u32) void { + const image_width: u32 = @intCast(image.width); + const image_height: u32 = @intCast(image.height); + const old_image_data: [*]u8 = @ptrCast(image.data); + const new_image_data: [*]u8 = @ptrCast(std.c.malloc(image_width * (image_height-1) * 3) orelse @panic("OOM")); + + assert(image_width == horizontal_seam.len); + + for (0..image_width) |x| { + var new_y: u32 = 0; + for (0..image_height) |y| { + if (horizontal_seam[x] == y) { + continue; + } + + new_image_data[3*(new_y * image_width + x)+0] = old_image_data[3*(y * image_width + x)+0]; + new_image_data[3*(new_y * image_width + x)+1] = old_image_data[3*(y * image_width + x)+1]; + new_image_data[3*(new_y * image_width + x)+2] = old_image_data[3*(y * image_width + x)+2]; + new_y += 1; + } + } + + image.height -= 1; + image.data = new_image_data; + std.c.free(old_image_data); +} + +fn findHorizontalSeam(result_buffer: []u32, energy_field: []f32, image_width: u32, image_height: u32) []u32 { + var seam_y: u32 = 0; + for (0..image_height) |y| { + if (energy_field[seam_y * image_height + (image_width-1)] > energy_field[y * image_height + (image_width-1)]) { + seam_y = @intCast(y); + } + } + + const horizontal_seam = result_buffer[0..image_width]; + @memset(horizontal_seam, image_width/2); + + var seam_x: usize = image_width-1; + while (true) { + horizontal_seam[seam_x] = seam_y; + + if (seam_x == 0) break; + seam_x -= 1; + + var y_iter = ClampedIterator.initCentered(0, @as(i32, @intCast(image_height))-1, @intCast(seam_y), seam_window_size); + while (y_iter.next()) |signed_next_y| { + const next_y: usize = @intCast(signed_next_y); + const next_energy = energy_field[next_y * image_width + seam_x]; + const current_energy = energy_field[seam_y * image_width + seam_x]; + if (current_energy > next_energy) { + seam_y = @intCast(next_y); + } + } + } + + return horizontal_seam; +} + +fn resizeImageThread(self: *SeamCarver, new_width: u32, new_height: u32) !void { + var image = self.source_image.copy(); + errdefer rl.unloadImage(image); + + self.resizing_progress = 0; + + const horizontal_reductions = @as(u32, @intCast(image.width)) - new_width; + const vertical_reductions = @as(u32, @intCast(image.height)) - new_height; + const total_progress = @as(f32, @floatFromInt(horizontal_reductions)) + @as(f32, @floatFromInt(vertical_reductions)); + + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + + const vertical_seam_buffer = try self.allocator.alloc(u32, @intCast(image.height)); + defer self.allocator.free(vertical_seam_buffer); + + for (0..horizontal_reductions) |iter| { + _ = arena.reset(.retain_capacity); + + const image_width: u32 = @intCast(image.width); + const image_height: u32 = @intCast(image.height); + + const energy_field = try calculateEnergy(arena.allocator(), image); + + // Calculate top down energy field + for (1..image_height) |y| { + for (0..image_width) |x| { + var min_top_value: f32 = std.math.floatMax(f32); + + var seam_x_iter = ClampedIterator.initCentered(0, @as(i32, @intCast(image_width))-1, @intCast(x), seam_window_size); + while (seam_x_iter.next()) |seam_x| { + const index = (y - 1) * image_width + @as(usize, @intCast(seam_x)); + min_top_value = @min(min_top_value, energy_field[index]); + } + + energy_field[y * image_width + x] += min_top_value; + } + } + + const vertical_seam = findVerticalSeam(vertical_seam_buffer, energy_field, image_width, image_height); + removeVerticalSeam(&image, vertical_seam); + + self.resizing_progress = @as(f32, @floatFromInt(iter)) / total_progress; + } + + const horizontal_seam_buffer = try self.allocator.alloc(u32, @intCast(image.width)); + defer self.allocator.free(horizontal_seam_buffer); + + for (0..vertical_reductions) |iter| { + _ = arena.reset(.retain_capacity); + + const image_width: u32 = @intCast(image.width); + const image_height: u32 = @intCast(image.height); + + const energy_field = try calculateEnergy(arena.allocator(), image); + + // Calculate left to right energy field + for (1..image_width) |x| { + for (0..image_height) |y| { + var min_left_value: f32 = std.math.floatMax(f32); + + var seam_y_iter = ClampedIterator.initCentered(0, @as(i32, @intCast(image_height))-1, @intCast(y), seam_window_size); + while (seam_y_iter.next()) |seam_y| { + const index = @as(usize, @intCast(seam_y)) * image_width + (x - 1); + min_left_value = @min(min_left_value, energy_field[index]); + } + + energy_field[y * image_width + x] += min_left_value; + } + } + + const horizontal_seam = findHorizontalSeam(horizontal_seam_buffer, energy_field, image_width, image_height); + removeHorizontalSeam(&image, horizontal_seam); + + self.resizing_progress = @as(f32, @floatFromInt(horizontal_reductions + iter)) / total_progress; + } + + self.resized_image = image; + self.work_thread = null; +} + +pub fn deinit(self: SeamCarver) void { + if (self.resized_image) |image| { + rl.unloadImage(image); + } +}