Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,25 @@ detect_barcodes(image_path: "shipping_label.pdf")
swift build -c release
```

2. Extract manifest (to verify):
2. Test:
```bash
swift test
```
The tests generate their image and PDF fixtures at runtime; `Tests/Resources`
is kept in the repository so SwiftPM can copy the declared resource folder.

3. Extract manifest (to verify):
```bash
osaurus manifest extract .build/release/libosaurus-vision.dylib
```

3. Package (for distribution):
4. Package (for distribution):
```bash
osaurus tools package osaurus.vision 0.1.0
```
This creates `osaurus.vision-0.1.0.zip`.

4. Install locally:
5. Install locally:
```bash
osaurus tools install ./osaurus.vision-0.1.0.zip
```
Expand Down
43 changes: 32 additions & 11 deletions Sources/osaurus_vision/Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,19 @@ private enum VisionHelper {
return pdfDocument.pageCount
}

static func saveCIImage(_ image: CIImage, to path: String, context: FolderContext?) throws {
static func saveCIImage(
_ image: CIImage, to path: String, context: FolderContext?, overwrite: Bool = false
) throws {
let absolutePath = resolvePath(path, context: context)
guard validatePath(absolutePath, context: context) else {
throw VisionError.invalidPath("Path outside working directory")
}

let url = URL(fileURLWithPath: absolutePath)
if FileManager.default.fileExists(atPath: url.path) && !overwrite {
throw VisionError.saveFailed(
"Output file already exists: \(absolutePath). Pass overwrite=true to replace it.")
}
let ciContext = CIContext()

// Create parent directory if needed
Expand Down Expand Up @@ -646,6 +652,7 @@ private struct BlurFacesTool: VisionTool {
let image_path: String
let output_path: String
let blur_radius: Double?
let overwrite: Bool?
let _context: FolderContext?
}

Expand All @@ -659,7 +666,8 @@ private struct BlurFacesTool: VisionTool {

let faces = request.results ?? []
guard !faces.isEmpty else {
try VisionHelper.saveCIImage(ciImage, to: input.output_path, context: input._context)
try VisionHelper.saveCIImage(
ciImage, to: input.output_path, context: input._context, overwrite: input.overwrite ?? false)
return [
"output_path": VisionHelper.resolvePath(input.output_path, context: input._context),
"faces_blurred": 0,
Expand Down Expand Up @@ -698,7 +706,8 @@ private struct BlurFacesTool: VisionTool {
}
}

try VisionHelper.saveCIImage(ciImage, to: input.output_path, context: input._context)
try VisionHelper.saveCIImage(
ciImage, to: input.output_path, context: input._context, overwrite: input.overwrite ?? false)
return [
"output_path": VisionHelper.resolvePath(input.output_path, context: input._context),
"faces_blurred": faces.count,
Expand All @@ -714,6 +723,7 @@ private struct AutoCropTool: VisionTool {
let output_path: String
let aspect_ratio: String?
let padding: Double?
let overwrite: Bool?
let _context: FolderContext?
}

Expand All @@ -728,7 +738,8 @@ private struct AutoCropTool: VisionTool {
guard let obs = request.results?.first, let salientObjects = obs.salientObjects,
!salientObjects.isEmpty
else {
try VisionHelper.saveCIImage(ciImage, to: input.output_path, context: input._context)
try VisionHelper.saveCIImage(
ciImage, to: input.output_path, context: input._context, overwrite: input.overwrite ?? false)
return [
"output_path": VisionHelper.resolvePath(input.output_path, context: input._context),
"cropped": false,
Expand Down Expand Up @@ -766,7 +777,8 @@ private struct AutoCropTool: VisionTool {
let croppedImage = ciImage.cropped(to: cropRect)
.transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))

try VisionHelper.saveCIImage(croppedImage, to: input.output_path, context: input._context)
try VisionHelper.saveCIImage(
croppedImage, to: input.output_path, context: input._context, overwrite: input.overwrite ?? false)
return [
"output_path": VisionHelper.resolvePath(input.output_path, context: input._context),
"cropped": true,
Expand All @@ -793,6 +805,7 @@ private struct GenerateSaliencyMapTool: VisionTool {
let image_path: String
let output_path: String
let type: String?
let overwrite: Bool?
let _context: FolderContext?
}

Expand Down Expand Up @@ -829,7 +842,9 @@ private struct GenerateSaliencyMapTool: VisionTool {
if let colored = colorFilter.outputImage { saliencyImage = colored }
}

try VisionHelper.saveCIImage(saliencyImage, to: input.output_path, context: input._context)
try VisionHelper.saveCIImage(
saliencyImage, to: input.output_path, context: input._context,
overwrite: input.overwrite ?? false)

let regions = (obs.salientObjects ?? []).map { obj in
[
Expand All @@ -852,6 +867,7 @@ private struct RemoveBackgroundTool: VisionTool {
struct Args: Decodable {
let image_path: String
let output_path: String
let overwrite: Bool?
let _context: FolderContext?
}

Expand Down Expand Up @@ -891,7 +907,8 @@ private struct RemoveBackgroundTool: VisionTool {
if !outputPath.hasSuffix(".png") { outputPath += ".png" }
}

try VisionHelper.saveCIImage(outputImage, to: outputPath, context: input._context)
try VisionHelper.saveCIImage(
outputImage, to: outputPath, context: input._context, overwrite: input.overwrite ?? false)
return [
"output_path": VisionHelper.resolvePath(outputPath, context: input._context),
"instances_detected": obs.allInstances.count,
Expand Down Expand Up @@ -1176,7 +1193,8 @@ private let manifest = """
"properties": {
"image_path": {"type": "string", "description": "Path to the input image"},
"output_path": {"type": "string", "description": "Path to save the output image"},
"blur_radius": {"type": "number", "description": "Blur intensity. Default: 30"}
"blur_radius": {"type": "number", "description": "Blur intensity. Default: 30"},
"overwrite": {"type": "boolean", "description": "Replace an existing output file. Default: false"}
},
"required": ["image_path", "output_path"]
},
Expand All @@ -1192,7 +1210,8 @@ private let manifest = """
"image_path": {"type": "string", "description": "Path to the input image"},
"output_path": {"type": "string", "description": "Path to save the cropped image"},
"aspect_ratio": {"type": "string", "description": "Target aspect ratio (e.g., '16:9', '1:1')"},
"padding": {"type": "number", "description": "Padding around salient region (0-1). Default: 0.1"}
"padding": {"type": "number", "description": "Padding around salient region (0-1). Default: 0.1"},
"overwrite": {"type": "boolean", "description": "Replace an existing output file. Default: false"}
},
"required": ["image_path", "output_path"]
},
Expand All @@ -1207,7 +1226,8 @@ private let manifest = """
"properties": {
"image_path": {"type": "string", "description": "Path to the input image"},
"output_path": {"type": "string", "description": "Path to save the saliency map"},
"type": {"type": "string", "enum": ["attention", "objectness"], "description": "Saliency type. Default: attention"}
"type": {"type": "string", "enum": ["attention", "objectness"], "description": "Saliency type. Default: attention"},
"overwrite": {"type": "boolean", "description": "Replace an existing output file. Default: false"}
},
"required": ["image_path", "output_path"]
},
Expand All @@ -1221,7 +1241,8 @@ private let manifest = """
"type": "object",
"properties": {
"image_path": {"type": "string", "description": "Path to the input image"},
"output_path": {"type": "string", "description": "Path to save output (saved as PNG)"}
"output_path": {"type": "string", "description": "Path to save output (saved as PNG)"},
"overwrite": {"type": "boolean", "description": "Replace an existing output file. Default: false"}
},
"required": ["image_path", "output_path"]
},
Expand Down
1 change: 1 addition & 0 deletions Tests/Resources/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# SwiftPM copies this declared test resource directory during `swift test`.
48 changes: 48 additions & 0 deletions Tests/VisionToolsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,26 @@ struct VisionPluginTests {
#expect(toolIds.contains("detect_faces"))
#expect(toolIds.contains("remove_background"))
}

@Test("Image-writing tools expose explicit output and overwrite controls")
func testOutputSafetyManifest() throws {
let invoker = PluginInvoker.shared
let manifest = invoker.getManifest()
let capabilities = manifest["capabilities"] as! [String: Any]
let tools = capabilities["tools"] as! [[String: Any]]
let byId = Dictionary(uniqueKeysWithValues: tools.map { ($0["id"] as! String, $0) })

for id in ["blur_faces", "auto_crop", "generate_saliency_map", "remove_background"] {
let tool = byId[id]!
#expect(tool["permission_policy"] as? String == "ask")

let params = tool["parameters"] as! [String: Any]
let properties = params["properties"] as! [String: Any]
let required = Set(params["required"] as? [String] ?? [])
#expect(required == ["image_path", "output_path"], "Tool \(id) should require explicit paths")
#expect(properties["overwrite"] != nil, "Tool \(id) should expose overwrite")
}
}
}

@Suite("Text Detection Tests", .serialized)
Expand Down Expand Up @@ -869,6 +889,7 @@ struct BlurFacesTests {
args: [
"image_path": imageUrl.path,
"output_path": outputUrl.path,
"overwrite": true,
])

#expect(result["error"] == nil)
Expand All @@ -892,10 +913,31 @@ struct BlurFacesTests {
"image_path": imageUrl.path,
"output_path": outputUrl.path,
"blur_radius": 50,
"overwrite": true,
])

#expect(result["error"] == nil)
}

@Test("Blur faces refuses to overwrite existing output by default")
func testBlurFacesOverwriteSafety() throws {
try TestImageGenerator.setup()
defer { TestImageGenerator.cleanup() }

let imageUrl = try TestImageGenerator.createFaceImage()
let outputUrl = TestImageGenerator.tempDir.appendingPathComponent(
"blurred_existing_\(UUID().uuidString).jpg")
try Data("existing".utf8).write(to: outputUrl)

let result = PluginInvoker.shared.invoke(
tool: "blur_faces",
args: [
"image_path": imageUrl.path,
"output_path": outputUrl.path,
])

#expect((result["error"] as? String)?.contains("already exists") == true)
}
}

@Suite("Auto Crop Tests", .serialized)
Expand All @@ -915,6 +957,7 @@ struct AutoCropTests {
args: [
"image_path": imageUrl.path,
"output_path": outputUrl.path,
"overwrite": true,
])

#expect(result["error"] == nil)
Expand All @@ -939,6 +982,7 @@ struct AutoCropTests {
"output_path": outputUrl.path,
"aspect_ratio": "16:9",
"padding": 0.2,
"overwrite": true,
])

#expect(result["error"] == nil)
Expand All @@ -963,6 +1007,7 @@ struct SaliencyMapTests {
"image_path": imageUrl.path,
"output_path": outputUrl.path,
"type": "attention",
"overwrite": true,
])

#expect(result["error"] == nil)
Expand All @@ -989,6 +1034,7 @@ struct SaliencyMapTests {
"image_path": imageUrl.path,
"output_path": outputUrl.path,
"type": "objectness",
"overwrite": true,
])

#expect(result["error"] == nil)
Expand All @@ -1013,6 +1059,7 @@ struct RemoveBackgroundTests {
args: [
"image_path": imageUrl.path,
"output_path": outputUrl.path,
"overwrite": true,
])

// Note: VNGenerateForegroundInstanceMaskRequest may not detect foreground in synthetic images
Expand All @@ -1039,6 +1086,7 @@ struct RemoveBackgroundTests {
args: [
"image_path": imageUrl.path,
"output_path": outputUrl.path,
"overwrite": true,
])

// Should convert to PNG for transparency
Expand Down