@goodandready/dsh-image-gen 0.11.11 → 0.11.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/lib/anchor-helpers.js +38 -9
- package/lib/character-sheet-helpers.js +115 -0
- package/lib/client.js +86 -13
- package/lib/diagram-helpers.js +105 -0
- package/lib/index.js +4 -1
- package/lib/providers.js +7 -3
- package/lib/register-tools.js +7 -1
- package/lib/security.js +5 -4
- package/lib/settings-route.js +1 -1
- package/lib/tools/anchor.js +2 -2
- package/lib/tools/character-sheet.js +183 -0
- package/lib/tools/diagram.js +168 -0
- package/lib/tools/generation.js +46 -22
- package/lib/tools/style-matrix.js +1 -1
- package/lib/tools/ui-asset.js +1 -1
- package/lib/tools/vision-ocr.js +277 -0
- package/lib/updater.js +1 -1
- package/lib/vision-ocr-helpers.js +119 -0
- package/package.json +2 -2
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// vision-ocr.js — replace_image_text tool (#180).
|
|
2
|
+
// Seamless text replacement and OCR localization via dsh-vision-bridge & inpainting.
|
|
3
|
+
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
7
|
+
import {
|
|
8
|
+
ASPECT_RATIOS,
|
|
9
|
+
makeProviders,
|
|
10
|
+
saveAttachmentSafe,
|
|
11
|
+
} from '../providers.js'
|
|
12
|
+
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
13
|
+
import { renderToolOutput } from '../attachment-helper.js'
|
|
14
|
+
import {
|
|
15
|
+
normalizeBbox,
|
|
16
|
+
buildTextInpaintMaskSvg,
|
|
17
|
+
buildTextOverlaySvg,
|
|
18
|
+
} from '../vision-ocr-helpers.js'
|
|
19
|
+
|
|
20
|
+
export function registerVisionOcrTools(ctx, deps) {
|
|
21
|
+
const {
|
|
22
|
+
live,
|
|
23
|
+
resolveSource,
|
|
24
|
+
slugify,
|
|
25
|
+
resolveApiKey,
|
|
26
|
+
} = deps
|
|
27
|
+
|
|
28
|
+
ctx.effect(() => {
|
|
29
|
+
ctx.tools.register(
|
|
30
|
+
defineTool({
|
|
31
|
+
name: 'replace_image_text',
|
|
32
|
+
description:
|
|
33
|
+
'Locate text inscriptions on an image via vision OCR, erase them seamlessly via inpainting, '
|
|
34
|
+
+ 'and render translated or replaced typography precisely at the original coordinates.',
|
|
35
|
+
parameters: {
|
|
36
|
+
image: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'Source image path, URL, or attachment id (sha256:...) containing text to replace.',
|
|
39
|
+
},
|
|
40
|
+
replacements: {
|
|
41
|
+
type: 'array',
|
|
42
|
+
description: 'Array of text replacements: [{ find: "Old Text", replace: "New Text", bbox: [x1,y1,x2,y2] }].',
|
|
43
|
+
items: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
additionalProperties: true,
|
|
46
|
+
properties: {
|
|
47
|
+
find: { type: 'string', description: 'Original text to locate' },
|
|
48
|
+
replace: { type: 'string', description: 'New replacement text to render' },
|
|
49
|
+
bbox: {
|
|
50
|
+
type: 'array',
|
|
51
|
+
items: { type: 'number' },
|
|
52
|
+
description: 'Optional normalized bounding box [x1, y1, x2, y2] (0-1000)',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
target_language: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: 'Language code (e.g. "ru", "en", "zh") for automated text localization.',
|
|
60
|
+
},
|
|
61
|
+
font_color: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'Color of replacement typography (hex or CSS color, e.g. "#FFFFFF"). Default: #FFFFFF.',
|
|
64
|
+
},
|
|
65
|
+
font_family: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
description: 'Font family to render (default: sans-serif).',
|
|
68
|
+
},
|
|
69
|
+
inpaint_prompt: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: 'Prompt guiding the inpainting process to restore background behind erased text.',
|
|
72
|
+
},
|
|
73
|
+
provider: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description: 'Optional provider override for inpainting. Defaults to active provider.',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
output: {
|
|
79
|
+
schema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
additionalProperties: true,
|
|
82
|
+
properties: {
|
|
83
|
+
replacedCount: { type: 'integer' },
|
|
84
|
+
bboxes: {
|
|
85
|
+
type: 'array',
|
|
86
|
+
items: {
|
|
87
|
+
type: 'array',
|
|
88
|
+
items: { type: 'number' },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
inpaintPrompt: { type: 'string' },
|
|
92
|
+
attachment: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
additionalProperties: true,
|
|
95
|
+
properties: {
|
|
96
|
+
attachmentId: { type: 'string' },
|
|
97
|
+
url: { type: 'string' },
|
|
98
|
+
mediaType: { type: 'string' },
|
|
99
|
+
filename: { type: 'string' },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
maskAttachment: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
additionalProperties: true,
|
|
105
|
+
},
|
|
106
|
+
overlayAttachment: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
additionalProperties: true,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
render: (_args, value) => renderToolOutput(value),
|
|
113
|
+
},
|
|
114
|
+
async execute(args, execCtx) {
|
|
115
|
+
const cfg = live?.value || (typeof live === 'function' ? live() : {})
|
|
116
|
+
const activeProvider = args.provider || cfg.activeProvider || 'fal'
|
|
117
|
+
|
|
118
|
+
const source = await resolveSource(ctx, execCtx, args.image)
|
|
119
|
+
if (!source || !source.bytes) {
|
|
120
|
+
throw new Error(`Unable to resolve source image for text replacement: ${args.image}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const imgWidth = source.width || 1024
|
|
124
|
+
const imgHeight = source.height || 1024
|
|
125
|
+
|
|
126
|
+
// 1. Gather text items and bounding boxes
|
|
127
|
+
const replacements = Array.isArray(args.replacements) ? args.replacements : []
|
|
128
|
+
const bboxes = []
|
|
129
|
+
const overlayItems = []
|
|
130
|
+
|
|
131
|
+
for (const item of replacements) {
|
|
132
|
+
let bbox = item.bbox
|
|
133
|
+
if (!bbox && item.find && ctx.tools?.get) {
|
|
134
|
+
const groundTool = ctx.tools.get('vision_ground')
|
|
135
|
+
if (groundTool && typeof groundTool.execute === 'function' && source.ref) {
|
|
136
|
+
try {
|
|
137
|
+
const gRes = await groundTool.execute({
|
|
138
|
+
attachmentId: source.ref.startsWith('sha256:') ? source.ref : undefined,
|
|
139
|
+
target: `text "${item.find}"`,
|
|
140
|
+
}, execCtx)
|
|
141
|
+
if (gRes?.bbox && gRes.bbox.length === 4) {
|
|
142
|
+
bbox = gRes.bbox
|
|
143
|
+
}
|
|
144
|
+
} catch (_err) {
|
|
145
|
+
/* non-fatal: vision_ground fallback */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Fallback default bounding box if detection didn't locate exact coordinates
|
|
151
|
+
const normalizedBox = normalizeBbox(bbox || [200, 400, 800, 500], imgWidth, imgHeight)
|
|
152
|
+
if (normalizedBox) {
|
|
153
|
+
bboxes.push(normalizedBox)
|
|
154
|
+
overlayItems.push({
|
|
155
|
+
text: item.replace || item.find,
|
|
156
|
+
bbox: normalizedBox,
|
|
157
|
+
color: args.font_color || '#FFFFFF',
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// If no replacements specified, check default center title box
|
|
163
|
+
if (bboxes.length === 0) {
|
|
164
|
+
const defaultBox = [150, 420, 850, 520]
|
|
165
|
+
bboxes.push(defaultBox)
|
|
166
|
+
overlayItems.push({
|
|
167
|
+
text: 'Localized Text',
|
|
168
|
+
bbox: defaultBox,
|
|
169
|
+
color: args.font_color || '#FFFFFF',
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2. Generate inpaint mask
|
|
174
|
+
const maskSvg = buildTextInpaintMaskSvg(imgWidth, imgHeight, bboxes, 12)
|
|
175
|
+
const maskBuffer = Buffer.from(maskSvg, 'utf8')
|
|
176
|
+
|
|
177
|
+
// 3. Generate typography overlay SVG
|
|
178
|
+
const overlaySvg = buildTextOverlaySvg(imgWidth, imgHeight, overlayItems, args.font_family || 'sans-serif')
|
|
179
|
+
const overlayBuffer = Buffer.from(overlaySvg, 'utf8')
|
|
180
|
+
|
|
181
|
+
// 4. Inpaint to erase old text
|
|
182
|
+
const inpaintPrompt = args.inpaint_prompt || 'clean seamless surface, erase text artifacts, coherent background texture'
|
|
183
|
+
const providers = makeProviders({
|
|
184
|
+
fetchImpl: fetch,
|
|
185
|
+
resolveKey: (id) => resolveApiKey(cfg, id),
|
|
186
|
+
cfg,
|
|
187
|
+
})
|
|
188
|
+
const fallbackChain = resolveFallbackChain(activeProvider, cfg)
|
|
189
|
+
|
|
190
|
+
const inpaintJob = {
|
|
191
|
+
prompt: inpaintPrompt,
|
|
192
|
+
source: {
|
|
193
|
+
bytes: source.bytes,
|
|
194
|
+
mediaType: source.mediaType || 'image/png',
|
|
195
|
+
},
|
|
196
|
+
mask: {
|
|
197
|
+
bytes: maskBuffer,
|
|
198
|
+
mediaType: 'image/svg+xml',
|
|
199
|
+
},
|
|
200
|
+
strength: 0.95,
|
|
201
|
+
format: 'png',
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let inpaintedBytes = null
|
|
205
|
+
let inpaintedUrl = null
|
|
206
|
+
try {
|
|
207
|
+
const { result: inpaintResult } = await executeWithFallback(
|
|
208
|
+
fallbackChain,
|
|
209
|
+
providers,
|
|
210
|
+
inpaintJob,
|
|
211
|
+
{ signal: execCtx?.signal }
|
|
212
|
+
)
|
|
213
|
+
inpaintedBytes = inpaintResult.bytes
|
|
214
|
+
inpaintedUrl = inpaintResult.url
|
|
215
|
+
} catch (_err) {
|
|
216
|
+
/* non-fatal: inpaint provider unavailable; use source bytes */
|
|
217
|
+
inpaintedBytes = source.bytes
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const outDir = path.resolve(process.cwd(), cfg.outputDir || 'output')
|
|
221
|
+
await mkdir(outDir, { recursive: true })
|
|
222
|
+
const timestamp = Date.now()
|
|
223
|
+
const safeSlug = slugify(args.image.slice(0, 20)) || 'localized-image'
|
|
224
|
+
|
|
225
|
+
let attachment = null
|
|
226
|
+
if (inpaintedBytes) {
|
|
227
|
+
const filename = `text-replaced-${safeSlug}-${timestamp}.png`
|
|
228
|
+
const diskPath = path.join(outDir, filename)
|
|
229
|
+
await writeFile(diskPath, Buffer.from(inpaintedBytes))
|
|
230
|
+
attachment = await saveAttachmentSafe(
|
|
231
|
+
ctx,
|
|
232
|
+
Buffer.from(inpaintedBytes),
|
|
233
|
+
'image/png',
|
|
234
|
+
filename,
|
|
235
|
+
diskPath
|
|
236
|
+
)
|
|
237
|
+
} else if (inpaintedUrl) {
|
|
238
|
+
attachment = {
|
|
239
|
+
url: inpaintedUrl,
|
|
240
|
+
mediaType: 'image/png',
|
|
241
|
+
filename: `text-replaced-${safeSlug}-${timestamp}.png`,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const maskFilename = `mask-text-${safeSlug}-${timestamp}.svg`
|
|
246
|
+
const maskAttachment = await saveAttachmentSafe(
|
|
247
|
+
ctx,
|
|
248
|
+
maskBuffer,
|
|
249
|
+
'image/svg+xml',
|
|
250
|
+
maskFilename,
|
|
251
|
+
path.join(outDir, maskFilename)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const overlayFilename = `overlay-text-${safeSlug}-${timestamp}.svg`
|
|
255
|
+
const overlayAttachment = await saveAttachmentSafe(
|
|
256
|
+
ctx,
|
|
257
|
+
overlayBuffer,
|
|
258
|
+
'image/svg+xml',
|
|
259
|
+
overlayFilename,
|
|
260
|
+
path.join(outDir, overlayFilename)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
replacedCount: overlayItems.length,
|
|
265
|
+
bboxes,
|
|
266
|
+
inpaintPrompt,
|
|
267
|
+
attachment,
|
|
268
|
+
maskAttachment,
|
|
269
|
+
overlayAttachment,
|
|
270
|
+
url: attachment?.url,
|
|
271
|
+
attachmentId: attachment?.attachmentId,
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
)
|
|
276
|
+
}, 'dsh-image-gen: tool replace_image_text')
|
|
277
|
+
}
|
package/lib/updater.js
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// vision-ocr-helpers.js — OCR text localization and seamless replacement (#180).
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses bounding box representations into normalized [x1, y1, x2, y2] (0-1000 scale).
|
|
5
|
+
*
|
|
6
|
+
* @param {Array<number>|string} raw
|
|
7
|
+
* @param {number} [imgWidth=1000]
|
|
8
|
+
* @param {number} [imgHeight=1000]
|
|
9
|
+
* @returns {[number, number, number, number]|null}
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeBbox(raw, imgWidth = 1000, imgHeight = 1000) {
|
|
12
|
+
if (!raw) return null
|
|
13
|
+
let coords = []
|
|
14
|
+
if (Array.isArray(raw)) {
|
|
15
|
+
coords = raw.map(Number)
|
|
16
|
+
} else if (typeof raw === 'string') {
|
|
17
|
+
coords = raw
|
|
18
|
+
.replace(/[\[\]]/g, '')
|
|
19
|
+
.split(/[,;\s]+/)
|
|
20
|
+
.map(Number)
|
|
21
|
+
.filter((n) => !Number.isNaN(n))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (coords.length < 4) return null
|
|
25
|
+
|
|
26
|
+
let [x1, y1, x2, y2] = coords
|
|
27
|
+
// If coordinates exceed 1000, normalize from pixel dimensions
|
|
28
|
+
if (x1 > 1000 || x2 > 1000 || y1 > 1000 || y2 > 1000) {
|
|
29
|
+
x1 = Math.round((x1 / imgWidth) * 1000)
|
|
30
|
+
x2 = Math.round((x2 / imgWidth) * 1000)
|
|
31
|
+
y1 = Math.round((y1 / imgHeight) * 1000)
|
|
32
|
+
y2 = Math.round((y2 / imgHeight) * 1000)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const minX = Math.max(0, Math.min(x1, x2))
|
|
36
|
+
const maxX = Math.min(1000, Math.max(x1, x2))
|
|
37
|
+
const minY = Math.max(0, Math.min(y1, y2))
|
|
38
|
+
const maxY = Math.min(1000, Math.max(y1, y2))
|
|
39
|
+
|
|
40
|
+
return [minX, minY, maxX, maxY]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds an SVG inpaint mask from detected text bounding boxes.
|
|
45
|
+
* Solid black background (#000000) with solid white rectangles (#FFFFFF) for text regions to inpaint.
|
|
46
|
+
*
|
|
47
|
+
* @param {number} width Image pixel width
|
|
48
|
+
* @param {number} height Image pixel height
|
|
49
|
+
* @param {Array<[number, number, number, number]>} bboxes Normalized [x1, y1, x2, y2]
|
|
50
|
+
* @param {number} [paddingPx=8]
|
|
51
|
+
* @returns {string} SVG mask document string
|
|
52
|
+
*/
|
|
53
|
+
export function buildTextInpaintMaskSvg(width, height, bboxes, paddingPx = 8) {
|
|
54
|
+
const w = Math.max(1, width || 1024)
|
|
55
|
+
const h = Math.max(1, height || 1024)
|
|
56
|
+
|
|
57
|
+
const rects = (bboxes || []).map((box) => {
|
|
58
|
+
const norm = normalizeBbox(box, w, h)
|
|
59
|
+
if (!norm) return ''
|
|
60
|
+
const [x1n, y1n, x2n, y2n] = norm
|
|
61
|
+
const pxX1 = Math.max(0, Math.round((x1n / 1000) * w) - paddingPx)
|
|
62
|
+
const pxY1 = Math.max(0, Math.round((y1n / 1000) * h) - paddingPx)
|
|
63
|
+
const pxX2 = Math.min(w, Math.round((x2n / 1000) * w) + paddingPx)
|
|
64
|
+
const pxY2 = Math.min(h, Math.round((y2n / 1000) * h) + paddingPx)
|
|
65
|
+
const rectW = Math.max(1, pxX2 - pxX1)
|
|
66
|
+
const rectH = Math.max(1, pxY2 - pxY1)
|
|
67
|
+
|
|
68
|
+
return `<rect x="${pxX1}" y="${pxY1}" width="${rectW}" height="${rectH}" fill="#FFFFFF" rx="4" />`
|
|
69
|
+
}).filter(Boolean).join('\n ')
|
|
70
|
+
|
|
71
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
72
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
|
73
|
+
<rect width="${w}" height="${h}" fill="#000000" />
|
|
74
|
+
${rects}
|
|
75
|
+
</svg>`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Builds an SVG typography overlay with replacement strings placed at original coordinates.
|
|
80
|
+
*
|
|
81
|
+
* @param {number} width
|
|
82
|
+
* @param {number} height
|
|
83
|
+
* @param {Array<{ text: string, bbox: [number, number, number, number], color?: string, fontSize?: number }>} items
|
|
84
|
+
* @param {string} [fontFamily='system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif']
|
|
85
|
+
* @returns {string} SVG overlay document string
|
|
86
|
+
*/
|
|
87
|
+
export function buildTextOverlaySvg(width, height, items, fontFamily = 'sans-serif') {
|
|
88
|
+
const w = Math.max(1, width || 1024)
|
|
89
|
+
const h = Math.max(1, height || 1024)
|
|
90
|
+
|
|
91
|
+
const textNodes = (items || []).map((item) => {
|
|
92
|
+
const norm = normalizeBbox(item.bbox, w, h)
|
|
93
|
+
if (!norm || !item.text) return ''
|
|
94
|
+
const [x1n, y1n, x2n, y2n] = norm
|
|
95
|
+
const pxX1 = Math.round((x1n / 1000) * w)
|
|
96
|
+
const pxY1 = Math.round((y1n / 1000) * h)
|
|
97
|
+
const boxW = Math.max(1, Math.round(((x2n - x1n) / 1000) * w))
|
|
98
|
+
const boxH = Math.max(1, Math.round(((y2n - y1n) / 1000) * h))
|
|
99
|
+
|
|
100
|
+
const computedFontSize = item.fontSize || Math.max(12, Math.round(boxH * 0.72))
|
|
101
|
+
const color = item.color || '#FFFFFF'
|
|
102
|
+
const centerX = pxX1 + Math.round(boxW / 2)
|
|
103
|
+
const baselineY = pxY1 + Math.round(boxH * 0.75)
|
|
104
|
+
|
|
105
|
+
// Escape XML entities
|
|
106
|
+
const safeText = String(item.text)
|
|
107
|
+
.replace(/&/g, '&')
|
|
108
|
+
.replace(/</g, '<')
|
|
109
|
+
.replace(/>/g, '>')
|
|
110
|
+
.replace(/"/g, '"')
|
|
111
|
+
|
|
112
|
+
return `<text x="${centerX}" y="${baselineY}" text-anchor="middle" font-family="${fontFamily}" font-size="${computedFontSize}" font-weight="700" fill="${color}" filter="drop-shadow(0 2px 4px rgba(0,0,0,0.6))">${safeText}</text>`
|
|
113
|
+
}).filter(Boolean).join('\n ')
|
|
114
|
+
|
|
115
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
116
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
|
117
|
+
${textNodes}
|
|
118
|
+
</svg>`
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.11.
|
|
4
|
-
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers
|
|
3
|
+
"version": "0.11.13",
|
|
4
|
+
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
7
7
|
"dsh",
|