- Segment: reject NaN thresholds (NaN fails every comparison, so it passed the [0,1] range check and reached the shim as the literal string "NaN"); ±Inf were already caught by the range comparisons, now covered by tests too. - upstreamPath: reject '%' in model ids — %2F/%2E%2E percent-escapes decode back into path structure server-side, bypassing the literal /?#/.. rejection. Ids never legitimately contain '%'. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WWCQcYStWXBYUy5sZWnbLT
80 lines
2.8 KiB
Go
80 lines
2.8 KiB
Go
// segment.go implements imagegen.SegmentationProvider against a
|
|
// GroundingDINO+SAM shim (segment-langsam) reached through llama-swap's
|
|
// /upstream passthrough (ADR-0023):
|
|
//
|
|
// POST /upstream/<id>/v1/segment multipart file,prompt[,threshold],output=mask
|
|
//
|
|
// The response is a single grayscale mask PNG where WHITE marks the prompted
|
|
// region — directly usable as imagegen.EditRequest.Mask (white = repaint).
|
|
// The shim also offers output=cutout|boxes; this client always requests the
|
|
// mask, because a cutout is derivable client-side from mask+original with no
|
|
// second GPU call.
|
|
package llamaswap
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/imagegen"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
)
|
|
|
|
// SegmentationModel implements imagegen.SegmentationProvider. The id selects
|
|
// which upstream llama-swap loads.
|
|
func (p *Provider) SegmentationModel(id string, opts ...imagegen.SegmentationModelOption) (imagegen.Segmenter, error) {
|
|
if err := p.requireBaseURL(); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = imagegen.ApplySegmentationModelOptions(opts)
|
|
return &segmentationModel{p: p, id: id}, nil
|
|
}
|
|
|
|
type segmentationModel struct {
|
|
p *Provider
|
|
id string
|
|
}
|
|
|
|
// Segment implements imagegen.Segmenter.
|
|
func (m *segmentationModel) Segment(ctx context.Context, req imagegen.SegmentationRequest, opts ...imagegen.SegmentationOption) (*imagegen.Result, error) {
|
|
req = req.Apply(opts...)
|
|
if len(req.Image.Data) == 0 {
|
|
return nil, fmt.Errorf("%w: segmentation requires an image", llm.ErrUnsupported)
|
|
}
|
|
if req.Prompt == "" {
|
|
return nil, fmt.Errorf("%w: segmentation requires a prompt", llm.ErrUnsupported)
|
|
}
|
|
// NaN fails every comparison, so it would sail through a bare range
|
|
// check and reach the upstream as the literal string "NaN".
|
|
if math.IsNaN(req.Threshold) || req.Threshold < 0 || req.Threshold > 1 {
|
|
return nil, fmt.Errorf("%w: segmentation threshold must be in [0,1], got %g", llm.ErrUnsupported, req.Threshold)
|
|
}
|
|
path, err := upstreamPath(m.id, "/v1/segment")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
threshold := ""
|
|
if req.Threshold != 0 {
|
|
threshold = strconv.FormatFloat(req.Threshold, 'g', -1, 64)
|
|
}
|
|
body, contentType, err := buildMultipart("build segment form",
|
|
filePart{field: "file", filename: "image.png", data: req.Image.Data},
|
|
[]formField{
|
|
{"prompt", req.Prompt, true},
|
|
{"threshold", threshold, false},
|
|
// Always the mask: white = prompted region, EditRequest.Mask
|
|
// polarity. Cutouts are derived client-side.
|
|
{"output", "mask", true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, respType, err := m.p.doRaw(ctx, http.MethodPost, path, m.id, contentType, body, maxImageResponseBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return singleImageResult(m.p.name, m.id, "segmentation", raw, respType)
|
|
}
|