mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
feat(images): add ESRGAN upscaling and Studio workflow (#12620)
* feat(images): add upscale model capabilities * fix(config): keep SD GGML upscale opt-in * feat(stablediffusion): add lazy ESRGAN upscale bridge Keep native image ownership in C++ and serialize context creation, inference and teardown. Use upscale_scale in backend options as the explicit upscale-only marker because ModelOptions does not carry usecases. Assisted-by: Codex:gpt-5 * fix(stablediffusion): validate ESRGAN settings * fix(upscale): forward typed settings and contain resolved model paths Typed settings replace legacy options without duplicates. Resolve model and trusted root symlinks before caching the model path, rejecting missing files and escapes. Assisted-by: Codex:gpt-5 * fix(upscale): validate scale and clean failed HTTP outputs Require an explicit scale bounded to 1-16, check backend results, and keep destination cleanup owned by the allocating endpoint. Cover validation, result failures, cleanup, and successful URL responses. Assisted-by: Codex:gpt-5 * feat(studio): add inline image upscale workflow Assisted-by: OpenAI:gpt-5 * test(studio): serve upscale capability fixtures * fix(stablediffusion): default ESRGAN tiling to full frame * feat(gallery): add RealESRGAN anime upscaler * fix(stablediffusion): allow full-frame ESRGAN tiles * fix(routes): select explicit upscale models Give image upscaling dedicated usecase-filtered middleware and keep ordinary image models out of the endpoint. Mark and verify the legacy Diffusers x4 gallery model as an explicit upscaler. Assisted-by: OpenAI:gpt-5 [shell] * refactor(upscale): keep settings in backend options * test(config): keep upscale options backend-specific * fix: keep upscale routing metadata out of backend options --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
a8ee0d95ec
commit
0177e4988c
43 files changed
+1660
-75
No files matched your search
@@ -1,5 +1,7 @@
|
||||
#include "stable-diffusion.h"
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
#include <climits>
|
||||
#include <cstdint>
|
||||
#define GGML_MAX_NAME 128
|
||||
|
||||
@@ -1452,3 +1454,66 @@ int unload() {
|
||||
free_sd_ctx(sd_c);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// Own copies even if upstream retains constructor arguments: never retain a Go
|
||||
// string pointer across the ABI call.
|
||||
struct gosd_upscaler {
|
||||
std::string path, backend, params_backend;
|
||||
upscaler_ctx_t* ctx = nullptr;
|
||||
~gosd_upscaler() { if (ctx) free_upscaler_ctx(ctx); }
|
||||
};
|
||||
|
||||
void* gosd_upscaler_create(const char* path, bool direct, int threads, int tile_size,
|
||||
const char* backend, const char* params_backend) {
|
||||
if (!path || !*path || tile_size < 0) return nullptr;
|
||||
try {
|
||||
auto h = std::make_unique<gosd_upscaler>();
|
||||
h->path = path;
|
||||
h->backend = backend ? backend : "";
|
||||
h->params_backend = params_backend ? params_backend : "";
|
||||
h->ctx = new_upscaler_ctx(h->path.c_str(), direct, threads, tile_size,
|
||||
h->backend.c_str(), h->params_backend.c_str());
|
||||
if (!h->ctx) return nullptr;
|
||||
return h.release();
|
||||
} catch (...) { return nullptr; }
|
||||
}
|
||||
|
||||
int gosd_upscaler_scale(void* handle) {
|
||||
if (!handle) return 0;
|
||||
try { return get_upscale_factor(static_cast<gosd_upscaler*>(handle)->ctx); }
|
||||
catch (...) { return 0; }
|
||||
}
|
||||
|
||||
void gosd_upscaler_destroy(void* handle) {
|
||||
delete static_cast<gosd_upscaler*>(handle);
|
||||
}
|
||||
|
||||
int gosd_upscaler_run(void* handle, const char* src, const char* dst, int scale) {
|
||||
if (!handle || !src || !*src || !dst || !*dst || scale <= 0) return 1;
|
||||
// RAII also covers exceptions from native inference.
|
||||
struct images {
|
||||
sd_image_t* data = nullptr;
|
||||
int count = 0;
|
||||
~images() { if (data) free_sd_images(data, count); }
|
||||
} output;
|
||||
int width = 0, height = 0, channels = 0;
|
||||
std::unique_ptr<unsigned char, decltype(&stbi_image_free)> input(
|
||||
stbi_load(src, &width, &height, &channels, 3), stbi_image_free);
|
||||
if (!input || width <= 0 || height <= 0) return 2;
|
||||
const uint64_t out_width = uint64_t(width) * scale;
|
||||
const uint64_t out_height = uint64_t(height) * scale;
|
||||
if (out_width > INT_MAX / 3 || out_height > INT_MAX ||
|
||||
out_width * out_height > SIZE_MAX / 3) return 4;
|
||||
sd_image_t image = {uint32_t(width), uint32_t(height), 3, input.get()};
|
||||
try {
|
||||
if (!upscale(static_cast<gosd_upscaler*>(handle)->ctx, image, scale,
|
||||
&output.data, &output.count)) return 3;
|
||||
if (!output.data || output.count != 1 || !output.data[0].data ||
|
||||
output.data[0].width != out_width || output.data[0].height != out_height ||
|
||||
output.data[0].channel != 3) return 4;
|
||||
if (!stbi_write_png(dst, int(out_width), int(out_height), 3,
|
||||
output.data[0].data, int(out_width) * 3)) return 5;
|
||||
return 0;
|
||||
} catch (...) { return 3; }
|
||||
}
|
||||
@@ -5,6 +5,13 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Opaque ESRGAN context. Run returns 0, or argument/decode/inference/output/write
|
||||
// errors (1..5); all image allocations remain native.
|
||||
void* gosd_upscaler_create(const char* path, bool direct, int threads, int tile_size, const char* backend, const char* params_backend);
|
||||
int gosd_upscaler_scale(void* handle);
|
||||
int gosd_upscaler_run(void* handle, const char* src, const char* dst, int scale);
|
||||
void gosd_upscaler_destroy(void* handle);
|
||||
|
||||
void sd_tiling_params_set_enabled(sd_tiling_params_t *params, bool enabled);
|
||||
void sd_tiling_params_set_tile_sizes(sd_tiling_params_t *params, int tile_size_w, int tile_size_h);
|
||||
void sd_tiling_params_set_rel_sizes(sd_tiling_params_t *params, float rel_size_w, float rel_size_h);
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/grpc/base"
|
||||
@@ -17,6 +18,9 @@ import (
|
||||
|
||||
type SDGGML struct {
|
||||
base.SingleThread
|
||||
upscaleMu sync.Mutex
|
||||
upscaler uintptr
|
||||
upscale upscaleSettings
|
||||
threads int
|
||||
sampleMethod string
|
||||
cfgScale float32
|
||||
@@ -25,9 +29,13 @@ type SDGGML struct {
|
||||
}
|
||||
|
||||
var (
|
||||
LoadModel func(model, model_apth string, options []uintptr, threads int32, diff int) int
|
||||
GenImage func(params uintptr, steps int, dst string, cfgScale float32, srcImage string, strength float32, maskImage string, refImages []uintptr, refImagesCount int) int
|
||||
GenVideo func(params uintptr, steps int, dst string, cfgScale float32, fps int, initImage string, endImage string) int
|
||||
UpscalerCreate func(string, bool, int32, int32, string, string) uintptr
|
||||
UpscalerScale func(uintptr) int32
|
||||
UpscalerRun func(uintptr, string, string, int32) int32
|
||||
UpscalerDestroy func(uintptr)
|
||||
LoadModel func(model, model_apth string, options []uintptr, threads int32, diff int) int
|
||||
GenImage func(params uintptr, steps int, dst string, cfgScale float32, srcImage string, strength float32, maskImage string, refImages []uintptr, refImagesCount int) int
|
||||
GenVideo func(params uintptr, steps int, dst string, cfgScale float32, fps int, initImage string, endImage string) int
|
||||
|
||||
TilingParamsSetEnabled func(params uintptr, enabled bool)
|
||||
TilingParamsSetTileSizes func(params uintptr, tileSizeX int, tileSizeY int)
|
||||
@@ -154,6 +162,22 @@ func CString(name string) *byte {
|
||||
}
|
||||
|
||||
func (sd *SDGGML) Load(opts *pb.ModelOptions) error {
|
||||
sd.upscaleMu.Lock()
|
||||
defer sd.upscaleMu.Unlock()
|
||||
if opts == nil {
|
||||
return fmt.Errorf("missing model options")
|
||||
}
|
||||
settings, err := parseUpscaleSettings(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sd.upscaler != 0 {
|
||||
return fmt.Errorf("free the existing upscaler before loading another model")
|
||||
}
|
||||
sd.upscale = settings
|
||||
if settings.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
sd.threads = int(opts.Threads)
|
||||
|
||||
@@ -336,3 +360,175 @@ func (sd *SDGGML) GenerateVideo(opts *pb.GenerateVideoRequest) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Presence of the backend option upscale_scale selects upscale mode.
|
||||
type upscaleSettings struct {
|
||||
enabled bool
|
||||
path string
|
||||
scale, tile, threads int32
|
||||
direct bool
|
||||
backend, paramsBackend string
|
||||
}
|
||||
|
||||
func parseUpscaleSettings(opts *pb.ModelOptions) (upscaleSettings, error) {
|
||||
s := upscaleSettings{threads: opts.Threads}
|
||||
image := false
|
||||
for _, op := range opts.Options {
|
||||
k, _, _ := strings.Cut(op, ":")
|
||||
if k == "upscale_scale" {
|
||||
s.enabled = true
|
||||
}
|
||||
if k == "diffusion_model" {
|
||||
image = true
|
||||
}
|
||||
}
|
||||
if !s.enabled {
|
||||
return s, nil
|
||||
}
|
||||
if image {
|
||||
return s, fmt.Errorf("upscale_scale cannot be combined with diffusion_model")
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, op := range opts.Options {
|
||||
k, v, _ := strings.Cut(op, ":")
|
||||
switch k {
|
||||
case "upscale_scale", "upscale_tile_size", "upscale_direct", "backend", "params_backend":
|
||||
if seen[k] {
|
||||
return s, fmt.Errorf("duplicate upscale setting %q", k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
switch k {
|
||||
case "upscale_scale", "upscale_tile_size":
|
||||
n, err := strconv.ParseInt(v, 10, 32)
|
||||
if err != nil || (k == "upscale_scale" && n <= 0) || (k == "upscale_tile_size" && n < 0) {
|
||||
if k == "upscale_tile_size" {
|
||||
return s, fmt.Errorf("%s must be a non-negative integer", k)
|
||||
}
|
||||
return s, fmt.Errorf("%s must be a positive integer", k)
|
||||
}
|
||||
if k == "upscale_scale" {
|
||||
s.scale = int32(n)
|
||||
} else {
|
||||
s.tile = int32(n)
|
||||
}
|
||||
case "upscale_direct":
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("invalid upscale_direct: %w", err)
|
||||
}
|
||||
s.direct = b
|
||||
case "backend":
|
||||
s.backend = v
|
||||
case "params_backend":
|
||||
s.paramsBackend = v
|
||||
}
|
||||
}
|
||||
if s.scale <= 0 {
|
||||
return s, fmt.Errorf("upscale_scale must be configured and positive")
|
||||
}
|
||||
modelFile := opts.ModelFile
|
||||
if modelFile == "" {
|
||||
return s, fmt.Errorf("upscale model path is empty")
|
||||
}
|
||||
if filepath.IsAbs(modelFile) {
|
||||
s.path = filepath.Clean(modelFile)
|
||||
} else {
|
||||
// Verify the untrusted relative name before joining, then verify the
|
||||
// resolved result as well. VerifyPath intentionally treats absolute
|
||||
// inputs as relative, so absolute model files use VerifyResolvedPath.
|
||||
if err := utils.VerifyPath(modelFile, opts.ModelPath); err != nil {
|
||||
return s, fmt.Errorf("upscale model path is outside model path: %w", err)
|
||||
}
|
||||
s.path = filepath.Join(opts.ModelPath, modelFile)
|
||||
}
|
||||
if err := utils.VerifyResolvedPath(s.path, opts.ModelPath); err != nil {
|
||||
return s, fmt.Errorf("upscale model path is outside model path: %w", err)
|
||||
}
|
||||
// Lexical containment alone does not prevent a model symlink (or a
|
||||
// symlinked parent directory) from escaping the trusted root. Resolve
|
||||
// both, requiring an existing target, and cache only the real model path.
|
||||
realRoot, err := filepath.EvalSymlinks(opts.ModelPath)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("resolve upscale model root: %w", err)
|
||||
}
|
||||
realPath, err := filepath.EvalSymlinks(s.path)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("resolve upscale model file: %w", err)
|
||||
}
|
||||
if err := utils.VerifyResolvedPath(realPath, realRoot); err != nil {
|
||||
return s, fmt.Errorf("upscale model path is outside real model path: %w", err)
|
||||
}
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("stat upscale model file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return s, fmt.Errorf("upscale model path must be a regular file")
|
||||
}
|
||||
s.path = realPath
|
||||
for _, v := range []string{s.path, s.backend, s.paramsBackend} {
|
||||
if strings.ContainsRune(v, 0) {
|
||||
return s, fmt.Errorf("upscale settings contain NUL")
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (sd *SDGGML) UpscaleImage(opts *pb.UpscaleImageRequest) error {
|
||||
sd.upscaleMu.Lock()
|
||||
defer sd.upscaleMu.Unlock()
|
||||
s := sd.upscale
|
||||
if !s.enabled {
|
||||
return fmt.Errorf("no upscale-only model loaded (configure upscale_scale)")
|
||||
}
|
||||
if opts == nil || opts.Scale <= 0 || opts.Src == "" || opts.Dst == "" || strings.ContainsRune(opts.Src, 0) || strings.ContainsRune(opts.Dst, 0) {
|
||||
return fmt.Errorf("upscale requires source, destination and positive requested scale")
|
||||
}
|
||||
if sd.upscaler == 0 {
|
||||
h := UpscalerCreate(s.path, s.direct, s.threads, s.tile, s.backend, s.paramsBackend)
|
||||
runtime.KeepAlive(s)
|
||||
if h == 0 {
|
||||
return fmt.Errorf("could not initialize ESRGAN upscaler")
|
||||
}
|
||||
detected := UpscalerScale(h)
|
||||
if detected <= 0 || detected != s.scale {
|
||||
UpscalerDestroy(h)
|
||||
return fmt.Errorf("configured upscale scale %d differs from detected native scale %d", s.scale, detected)
|
||||
}
|
||||
sd.upscaler = h
|
||||
}
|
||||
if opts.Scale != s.scale {
|
||||
return fmt.Errorf("requested upscale scale %d differs from native scale %d", opts.Scale, s.scale)
|
||||
}
|
||||
code := UpscalerRun(sd.upscaler, opts.Src, opts.Dst, opts.Scale)
|
||||
runtime.KeepAlive(opts)
|
||||
switch code {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return fmt.Errorf("invalid native upscale arguments")
|
||||
case 2:
|
||||
return fmt.Errorf("upscale source decode failed")
|
||||
case 3:
|
||||
return fmt.Errorf("upscale inference failed")
|
||||
case 4:
|
||||
return fmt.Errorf("upscale output validation failed")
|
||||
case 5:
|
||||
return fmt.Errorf("upscale PNG write failed")
|
||||
default:
|
||||
return fmt.Errorf("upscale failed (native code %d)", code)
|
||||
}
|
||||
}
|
||||
|
||||
func (sd *SDGGML) Free() error {
|
||||
sd.upscaleMu.Lock()
|
||||
defer sd.upscaleMu.Unlock()
|
||||
if sd.upscaler != 0 {
|
||||
UpscalerDestroy(sd.upscaler)
|
||||
sd.upscaler = 0
|
||||
}
|
||||
sd.upscale = upscaleSettings{}
|
||||
// Deliberately leave the existing diffusion sd_c lifecycle unchanged.
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
@@ -222,3 +224,313 @@ var _ = Describe("GenerateImage VAE tiling", func() {
|
||||
Expect(fake.overlapCalls).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ESRGAN lifecycle", func() {
|
||||
var sd *SDGGML
|
||||
var root, modelPath string
|
||||
var creates, destroys, runs, loads int
|
||||
var scale, code int32
|
||||
var fail bool
|
||||
var entered, release chan struct{}
|
||||
BeforeEach(func() {
|
||||
sd = &SDGGML{}
|
||||
root = GinkgoT().TempDir()
|
||||
var err error
|
||||
root, err = filepath.EvalSymlinks(root)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
modelPath = filepath.Join(root, "esrgan.gguf")
|
||||
Expect(os.WriteFile(modelPath, []byte("model"), 0600)).To(Succeed())
|
||||
creates, destroys, runs, loads = 0, 0, 0, 0
|
||||
scale, code, fail = 4, 0, false
|
||||
entered, release = nil, nil
|
||||
oldCreate, oldScale, oldRun, oldDestroy, oldLoad := UpscalerCreate, UpscalerScale, UpscalerRun, UpscalerDestroy, LoadModel
|
||||
DeferCleanup(func() {
|
||||
UpscalerCreate, UpscalerScale, UpscalerRun, UpscalerDestroy, LoadModel = oldCreate, oldScale, oldRun, oldDestroy, oldLoad
|
||||
})
|
||||
UpscalerCreate = func(path string, direct bool, threads, tile int32, backend, params string) uintptr {
|
||||
creates++
|
||||
Expect(path).To(Equal(modelPath))
|
||||
Expect(tile).To(Equal(int32(128)))
|
||||
if fail {
|
||||
return 0
|
||||
}
|
||||
return 42
|
||||
}
|
||||
UpscalerScale = func(uintptr) int32 { return scale }
|
||||
UpscalerRun = func(uintptr, string, string, int32) int32 {
|
||||
runs++
|
||||
if entered != nil {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
return code
|
||||
}
|
||||
UpscalerDestroy = func(h uintptr) { Expect(h).To(Equal(uintptr(42))); destroys++ }
|
||||
LoadModel = func(string, string, []uintptr, int32, int) int { loads++; return 0 }
|
||||
})
|
||||
load := func(extra ...string) error {
|
||||
return sd.Load(&pb.ModelOptions{ModelPath: root, ModelFile: "esrgan.gguf", Options: append([]string{"upscale_scale:4", "upscale_tile_size:128"}, extra...)})
|
||||
}
|
||||
run := func(s int32) error {
|
||||
return sd.UpscaleImage(&pb.UpscaleImageRequest{Src: "in.png", Dst: "out.png", Scale: s})
|
||||
}
|
||||
It("skips diffusion, creates lazily, reuses and frees once", func() {
|
||||
Expect(load()).To(Succeed())
|
||||
Expect(loads).To(BeZero())
|
||||
Expect(creates).To(BeZero())
|
||||
Expect(run(4)).To(Succeed())
|
||||
Expect(run(4)).To(Succeed())
|
||||
Expect(creates).To(Equal(1))
|
||||
Expect(runs).To(Equal(2))
|
||||
Expect(sd.Free()).To(Succeed())
|
||||
Expect(sd.Free()).To(Succeed())
|
||||
Expect(destroys).To(Equal(1))
|
||||
Expect(run(4)).NotTo(Succeed())
|
||||
})
|
||||
It("rejects invalid configuration", func() {
|
||||
Expect(load("upscale_scale:0")).NotTo(Succeed())
|
||||
Expect(load("upscale_tile_size:-1")).NotTo(Succeed())
|
||||
})
|
||||
It("rejects detected scale mismatch without caching", func() {
|
||||
Expect(load()).To(Succeed())
|
||||
scale = 2
|
||||
Expect(run(4)).To(MatchError(ContainSubstring("configured")))
|
||||
Expect(destroys).To(Equal(1))
|
||||
scale = 4
|
||||
Expect(run(4)).To(Succeed())
|
||||
Expect(creates).To(Equal(2))
|
||||
})
|
||||
It("rejects requested scale mismatch", func() {
|
||||
Expect(load()).To(Succeed())
|
||||
Expect(run(2)).To(MatchError(ContainSubstring("requested")))
|
||||
Expect(runs).To(BeZero())
|
||||
})
|
||||
It("does not cache failed initialization", func() {
|
||||
Expect(load()).To(Succeed())
|
||||
fail = true
|
||||
Expect(run(4)).NotTo(Succeed())
|
||||
fail = false
|
||||
Expect(run(4)).To(Succeed())
|
||||
Expect(creates).To(Equal(2))
|
||||
})
|
||||
It("rejects undetected scales", func() {
|
||||
Expect(load()).To(Succeed())
|
||||
scale = 0
|
||||
Expect(run(4)).NotTo(Succeed())
|
||||
Expect(destroys).To(Equal(1))
|
||||
})
|
||||
It("reports native failures", func() {
|
||||
Expect(load()).To(Succeed())
|
||||
for _, c := range []int32{1, 2, 3, 4, 5} {
|
||||
code = c
|
||||
Expect(run(4)).NotTo(Succeed())
|
||||
}
|
||||
})
|
||||
It("waits for inference before Free", func() {
|
||||
Expect(load()).To(Succeed())
|
||||
Expect(run(4)).To(Succeed())
|
||||
entered, release = make(chan struct{}), make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
freed := make(chan error, 1)
|
||||
go func() { done <- run(4) }()
|
||||
<-entered
|
||||
go func() { freed <- sd.Free() }()
|
||||
Consistently(freed, "50ms").ShouldNot(Receive())
|
||||
close(release)
|
||||
Eventually(done).Should(Receive(BeNil()))
|
||||
Eventually(freed).Should(Receive(BeNil()))
|
||||
Expect(destroys).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
func TestUpscaleSettings(t *testing.T) {
|
||||
root := upscaleTestRoot(t)
|
||||
opts := &pb.ModelOptions{ModelPath: root, ModelFile: filepath.Join(root, "model.gguf"), Threads: 7, Options: []string{
|
||||
"upscale_scale:4", "upscale_tile_size:256",
|
||||
"upscale_direct:true", "backend:Vulkan0", "params_backend:CPU",
|
||||
}}
|
||||
got, err := parseUpscaleSettings(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.enabled || got.path != opts.ModelFile || got.scale != 4 || got.tile != 256 || got.threads != 7 || !got.direct || got.backend != "Vulkan0" || got.paramsBackend != "CPU" {
|
||||
t.Fatalf("wrong settings: %+v", got)
|
||||
}
|
||||
for _, options := range [][]string{
|
||||
{"upscale_scale:banana"}, {"upscale_scale:2147483648"},
|
||||
{"upscale_scale:4", "upscale_tile_size:-1"},
|
||||
{"upscale_scale:4", "diffusion_model"}, {"upscale_scale:4", "upscale_direct:maybe"},
|
||||
} {
|
||||
opts.Options = options
|
||||
if _, err := parseUpscaleSettings(opts); err == nil {
|
||||
t.Fatalf("accepted invalid options %v", options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpscaleModeSelection(t *testing.T) {
|
||||
root := upscaleTestRoot(t)
|
||||
|
||||
upscale, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"upscale_scale:4"}})
|
||||
if err != nil || !upscale.enabled {
|
||||
t.Fatalf("upscale_scale did not enable upscale mode: settings=%+v err=%v", upscale, err)
|
||||
}
|
||||
|
||||
knownUsecases, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"known_usecases:upscale"}})
|
||||
if err != nil || knownUsecases.enabled {
|
||||
t.Fatalf("known_usecases must not enable upscale mode: settings=%+v err=%v", knownUsecases, err)
|
||||
}
|
||||
|
||||
if _, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"upscale_scale:4", "diffusion_model"}}); err == nil {
|
||||
t.Fatal("accepted upscale_scale combined with diffusion_model")
|
||||
}
|
||||
|
||||
savedLoadModel := LoadModel
|
||||
defer func() { LoadModel = savedLoadModel }()
|
||||
loads := 0
|
||||
diffusion := -1
|
||||
LoadModel = func(_ string, _ string, _ []uintptr, _ int32, diffusionModel int) int {
|
||||
loads++
|
||||
diffusion = diffusionModel
|
||||
return 0
|
||||
}
|
||||
if err := (&SDGGML{}).Load(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"diffusion_model"}}); err != nil {
|
||||
t.Fatalf("Load without upscale_scale: %v", err)
|
||||
}
|
||||
if loads != 1 || diffusion != 1 {
|
||||
t.Fatalf("Load without upscale_scale did not use diffusion path: loads=%d diffusion=%d", loads, diffusion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpscaleSettingsFullFrameTile(t *testing.T) {
|
||||
root := upscaleTestRoot(t)
|
||||
for _, options := range [][]string{
|
||||
{"upscale_scale:4"},
|
||||
{"upscale_scale:4", "upscale_tile_size:0"},
|
||||
} {
|
||||
got, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: options})
|
||||
if err != nil {
|
||||
t.Fatalf("parseUpscaleSettings(%v): %v", options, err)
|
||||
}
|
||||
if got.tile != 0 {
|
||||
t.Fatalf("tile for %v = %d, want full-frame 0", options, got.tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpscaleSettingsPathContainment(t *testing.T) {
|
||||
root := upscaleTestRoot(t)
|
||||
if err := os.Mkdir(filepath.Join(root, "nested"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "nested/model.gguf"), []byte("model"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, modelFile, want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"safe nested relative path", "nested/model.gguf", filepath.Join(root, "nested/model.gguf"), false},
|
||||
{"relative traversal", "../outside.gguf", "", true},
|
||||
{"allowed in-root absolute path", filepath.Join(root, "model.gguf"), filepath.Join(root, "model.gguf"), false},
|
||||
{"outside absolute path", "/other/model.gguf", "", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: tc.modelFile, Options: []string{"upscale_scale:4"}})
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("accepted path outside model path")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.path != tc.want {
|
||||
t.Fatalf("path = %q, want %q", got.path, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpscaleSettingsRejectsDuplicateSettings(t *testing.T) {
|
||||
root := upscaleTestRoot(t)
|
||||
for _, setting := range []string{"upscale_scale:4", "upscale_tile_size:128", "upscale_direct:true", "backend:Vulkan", "params_backend:CPU"} {
|
||||
t.Run(setting, func(t *testing.T) {
|
||||
_, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"upscale_scale:4", setting, setting}})
|
||||
if err == nil {
|
||||
t.Fatalf("accepted duplicate %s", setting)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func upscaleTestRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "model.gguf"), []byte("model"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestUpscaleSettingsRealPathContainment(t *testing.T) {
|
||||
root := upscaleTestRoot(t)
|
||||
outside := upscaleTestRoot(t)
|
||||
for _, tc := range []struct {
|
||||
name, target string
|
||||
wantErr bool
|
||||
}{
|
||||
{"in-root symlink", filepath.Join(root, "model.gguf"), false},
|
||||
{"outside symlink", filepath.Join(outside, "model.gguf"), true},
|
||||
{"dangling symlink", filepath.Join(root, "missing.gguf"), true},
|
||||
{"outside directory symlink", outside, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
link := filepath.Join(root, tc.name)
|
||||
if err := os.Symlink(tc.target, link); err != nil {
|
||||
t.Skipf("symlink creation unsupported: %v", err)
|
||||
}
|
||||
file := link
|
||||
if tc.name == "outside directory symlink" {
|
||||
file = filepath.Join(link, "model.gguf")
|
||||
}
|
||||
got, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: file, Options: []string{"upscale_scale:4"}})
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("accepted unsafe symlink")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.path != tc.target {
|
||||
t.Fatalf("cached path %q, want real path %q", got.path, tc.target)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("symlinked trusted root", func(t *testing.T) {
|
||||
link := filepath.Join(t.TempDir(), "root")
|
||||
if err := os.Symlink(root, link); err != nil {
|
||||
t.Skipf("symlink creation unsupported: %v", err)
|
||||
}
|
||||
got, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: link, ModelFile: "model.gguf", Options: []string{"upscale_scale:4"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.path != filepath.Join(root, "model.gguf") {
|
||||
t.Fatalf("not resolved: %q", got.path)
|
||||
}
|
||||
})
|
||||
for _, file := range []string{"missing.gguf", "."} {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
if _, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: file, Options: []string{"upscale_scale:4"}}); err == nil {
|
||||
t.Fatal("accepted missing or non-file model")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ func main() {
|
||||
}
|
||||
|
||||
libFuncs := []LibFuncs{
|
||||
{&UpscalerCreate, "gosd_upscaler_create"},
|
||||
{&UpscalerScale, "gosd_upscaler_scale"},
|
||||
{&UpscalerRun, "gosd_upscaler_run"},
|
||||
{&UpscalerDestroy, "gosd_upscaler_destroy"},
|
||||
{&LoadModel, "load_model"},
|
||||
{&GenImage, "gen_image"},
|
||||
{&GenVideo, "gen_video"},
|
||||
|
||||
@@ -451,3 +451,21 @@ var _ = Describe("effectiveThreads", func() {
|
||||
Expect(effectiveThreads(config.ModelConfig{}, 0)).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("grpcModelOpts option forwarding", func() {
|
||||
It("preserves explicit upscale backend options exactly", func() {
|
||||
threads := 1
|
||||
options := []string{"upscale_scale:2", "upscale_scale:8", "upscale_tile_size:-1", "backend:CPU"}
|
||||
cfg := config.ModelConfig{Threads: &threads, Backend: "stablediffusion-ggml", KnownUsecases: config.GetUsecasesFromYAML([]string{"upscale", "image"}), Options: options}
|
||||
Expect(grpcModelOpts(cfg, "/models").Options).To(Equal(options))
|
||||
Expect(cfg.Options).To(Equal(options))
|
||||
})
|
||||
|
||||
It("leaves generic SD options unchanged", func() {
|
||||
threads := 1
|
||||
cfg := config.ModelConfig{Threads: &threads, Backend: "stablediffusion-ggml", Options: []string{"backend:CPU", "diffusion_model:model.gguf"}}
|
||||
Expect(grpcModelOpts(cfg, "/models").Options).To(Equal(cfg.Options))
|
||||
cfg.KnownUsecases = config.GetUsecasesFromYAML([]string{"image"})
|
||||
Expect(grpcModelOpts(cfg, "/models").Options).To(Equal(cfg.Options))
|
||||
})
|
||||
})
|
||||
+15
-2
@@ -2,6 +2,7 @@ package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
@@ -24,7 +25,7 @@ func ImageUpscale(ctx context.Context, src, dst string, scale int, loader *model
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
_, err = inferenceModel.UpscaleImage(
|
||||
result, err := inferenceModel.UpscaleImage(
|
||||
ctx,
|
||||
&proto.UpscaleImageRequest{
|
||||
Src: src,
|
||||
@@ -32,7 +33,19 @@ func ImageUpscale(ctx context.Context, src, dst string, scale int, loader *model
|
||||
Scale: int32(scale),
|
||||
},
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return fmt.Errorf("image upscale returned nil result")
|
||||
}
|
||||
if !result.Success {
|
||||
if result.Message != "" {
|
||||
return fmt.Errorf("image upscale failed: %s", result.Message)
|
||||
}
|
||||
return fmt.Errorf("image upscale failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn, nil
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package backend_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
ggrpc "google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type upscaleBackend struct {
|
||||
grpcPkg.Backend
|
||||
result *pb.Result
|
||||
err error
|
||||
request *pb.UpscaleImageRequest
|
||||
}
|
||||
|
||||
func (*upscaleBackend) HealthCheck(context.Context) (bool, error) { return true, nil }
|
||||
func (*upscaleBackend) IsBusy() bool { return false }
|
||||
func (b *upscaleBackend) UpscaleImage(_ context.Context, in *pb.UpscaleImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
|
||||
b.request = in
|
||||
return b.result, b.err
|
||||
}
|
||||
|
||||
func TestImageUpscaleResult(t *testing.T) {
|
||||
transportErr := errors.New("transport failed")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
result *pb.Result
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "nil", want: "nil"},
|
||||
{name: "unsuccessful", result: &pb.Result{}, want: "failed"},
|
||||
{name: "message", result: &pb.Result{Message: "native mismatch"}, want: "native mismatch"},
|
||||
{name: "transport", err: transportErr, want: "transport failed"},
|
||||
{name: "success", result: &pb.Result{Success: true}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fake := &upscaleBackend{result: tc.result, err: tc.err}
|
||||
loader := model.NewModelLoader(&system.SystemState{})
|
||||
loader.SetModelRouter(func(_ context.Context, id string, _, _, _, _ string, _ *pb.ModelOptions, _ bool) (*model.Model, error) {
|
||||
return model.NewModelWithClient(id, "test://upscale", fake), nil
|
||||
})
|
||||
cfg := config.ModelConfig{Name: "upscale", Backend: "stub"}
|
||||
cfg.SetDefaults()
|
||||
fn, err := backend.ImageUpscale(context.Background(), "source", "destination", 3, loader, cfg, config.NewApplicationConfig(config.WithSystemState(&system.SystemState{})))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = fn()
|
||||
if tc.want == "" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.want)
|
||||
}
|
||||
if tc.err != nil && !errors.Is(err, tc.err) {
|
||||
t.Fatalf("lost transport error: %v", err)
|
||||
}
|
||||
if fake.request == nil || fake.request.Scale != 3 || fake.request.Src != "source" || fake.request.Dst != "destination" {
|
||||
t.Fatalf("unexpected request: %v", fake.request)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
UsecaseEmbeddings = "embeddings"
|
||||
UsecaseTokenize = "tokenize"
|
||||
UsecaseImage = "image"
|
||||
UsecaseUpscale = "upscale"
|
||||
UsecaseVideo = "video"
|
||||
Usecase3D = "3d"
|
||||
Usecase3DAnimation = "3d_animation"
|
||||
@@ -122,6 +123,11 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
|
||||
GRPCMethod: MethodTokenizeString,
|
||||
Description: "Tokenization via the TokenizeString RPC without running inference.",
|
||||
},
|
||||
UsecaseUpscale: {
|
||||
Flag: FLAG_UPSCALE,
|
||||
GRPCMethod: MethodUpscaleImage,
|
||||
Description: "Image upscaling via the UpscaleImage RPC.",
|
||||
},
|
||||
UsecaseImage: {
|
||||
Flag: FLAG_IMAGE,
|
||||
GRPCMethod: MethodGenerateImage,
|
||||
@@ -406,7 +412,7 @@ var BackendCapabilities = map[string]BackendCapability{
|
||||
// --- Image/video generation backends ---
|
||||
"diffusers": {
|
||||
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodUpscaleImage, MethodGenerateVideo, MethodSoundGeneration},
|
||||
PossibleUsecases: []string{UsecaseImage, UsecaseVideo, UsecaseSoundGeneration},
|
||||
PossibleUsecases: []string{UsecaseImage, UsecaseUpscale, UsecaseVideo, UsecaseSoundGeneration},
|
||||
DefaultUsecases: []string{UsecaseImage},
|
||||
Description: "HuggingFace diffusers — image, video, and sound generation",
|
||||
},
|
||||
@@ -432,8 +438,8 @@ var BackendCapabilities = map[string]BackendCapability{
|
||||
Description: "Stable Diffusion native backend",
|
||||
},
|
||||
"stablediffusion-ggml": {
|
||||
GRPCMethods: []GRPCMethod{MethodGenerateImage},
|
||||
PossibleUsecases: []string{UsecaseImage},
|
||||
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodUpscaleImage},
|
||||
PossibleUsecases: []string{UsecaseImage, UsecaseUpscale},
|
||||
DefaultUsecases: []string{UsecaseImage},
|
||||
Description: "Stable Diffusion via GGML quantized models",
|
||||
},
|
||||
|
||||
@@ -445,3 +445,15 @@ var _ = Describe("AllBackendNames", func() {
|
||||
Expect(slices.IsSorted(names)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Upscale backend declaration", func() {
|
||||
It("maps upscale to its dedicated RPC and advertises SD GGML support", func() {
|
||||
info := UsecaseInfoMap[UsecaseUpscale]
|
||||
Expect(info.Flag).To(Equal(FLAG_UPSCALE))
|
||||
Expect(info.GRPCMethod).To(Equal(MethodUpscaleImage))
|
||||
cap := GetBackendCapability("stablediffusion-ggml")
|
||||
Expect(cap.GRPCMethods).To(ContainElement(MethodUpscaleImage))
|
||||
Expect(cap.PossibleUsecases).To(ContainElement(UsecaseUpscale))
|
||||
Expect(cap.DefaultUsecases).NotTo(ContainElement(UsecaseUpscale))
|
||||
})
|
||||
})
|
||||
@@ -62,6 +62,7 @@ var UsecaseOptions = []FieldOption{
|
||||
{Value: "rerank", Label: "Rerank"},
|
||||
{Value: "score", Label: "Score (Router Classifier)"},
|
||||
{Value: "image", Label: "Image"},
|
||||
{Value: "upscale", Label: "Image upscaling"},
|
||||
{Value: "vision", Label: "Vision"},
|
||||
{Value: "detection", Label: "Detection"},
|
||||
{Value: "depth", Label: "Depth"},
|
||||
|
||||
@@ -85,3 +85,15 @@ var _ = Describe("Decisions router metadata", func() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("backend-only upscale options", func() {
|
||||
It("does not expose scale or tile size as generic root configuration", func() {
|
||||
reg := meta.DefaultRegistry()
|
||||
Expect(reg).NotTo(HaveKey("upscale_scale"))
|
||||
Expect(reg).NotTo(HaveKey("upscale_tile_size"))
|
||||
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), reg)
|
||||
for _, field := range md.Fields {
|
||||
Expect(field.Path).NotTo(BeElementOf("upscale_scale", "upscale_tile_size"))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -162,6 +162,7 @@ func (c *ModelConfig) Capabilities() []string {
|
||||
add(c.HasUsecases(FLAG_TTS), UsecaseTTS)
|
||||
add(c.HasUsecases(FLAG_SOUND_GENERATION), UsecaseSoundGeneration)
|
||||
add(c.HasUsecases(FLAG_IMAGE), UsecaseImage)
|
||||
add(c.HasUsecases(FLAG_UPSCALE), UsecaseUpscale)
|
||||
add(c.HasUsecases(FLAG_VIDEO), UsecaseVideo)
|
||||
add(c.HasUsecases(FLAG_3D), Usecase3D)
|
||||
add(c.HasUsecases(FLAG_3D_ANIMATION), Usecase3DAnimation)
|
||||
@@ -207,7 +208,7 @@ func (c *ModelConfig) InputModalities() []string {
|
||||
// Ollama surface); detection/depth/face/3D models consume images directly.
|
||||
imageIn := (chatish && c.VisionSupported()) || c.LimitMMPerPrompt.LimitImagePerPrompt > 0 ||
|
||||
c.HasUsecases(FLAG_DETECTION) || c.HasUsecases(FLAG_DEPTH) || c.HasUsecases(FLAG_FACE_RECOGNITION) ||
|
||||
c.HasUsecases(FLAG_3D)
|
||||
c.HasUsecases(FLAG_3D) || c.HasUsecases(FLAG_UPSCALE)
|
||||
|
||||
audioIn := c.AudioInputSupported() || c.HasUsecases(FLAG_TRANSCRIPT) || c.HasUsecases(FLAG_AUDIO_TRANSFORM) ||
|
||||
c.HasUsecases(FLAG_REALTIME_AUDIO) || c.HasUsecases(FLAG_VAD) || c.HasUsecases(FLAG_DIARIZATION) ||
|
||||
@@ -228,7 +229,7 @@ func (c *ModelConfig) OutputModalities() []string {
|
||||
modalities := declaredModalities(c.KnownOutputModalities)
|
||||
textOut := c.HasUsecases(FLAG_CHAT) || c.HasUsecases(FLAG_COMPLETION) || c.HasUsecases(FLAG_EDIT) ||
|
||||
c.HasUsecases(FLAG_TRANSCRIPT)
|
||||
imageOut := c.HasUsecases(FLAG_IMAGE)
|
||||
imageOut := c.HasUsecases(FLAG_IMAGE) || c.HasUsecases(FLAG_UPSCALE)
|
||||
audioOut := c.HasUsecases(FLAG_TTS) || c.HasUsecases(FLAG_SOUND_GENERATION) ||
|
||||
c.HasUsecases(FLAG_AUDIO_TRANSFORM) || c.HasUsecases(FLAG_REALTIME_AUDIO)
|
||||
videoOut := c.HasUsecases(FLAG_VIDEO)
|
||||
|
||||
@@ -207,3 +207,17 @@ var _ = Describe("Model capabilities derivation", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Upscale capabilities", func() {
|
||||
It("keeps dedicated upscalers image-only without guessing generation", func() {
|
||||
for _, backend := range []string{"stablediffusion-ggml", "llama-cpp", ""} {
|
||||
cfg := &ModelConfig{Backend: backend, KnownUsecases: GetUsecasesFromYAML([]string{"upscale"})}
|
||||
Expect(cfg.Capabilities()).To(Equal([]string{UsecaseUpscale}))
|
||||
Expect(cfg.InputModalities()).To(Equal([]string{ModalityImage}))
|
||||
Expect(cfg.OutputModalities()).To(Equal([]string{ModalityImage}))
|
||||
Expect(cfg.HasUsecases(FLAG_IMAGE)).To(BeFalse())
|
||||
Expect((&ModelConfig{Backend: backend}).HasUsecases(FLAG_UPSCALE)).To(BeFalse())
|
||||
}
|
||||
Expect(*GetUsecasesFromYAML([]string{"FLAG_UPSCALE"})).To(Equal(FLAG_UPSCALE))
|
||||
})
|
||||
})
|
||||
@@ -2088,6 +2088,9 @@ const (
|
||||
// pickers it cannot serve.
|
||||
FLAG_DECISIONS ModelConfigUsecase = 1 << 25
|
||||
|
||||
// Marks a model as an image upscaler served by the UpscaleImage RPC.
|
||||
FLAG_UPSCALE ModelConfigUsecase = 1 << 26
|
||||
|
||||
// Common Subsets
|
||||
FLAG_LLM ModelConfigUsecase = FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT
|
||||
)
|
||||
@@ -2151,6 +2154,7 @@ func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
|
||||
"FLAG_3D": FLAG_3D,
|
||||
"FLAG_3D_ANIMATION": FLAG_3D_ANIMATION,
|
||||
"FLAG_DECISIONS": FLAG_DECISIONS,
|
||||
"FLAG_UPSCALE": FLAG_UPSCALE,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2179,7 +2183,7 @@ func GetUsecasesFromYAML(input []string) *ModelConfigUsecase {
|
||||
//
|
||||
// Declared known_usecases are normally additive — the guessing heuristic
|
||||
// still adds whatever it can infer from backend/templates. The exceptions
|
||||
// are FLAG_SCORE, FLAG_TOKEN_CLASSIFY and FLAG_DECISIONS: when the operator
|
||||
// are FLAG_SCORE, FLAG_TOKEN_CLASSIFY, FLAG_DECISIONS and FLAG_UPSCALE: when the operator
|
||||
// declared any of them, they reserved the model for a direct-decode primitive
|
||||
// (the router classifier, the PII NER tier, or a decision head). Letting GuessUsecases
|
||||
// paint chat/completion/embeddings on top would surface it in pickers it
|
||||
@@ -2191,7 +2195,7 @@ func (c *ModelConfig) HasUsecases(u ModelConfigUsecase) bool {
|
||||
if (u & *c.KnownUsecases) == u {
|
||||
return true
|
||||
}
|
||||
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY | FLAG_DECISIONS)) != 0 {
|
||||
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY | FLAG_DECISIONS | FLAG_UPSCALE)) != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2202,6 +2206,10 @@ func (c *ModelConfig) HasUsecases(u ModelConfigUsecase) bool {
|
||||
// In its current state, this function should ideally check for properties of the config like templates, rather than the direct backend name checks for the lower half.
|
||||
// This avoids the maintenance burden of updating this list for each new backend - but unfortunately, that's the best option for some services currently.
|
||||
func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
|
||||
// Upscaling requires a dedicated model declaration, not just an image backend.
|
||||
if u&FLAG_UPSCALE != 0 {
|
||||
return false
|
||||
}
|
||||
// Backends that are clearly not text-generation
|
||||
nonTextGenBackends := []string{
|
||||
"whisper", "piper", "kokoro",
|
||||
|
||||
@@ -52,6 +52,25 @@ parameters:
|
||||
Expect(valid).To(BeTrue())
|
||||
})
|
||||
|
||||
It("parses upscale backend options and explicit usecase from YAML", func() {
|
||||
raw := []byte(`
|
||||
name: realesrgan
|
||||
backend: stablediffusion-ggml
|
||||
known_usecases: [upscale]
|
||||
options:
|
||||
- upscale_scale:4
|
||||
- upscale_tile_size:128
|
||||
parameters:
|
||||
model: realesrgan-x4plus.gguf
|
||||
`)
|
||||
var cfg ModelConfig
|
||||
Expect(yaml.Unmarshal(raw, &cfg)).To(Succeed())
|
||||
Expect(cfg.Options).To(Equal([]string{"upscale_scale:4", "upscale_tile_size:128"}))
|
||||
Expect(cfg.KnownUsecases).NotTo(BeNil())
|
||||
Expect(*cfg.KnownUsecases & FLAG_UPSCALE).To(Equal(FLAG_UPSCALE))
|
||||
Expect(cfg.KnownUsecaseStrings).To(ContainElement("FLAG_UPSCALE"))
|
||||
})
|
||||
|
||||
It("round-trips context compression settings", func() {
|
||||
raw := []byte(`
|
||||
name: compressed-chat
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UpscaleScaleFromOptions reports the declared native scale for client metadata.
|
||||
// Match the backend's int32 range and reject duplicates rather than advertising
|
||||
// a value that the backend will reject. This does not replace backend validation.
|
||||
func UpscaleScaleFromOptions(c ModelConfig) int {
|
||||
var scale int
|
||||
seen := false
|
||||
for _, option := range c.Options {
|
||||
key, value, _ := strings.Cut(option, ":")
|
||||
if key != "upscale_scale" {
|
||||
continue
|
||||
}
|
||||
if seen {
|
||||
return 0
|
||||
}
|
||||
seen = true
|
||||
n, err := strconv.ParseInt(value, 10, 32)
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
scale = int(n)
|
||||
}
|
||||
return scale
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpscaleScaleFromOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
options []string
|
||||
want int
|
||||
}{
|
||||
{"absent", nil, 0},
|
||||
{"unrelated", []string{"backend:CPU", "upscale_tile_size:128"}, 0},
|
||||
{"valid", []string{"upscale_scale:4"}, 4},
|
||||
{"malformed", []string{"upscale_scale:banana"}, 0},
|
||||
{"missing value", []string{"upscale_scale"}, 0},
|
||||
{"empty", []string{"upscale_scale:"}, 0},
|
||||
{"zero", []string{"upscale_scale:0"}, 0},
|
||||
{"negative", []string{"upscale_scale:-1"}, 0},
|
||||
{"overflow", []string{"upscale_scale:2147483648"}, 0},
|
||||
{"huge", []string{"upscale_scale:999999999999999999999999999"}, 0},
|
||||
{"duplicate", []string{"upscale_scale:4", "upscale_scale:4"}, 0},
|
||||
{"invalid duplicate", []string{"upscale_scale:4", "upscale_scale:bad"}, 0},
|
||||
{"extra separator", []string{"upscale_scale:4:2"}, 0},
|
||||
{"whitespace", []string{"upscale_scale: 4"}, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := ModelConfig{Options: tc.options}
|
||||
if got := UpscaleScaleFromOptions(cfg); got != tc.want {
|
||||
t.Fatalf("got %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package gallery_test
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var _ = Describe("gallery/index.yaml ESRGAN entry", func() {
|
||||
It("installs the official native x4 checkpoint as an upscale-only model", func() {
|
||||
entries, err := loadGalleryIndex()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
entry, ok := indexEntriesByName(entries)["realesrgan-x4plus-anime-6b"]
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(entry.URL).To(Equal("github:mudler/LocalAI/gallery/virtual.yaml@master"))
|
||||
Expect(entry.License).To(Equal("BSD-3-Clause"))
|
||||
Expect(entry.Overrides["backend"]).To(Equal("stablediffusion-ggml"))
|
||||
Expect(entry.Overrides["known_usecases"]).To(ConsistOf("upscale"))
|
||||
Expect(entry.Overrides).NotTo(HaveKey("upscale_scale"))
|
||||
Expect(entry.Overrides).NotTo(HaveKey("upscale_tile_size"))
|
||||
Expect(entry.Overrides["options"]).To(ConsistOf("upscale_scale:4", "upscale_tile_size:128"))
|
||||
Expect(entry.AdditionalFiles).To(HaveLen(1))
|
||||
file := entry.AdditionalFiles[0]
|
||||
Expect(file.Filename).To(Equal("RealESRGAN_x4plus_anime_6B.pth"))
|
||||
Expect(entry.Overrides["parameters"]).To(HaveKeyWithValue("model", file.Filename))
|
||||
Expect(file.URI).To(Equal("https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/" + file.Filename))
|
||||
Expect(file.SHA256).To(Equal("f872d837d3c90ed2e05227bed711af5671a6fd1c9f7d7e91c911a61f155e99da"))
|
||||
|
||||
// Keep the copy-paste configuration in the documentation aligned with the
|
||||
// installable entry, including YAML scalar types.
|
||||
doc, err := os.ReadFile("../../docs/content/features/image-generation.md")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
const start = "```yaml\nname: realesrgan-x4plus-anime-6b\n"
|
||||
_, example, found := strings.Cut(string(doc), start)
|
||||
Expect(found).To(BeTrue())
|
||||
example, _, found = strings.Cut(example, "```")
|
||||
Expect(found).To(BeTrue())
|
||||
var settings map[string]any
|
||||
Expect(yaml.Unmarshal([]byte(example), &settings)).To(Succeed())
|
||||
Expect(settings).To(Equal(entry.Overrides))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Diffusers upscaler gallery capability", func() {
|
||||
It("explicitly declares upscaling rather than image generation", func() {
|
||||
entries, err := loadGalleryIndex()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
entry, ok := indexEntriesByName(entries)["stable-diffusion-x4-upscaler"]
|
||||
Expect(ok).To(BeTrue())
|
||||
data, err := yaml.Marshal(entry.Overrides)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
var cfg config.ModelConfig
|
||||
Expect(yaml.Unmarshal(data, &cfg)).To(Succeed())
|
||||
Expect(cfg.HasUsecases(config.FLAG_UPSCALE)).To(BeTrue())
|
||||
Expect(cfg.HasUsecases(config.FLAG_IMAGE)).To(BeFalse())
|
||||
Expect(config.GetBackendCapability(cfg.Backend).GRPCMethods).To(ContainElement(config.MethodUpscaleImage))
|
||||
})
|
||||
})
|
||||
@@ -44,6 +44,7 @@ func ListModelCapabilitiesEndpoint(bcl *config.ModelConfigLoader, ml *model.Mode
|
||||
cfg.ContextSize = &appConfig.ContextSize
|
||||
}
|
||||
entry.Capabilities = cfg.Capabilities()
|
||||
entry.UpscaleScale = config.UpscaleScaleFromOptions(cfg)
|
||||
// Generation aliases inherit target capabilities, but the router's
|
||||
// native classifier loads the named config directly (no alias resolution).
|
||||
original, exists := bcl.GetModelConfig(m)
|
||||
|
||||
@@ -98,6 +98,31 @@ var _ = Describe("ListModelCapabilitiesEndpoint", func() {
|
||||
}
|
||||
})
|
||||
|
||||
It("exposes lowercase upscale and backend option scale without text modalities", func() {
|
||||
writeConfig("upscaler", "name: upscaler\nbackend: stablediffusion-ggml\nknown_usecases: [upscale]\noptions:\n - upscale_scale:4\n")
|
||||
entry := entryFor(call(), "upscaler")
|
||||
Expect(entry).NotTo(BeNil())
|
||||
Expect(entry.Capabilities).To(Equal([]string{"upscale"}))
|
||||
Expect(entry.InputModalities).To(Equal([]string{"image"}))
|
||||
Expect(entry.OutputModalities).To(Equal([]string{"image"}))
|
||||
Expect(entry.UpscaleScale).To(Equal(4))
|
||||
raw, err := json.Marshal(entry)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(raw)).To(ContainSubstring(`"upscale_scale":4`))
|
||||
})
|
||||
|
||||
It("omits absent or invalid backend scale metadata", func() {
|
||||
for _, options := range []string{"", "options: [upscale_scale:bad]\n", "options: [upscale_scale:0]\n", "options: [upscale_scale:-1]\n", "options: [upscale_scale:2147483648]\n", "options: [upscale_scale:4, upscale_scale:4]\n"} {
|
||||
writeConfig("upscaler", "name: upscaler\nbackend: stablediffusion-ggml\nknown_usecases: [upscale]\n"+options)
|
||||
entry := entryFor(call(), "upscaler")
|
||||
Expect(entry).NotTo(BeNil())
|
||||
Expect(entry.UpscaleScale).To(BeZero())
|
||||
raw, err := json.Marshal(entry)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(raw)).NotTo(ContainSubstring(`"upscale_scale"`))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns the list envelope even with no models", func() {
|
||||
resp := call()
|
||||
Expect(resp.Object).To(Equal("list"))
|
||||
|
||||
@@ -21,6 +21,10 @@ import (
|
||||
model "github.com/mudler/LocalAI/pkg/model"
|
||||
)
|
||||
|
||||
// maxUpscaleScale limits output area amplification to 256x and fits the int32
|
||||
// protobuf field. The backend still enforces the model's exact native scale.
|
||||
const maxUpscaleScale = 16
|
||||
|
||||
// UpscaleEndpoint handles POST /v1/images/upscale
|
||||
//
|
||||
// @Summary Image upscaling
|
||||
@@ -30,7 +34,7 @@ import (
|
||||
// @Produce application/json
|
||||
// @Param model formData string true "Upscaler model identifier (e.g. stable-diffusion-x4-upscaler)"
|
||||
// @Param image formData file true "Input image file"
|
||||
// @Param scale formData int false "Upscale factor: 2 or 4 (default 2)"
|
||||
// @Param scale formData int true "Upscale factor: integer from 1 to 16; must match the model native scale"
|
||||
// @Success 200 {object} schema.OpenAIResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
@@ -45,13 +49,12 @@ func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appCon
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "missing model")
|
||||
}
|
||||
|
||||
scale := 2
|
||||
if scaleStr != "" {
|
||||
v, err := strconv.Atoi(scaleStr)
|
||||
if err != nil || (v != 2 && v != 4) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "scale must be 2 or 4")
|
||||
}
|
||||
scale = v
|
||||
if scaleStr == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "missing scale")
|
||||
}
|
||||
scale, err := strconv.Atoi(scaleStr)
|
||||
if err != nil || scale < 1 || scale > maxUpscaleScale {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "scale must be an integer from 1 to 16")
|
||||
}
|
||||
|
||||
// Read uploaded image
|
||||
@@ -103,19 +106,26 @@ func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appCon
|
||||
id := uuid.New().String()
|
||||
dstPath := filepath.Join(tmpDir, fmt.Sprintf("upscale_%s.png", id))
|
||||
|
||||
// Only this endpoint owns the generated destination; the generic backend
|
||||
// must not delete arbitrary caller-supplied paths on failure.
|
||||
completed := false
|
||||
defer func() {
|
||||
if !completed {
|
||||
_ = os.Remove(dstPath)
|
||||
}
|
||||
}()
|
||||
|
||||
fn, err := backend.ImageUpscaleFunc(c.Request().Context(), srcPath, dstPath, scale, ml, *cfg, appConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL := middleware.BaseURL(c)
|
||||
imgURL, err := url.JoinPath(baseURL, "generated-images", filepath.Base(dstPath))
|
||||
if err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -129,6 +139,10 @@ func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appCon
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
if err := c.JSON(http.StatusOK, resp); err != nil {
|
||||
return err
|
||||
}
|
||||
completed = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
ggrpc "google.golang.org/grpc"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
@@ -29,7 +34,7 @@ var _ = Describe("Image upscaling", func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "upscale")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
appConfig = config.NewApplicationConfig(config.WithGeneratedContentDir(tmpDir))
|
||||
appConfig = config.NewApplicationConfig(config.WithGeneratedContentDir(tmpDir), config.WithSystemState(&system.SystemState{}))
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
@@ -39,7 +44,7 @@ var _ = Describe("Image upscaling", func() {
|
||||
It("stores the result in the directory served by /generated-images", func() {
|
||||
original := backend.ImageUpscaleFunc
|
||||
backend.ImageUpscaleFunc = func(_ context.Context, _, dst string, scale int, _ *model.ModelLoader, _ config.ModelConfig, _ *config.ApplicationConfig) (func() error, error) {
|
||||
Expect(scale).To(Equal(4))
|
||||
Expect(scale).To(Equal(3))
|
||||
return func() error {
|
||||
return os.WriteFile(dst, []byte("PNGDATA"), 0o644)
|
||||
}, nil
|
||||
@@ -47,7 +52,7 @@ var _ = Describe("Image upscaling", func() {
|
||||
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
|
||||
|
||||
req, _ := makeMultipartRequest(
|
||||
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "4"},
|
||||
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "3"},
|
||||
map[string][]byte{"image": []byte("IMAGEDATA")},
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -66,24 +71,92 @@ var _ = Describe("Image upscaling", func() {
|
||||
contents, err := os.ReadFile(filepath.Join(tmpDir, "images", filename))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(contents).To(Equal([]byte("PNGDATA")))
|
||||
entries, err := os.ReadDir(filepath.Join(tmpDir, "images"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(entries).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("rejects unsupported scale factors", func() {
|
||||
req, _ := makeMultipartRequest(
|
||||
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "3"},
|
||||
map[string][]byte{"image": []byte("IMAGEDATA")},
|
||||
)
|
||||
DescribeTable("rejects missing or invalid scales", func(scale string) {
|
||||
fields := map[string]string{"model": "upscaler"}
|
||||
if scale != "missing" {
|
||||
fields["scale"] = scale
|
||||
}
|
||||
req, _ := makeMultipartRequest(fields, map[string][]byte{"image": []byte("IMAGE")})
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := echo.New().NewContext(req, rec)
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
|
||||
|
||||
e := echo.New()
|
||||
ctx := e.NewContext(req, rec)
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{})
|
||||
err := UpscaleEndpoint(nil, nil, appConfig)(ctx)
|
||||
var httpErr *echo.HTTPError
|
||||
Expect(err).To(MatchError(ContainSubstring("scale must be 2 or 4")))
|
||||
Expect(err).To(BeAssignableToTypeOf(httpErr))
|
||||
httpErr = err.(*echo.HTTPError)
|
||||
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(httpErr.Message).To(Equal("scale must be 2 or 4"))
|
||||
Expect(bytes.TrimSpace(rec.Body.Bytes())).To(BeEmpty())
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
e.HTTPErrorHandler(err, ctx)
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
},
|
||||
Entry("missing", "missing"), Entry("empty", ""), Entry("text", "abc"),
|
||||
Entry("fraction", "2.5"), Entry("zero", "0"), Entry("negative", "-1"),
|
||||
// 16 caps area amplification at 256x; int32 overflow must also fail.
|
||||
Entry("above resource bound", "17"), Entry("int32 overflow", "2147483648"),
|
||||
Entry("integer overflow", "999999999999999999999999"))
|
||||
|
||||
DescribeTable("cleans only request files on failure", func(outcome string) {
|
||||
sentinel := filepath.Join(tmpDir, "unrelated.png")
|
||||
Expect(os.WriteFile(sentinel, []byte("keep"), 0600)).To(Succeed())
|
||||
var srcPath, dstPath string
|
||||
original := backend.ImageUpscaleFunc
|
||||
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
|
||||
backend.ImageUpscaleFunc = func(ctx context.Context, src, dst string, scale int, _ *model.ModelLoader, cfg config.ModelConfig, app *config.ApplicationConfig) (func() error, error) {
|
||||
srcPath, dstPath = src, dst
|
||||
Expect(os.WriteFile(dst, []byte("partial"), 0600)).To(Succeed())
|
||||
if outcome == "load" {
|
||||
return nil, errors.New("load failed")
|
||||
}
|
||||
fake := &upscaleTestBackend{outcome: outcome}
|
||||
loader := model.NewModelLoader(&system.SystemState{})
|
||||
loader.SetModelRouter(func(_ context.Context, id string, _, _, _, _ string, _ *pb.ModelOptions, _ bool) (*model.Model, error) {
|
||||
return model.NewModelWithClient(id, "test://upscale", fake), nil
|
||||
})
|
||||
return backend.ImageUpscale(ctx, src, dst, scale, loader, cfg, app)
|
||||
}
|
||||
req, _ := makeMultipartRequest(map[string]string{"model": "upscaler", "scale": strconv.Itoa(16)}, map[string][]byte{"image": []byte("IMAGE")})
|
||||
rec := httptest.NewRecorder()
|
||||
e := echo.New()
|
||||
ctx := e.NewContext(req, rec)
|
||||
cfg := &config.ModelConfig{Name: "upscaler", Backend: "stub"}
|
||||
cfg.SetDefaults()
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
|
||||
err := UpscaleEndpoint(nil, nil, appConfig)(ctx)
|
||||
Expect(err).To(HaveOccurred())
|
||||
if outcome == "unsuccessful" {
|
||||
Expect(err.Error()).To(ContainSubstring("native scale mismatch"))
|
||||
}
|
||||
e.HTTPErrorHandler(err, ctx)
|
||||
Expect(rec.Code).To(Equal(http.StatusInternalServerError))
|
||||
Expect(srcPath).NotTo(BeEmpty())
|
||||
Expect(dstPath).NotTo(BeEmpty())
|
||||
_, err = os.Stat(srcPath)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
_, err = os.Stat(dstPath)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
contents, err := os.ReadFile(sentinel)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(contents)).To(Equal("keep"))
|
||||
}, Entry("model loading", "load"), Entry("transport", "transport"), Entry("nil result", "nil"), Entry("unsuccessful result", "unsuccessful"))
|
||||
})
|
||||
|
||||
type upscaleTestBackend struct {
|
||||
grpcPkg.Backend
|
||||
outcome string
|
||||
}
|
||||
|
||||
func (*upscaleTestBackend) HealthCheck(context.Context) (bool, error) { return true, nil }
|
||||
func (*upscaleTestBackend) IsBusy() bool { return false }
|
||||
func (b *upscaleTestBackend) UpscaleImage(_ context.Context, in *pb.UpscaleImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
|
||||
Expect(in.Scale).To(Equal(int32(16)))
|
||||
switch b.outcome {
|
||||
case "transport":
|
||||
return nil, errors.New("transport failed")
|
||||
case "nil":
|
||||
return nil, nil
|
||||
default:
|
||||
return &pb.Result{Success: false, Message: "native scale mismatch"}, nil
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -54,11 +54,13 @@ export const GPU_RESOURCES = {
|
||||
}
|
||||
|
||||
// Stub everything the front page reads. `types` are the types that have a
|
||||
// model. `installs` collects the names POSTed to the install endpoint; after an
|
||||
// install the type gains its model on the next capabilities read when
|
||||
// `installOnPost` is set.
|
||||
// model; `capabilityModels` supplies complete capability records when a test
|
||||
// needs fields beyond a model type. `installs` collects the names POSTed to the
|
||||
// install endpoint; after an install the type gains its model on the next
|
||||
// capabilities read when `installOnPost` is set.
|
||||
export async function mockStudio(page, {
|
||||
types = ALL_TYPES,
|
||||
capabilityModels,
|
||||
resources = GPU_RESOURCES,
|
||||
operations = [],
|
||||
installOnPost = false,
|
||||
@@ -69,7 +71,7 @@ export async function mockStudio(page, {
|
||||
await page.route('**/api/models/capabilities', route => {
|
||||
state.capabilityCalls += 1
|
||||
if (capabilitiesStatus !== 200) return route.fulfill({ status: capabilitiesStatus, json: { error: 'down' } })
|
||||
return route.fulfill({ json: capabilities(state.types) })
|
||||
return route.fulfill({ json: { data: capabilityModels || capabilities(state.types).data } })
|
||||
})
|
||||
// The v1 list is the fallback useModels takes when the capabilities call
|
||||
// fails; make it fail too so an error state is reachable.
|
||||
|
||||
@@ -243,3 +243,81 @@ test.describe('Lineage view: phone', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Inline upscale', () => {
|
||||
async function setup(page, { models = true, failure } = {}) {
|
||||
await mockStudio(page, { capabilityModels: models ? [
|
||||
{ id: 'up-4', capabilities: ['FLAG_UPSCALE'], upscaleScale: 4 },
|
||||
{ id: 'up-2', capabilities: ['FLAG_UPSCALE'], upscaleScale: 2 },
|
||||
{ id: 'invalid', capabilities: ['FLAG_UPSCALE'], upscaleScale: 0 },
|
||||
{ id: 'text-image', capabilities: ['FLAG_IMAGE'] },
|
||||
] : [] })
|
||||
await stubMedia(page)
|
||||
const history = sampleHistory()
|
||||
if (failure === 'cross-origin') history.image.find(e => e.id === 'img-bowls').results[0].url = 'https://other.test/source.png'
|
||||
await seedHistory(page, history)
|
||||
if (['fetch', 'non-image', 'source-decode'].includes(failure)) {
|
||||
await page.route('**/generated-images/bowls.png', route => route.fulfill({
|
||||
status: failure === 'fetch' ? 500 : 200,
|
||||
contentType: failure === 'non-image' ? 'text/html' : 'image/png', body: 'bad image',
|
||||
}))
|
||||
}
|
||||
let requests = 0
|
||||
await page.route('**/v1/images/upscale', async route => {
|
||||
requests++
|
||||
const request = route.request()
|
||||
expect(request.headers()['content-type']).toContain('multipart/form-data; boundary=')
|
||||
const body = request.postDataBuffer().toString()
|
||||
expect(body).toContain('name="model"\r\n\r\nup-2')
|
||||
expect(body).toContain('name="scale"\r\n\r\n2')
|
||||
expect(body).toContain('name="image"; filename="bowls.png"')
|
||||
await route.fulfill({ status: failure === 'api' ? 500 : 200, json: failure === 'api'
|
||||
? { error: 'upscale unavailable' }
|
||||
: { data: [{ url: '/generated-images/upscaled.png', width: 99999, height: 99999 }] } })
|
||||
})
|
||||
if (failure === 'result-decode') await page.route('**/generated-images/upscaled.png', route => route.fulfill({ contentType: 'image/png', body: 'broken' }))
|
||||
await page.goto('/app/studio?work=img-bowls')
|
||||
await page.getByTestId('studio-dock-toggle').click()
|
||||
return () => requests
|
||||
}
|
||||
|
||||
test('absent without an eligible model', async ({ page }) => {
|
||||
await setup(page, { models: false })
|
||||
await expect(page.getByTestId('step-upscale')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('filtered models, fixed scale, multipart and a persisted image child with repeat suggestions', async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.getByTestId('step-upscale').click()
|
||||
const composer = page.getByTestId('upscale-composer')
|
||||
await expect(composer.locator('img')).toBeVisible()
|
||||
await expect(composer.locator('option')).toHaveText(['up-4', 'up-2'])
|
||||
await composer.getByLabel('Upscaling model').selectOption('up-2')
|
||||
await expect(composer.getByLabel('Scale', { exact: true })).toHaveValue('2×')
|
||||
await expect(composer.getByLabel('Scale', { exact: true })).toHaveAttribute('readonly', '')
|
||||
await composer.getByRole('button', { name: 'Upscale', exact: true }).click()
|
||||
await expect(page.getByTestId('lineage-node')).toHaveCount(2)
|
||||
for (const edge of ['animate', 'to-3d', 'variation', 'upscale']) await expect(page.getByTestId(`step-${edge}`)).toBeVisible()
|
||||
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('localai_image_history')).find(e => e.edge === 'upscale'))).toMatchObject({
|
||||
parentId: 'img-bowls', model: 'up-2', params: { scale: 2, sourceDimensions: { width: 640, height: 420 }, outputDimensions: { width: 640, height: 420 } },
|
||||
})
|
||||
await expect(page.locator('.studio-edge-label')).toContainText(['upscale'])
|
||||
await page.getByTestId('step-upscale').click()
|
||||
await expect(page.getByTestId('upscale-composer').locator('img')).toHaveAttribute('src', '/generated-images/upscaled.png')
|
||||
})
|
||||
|
||||
for (const failure of ['cross-origin', 'non-image', 'fetch', 'api', 'source-decode', 'result-decode']) {
|
||||
test(`${failure} failure preserves selection and creates no child`, async ({ page }) => {
|
||||
const requests = await setup(page, { failure })
|
||||
await page.getByTestId('step-upscale').click()
|
||||
const composer = page.getByTestId('upscale-composer')
|
||||
await composer.getByLabel('Upscaling model').selectOption('up-2')
|
||||
await composer.getByRole('button', { name: 'Upscale', exact: true }).click()
|
||||
await expect(composer.getByRole('alert')).toContainText('Upscale failed')
|
||||
await expect(page.getByTestId('lineage-node')).toHaveCount(1)
|
||||
await expect(node(page, 'img-bowls')).toHaveAttribute('data-selected', 'true')
|
||||
expect(requests()).toBe(['api', 'result-decode'].includes(failure) ? 1 : 0)
|
||||
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('localai_image_history')).some(e => e.edge === 'upscale'))).toBe(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -206,7 +206,8 @@
|
||||
"transform": "transform",
|
||||
"diarize": "who spoke",
|
||||
"soundtrack": "soundtrack",
|
||||
"turntable": "turntable"
|
||||
"turntable": "turntable",
|
||||
"upscale": "upscale"
|
||||
},
|
||||
"steps": {
|
||||
"animate": {
|
||||
@@ -240,6 +241,10 @@
|
||||
"turntable": {
|
||||
"title": "Turntable clip",
|
||||
"why": "Show the object from every side"
|
||||
},
|
||||
"upscale": {
|
||||
"title": "Upscale",
|
||||
"why": "Increase the resolution of this image."
|
||||
}
|
||||
},
|
||||
"kinds": {
|
||||
@@ -364,6 +369,14 @@
|
||||
"notKept": "The recording and the transcript are not kept, so only the summary of this run is shown. Run it again with the file to see the timeline.",
|
||||
"empty": "Choose a recording to see who spoke and when"
|
||||
}
|
||||
},
|
||||
"upscale": {
|
||||
"model": "Upscaling model",
|
||||
"scale": "Scale",
|
||||
"source": "Source image",
|
||||
"loading": "Upscaling…",
|
||||
"failed": "Upscale failed: {{message}}",
|
||||
"noResult": "No output image was returned."
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import UpscaleComposer from './UpscaleComposer'
|
||||
import Icon from '../Icon'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import InstallNote from './InstallNote'
|
||||
@@ -31,10 +33,10 @@ function isTyping(el) {
|
||||
// are installed.
|
||||
//
|
||||
// "Run as a new take" and "Branch from here" open the workspace with the source
|
||||
// and the words filled in. They do not run anything here. A step the destination
|
||||
// and the words filled in. Upscale instead runs in the inline composer. A step the destination
|
||||
// workspace cannot start from yet is listed but disabled, with the reason.
|
||||
export default function LineageView({
|
||||
items, workId, modalities, defaultModel, onClose, onToggleFavourite, onHandoff, onModelsChanged,
|
||||
items, workId, modalities, upscalers = [], onAddImage, defaultModel, onClose, onToggleFavourite, onHandoff, onModelsChanged,
|
||||
}) {
|
||||
const { t } = useTranslation('media')
|
||||
const members = useMemo(() => projectOf(items, workId), [items, workId])
|
||||
@@ -137,7 +139,7 @@ export default function LineageView({
|
||||
}
|
||||
|
||||
const root = members[0]
|
||||
const steps = NEXT_STEPS[selected.type] || []
|
||||
const steps = [...(NEXT_STEPS[selected.type] || []), ...(selected.type === 'images' && upscalers.length ? [{ to: 'images', edge: 'upscale', supported: true }] : [])]
|
||||
const takeOk = canTake(selected)
|
||||
const branchStep = suggestion || steps.find(s => s.supported) || null
|
||||
const draftInstalled = draft ? installed.has(draft.to) : true
|
||||
@@ -189,7 +191,7 @@ export default function LineageView({
|
||||
)
|
||||
}
|
||||
if (n.kind === 'ghost') {
|
||||
const missing = !installed.has(n.ghost.to)
|
||||
const missing = n.ghost.edge !== 'upscale' && !installed.has(n.ghost.to)
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
@@ -257,9 +259,9 @@ export default function LineageView({
|
||||
{draft ? (
|
||||
<>
|
||||
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => setDraft(null)} data-testid="studio-draft-cancel">{t('studio.lineage.cancel')}</button>
|
||||
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={!canOpenDraft} onClick={openDraft} data-testid="studio-draft-open">
|
||||
{draft.edge !== 'upscale' && <button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={!canOpenDraft} onClick={openDraft} data-testid="studio-draft-open">
|
||||
<Icon name="sparkles" /> {t('studio.lineage.openIn', { type: label(draft.to) })}
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -284,7 +286,13 @@ export default function LineageView({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{expanded && (draft ? (
|
||||
{expanded && (draft?.edge === 'upscale' ? (
|
||||
<UpscaleComposer key={selected.id} source={selected} upscalers={upscalers} onSuccess={entry => {
|
||||
const id = onAddImage(entry)
|
||||
setSelectedId(id)
|
||||
setDraft(null)
|
||||
}} />
|
||||
) : draft ? (
|
||||
<div className="studio-dock__body studio-draft" data-testid="studio-draft">
|
||||
<span className="studio-eyebrow">{t('studio.lineage.makeFrom')}</span>
|
||||
<div className="studio-draft__targets" role="group" aria-label={t('studio.lineage.targets')}>
|
||||
@@ -296,7 +304,7 @@ export default function LineageView({
|
||||
type="button"
|
||||
className="dk-chip studio-type"
|
||||
aria-pressed={draft.edge === step.edge}
|
||||
data-missing={(!installed.has(step.to)) || undefined}
|
||||
data-missing={(step.edge !== 'upscale' && !installed.has(step.to)) || undefined}
|
||||
disabled={bad}
|
||||
data-testid={`draft-target-${step.edge}`}
|
||||
onClick={() => startDraft(step)}
|
||||
@@ -330,7 +338,7 @@ export default function LineageView({
|
||||
{steps.length === 0 && <li className="studio-steps__none">{t('studio.lineage.noBranch', { type: label(selected.type) })}</li>}
|
||||
{steps.map(step => {
|
||||
const bad = !step.supported
|
||||
const missing = !bad && !installed.has(step.to)
|
||||
const missing = !bad && step.edge !== 'upscale' && !installed.has(step.to)
|
||||
return (
|
||||
<li key={step.edge}>
|
||||
<button type="button" className="studio-step" disabled={bad} onClick={() => startDraft(step)} data-testid={`step-${step.edge}`} data-state={bad ? 'unsupported' : missing ? 'missing' : 'ready'}>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageApi } from '../../utils/api'
|
||||
import { imageDimensions, sourceImageFile } from '../../utils/upscale'
|
||||
|
||||
export default function UpscaleComposer({ source, upscalers, onSuccess }) {
|
||||
const { t } = useTranslation('media')
|
||||
const [model, setModel] = useState(upscalers[0]?.id || '')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const active = useRef(true)
|
||||
const running = useRef(false)
|
||||
useEffect(() => { active.current = true; return () => { active.current = false } }, [])
|
||||
const selected = upscalers.find(m => m.id === model) || upscalers[0]
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!selected || running.current) return
|
||||
running.current = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const image = await sourceImageFile(source.url)
|
||||
const sourceDimensions = await imageDimensions(image)
|
||||
if (!active.current) return
|
||||
const response = await imageApi.upscale({ model: selected.id, image, scale: selected.upscaleScale })
|
||||
const results = (response?.data || []).map(r => ({ url: r.url || (r.b64_json ? `data:image/png;base64,${r.b64_json}` : '') }))
|
||||
if (!results.length || results.some(r => !r.url)) throw new Error(t('studio.upscale.noResult'))
|
||||
// A result that cannot be decoded is a failed run: never record guessed
|
||||
// dimensions or an unusable child. The endpoint's dimensions are ignored.
|
||||
const dimensions = await Promise.all(results.map(r => imageDimensions(r.url)))
|
||||
if (!active.current) return
|
||||
onSuccess({ model: selected.id, params: { scale: selected.upscaleScale, sourceDimensions, outputDimensions: dimensions[0] }, results, parentId: source.id, edge: 'upscale' })
|
||||
} catch (err) {
|
||||
if (active.current) setError(t('studio.upscale.failed', { message: err.message }))
|
||||
} finally {
|
||||
running.current = false
|
||||
if (active.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="studio-dock__body studio-detail" onSubmit={submit} data-testid="upscale-composer" aria-busy={loading}>
|
||||
<div className="studio-detail__preview"><img src={source.url} alt={source.title || t('studio.upscale.source')} /></div>
|
||||
<div className="studio-detail__info">
|
||||
<label className="studio-eyebrow">{t('studio.upscale.model')}
|
||||
<select className="dk-input" value={selected?.id || ''} onChange={e => setModel(e.target.value)} disabled={loading}>
|
||||
{upscalers.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="studio-eyebrow">{t('studio.upscale.scale')}
|
||||
<input className="dk-input" value={selected ? `${selected.upscaleScale}×` : ''} readOnly />
|
||||
</label>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<button className="dk-btn dk-btn--primary" type="submit" disabled={loading || !selected}>
|
||||
{t(loading ? 'studio.upscale.loading' : 'studio.steps.upscale.title')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
+10
-7
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { clearAllMediaHistory, FAVOURITES_KEY, readAllMediaHistory } from './useMediaHistory'
|
||||
import { clearAllMediaHistory, FAVOURITES_KEY, readAllMediaHistory, useMediaHistory } from './useMediaHistory'
|
||||
import { use3DHistory } from './use3DHistory'
|
||||
import { collectWork, pruneIds, toggleId } from '../utils/studioWork'
|
||||
|
||||
@@ -21,12 +21,14 @@ function writeFavourites(ids) {
|
||||
|
||||
// Everything the Studio front page shows as "your work": the entries each
|
||||
// workspace stored, in one list, with favourites. The browser-storage lists are
|
||||
// read once on mount (they change only while a workspace is open, and this page
|
||||
// mounts fresh when you come back); 3D arrives asynchronously from IndexedDB.
|
||||
// read once on mount, except images: inline upscaling writes through the same
|
||||
// image history hook and merges its live entries. 3D arrives from IndexedDB.
|
||||
//
|
||||
// `ready` is false until 3D has answered, so the page can show a skeleton
|
||||
// instead of flashing an empty state for work that is a moment away.
|
||||
export function useStudioWork() {
|
||||
const { addEntry: addImageEntry, historyProps: imageHistory } = useMediaHistory('image')
|
||||
const clearImages = imageHistory.onClearAll
|
||||
const [media, setMedia] = useState(() => readAllMediaHistory())
|
||||
const [favourites, setFavourites] = useState(readFavourites)
|
||||
const { entries: threeD, clearAll: clearThreeD } = use3DHistory()
|
||||
@@ -41,8 +43,8 @@ export function useStudioWork() {
|
||||
useEffect(() => { if (threeD.length > 0) setReady(true) }, [threeD])
|
||||
|
||||
const items = useMemo(
|
||||
() => collectWork(media, threeD, { favourites }),
|
||||
[media, threeD, favourites],
|
||||
() => collectWork({ ...media, image: imageHistory.entries }, threeD, { favourites }),
|
||||
[media, imageHistory.entries, threeD, favourites],
|
||||
)
|
||||
|
||||
const toggleFavourite = useCallback((id) => {
|
||||
@@ -54,11 +56,12 @@ export function useStudioWork() {
|
||||
}, [items])
|
||||
|
||||
const clearHistory = useCallback(async () => {
|
||||
clearImages()
|
||||
clearAllMediaHistory()
|
||||
setMedia(readAllMediaHistory())
|
||||
setFavourites([])
|
||||
await clearThreeD()
|
||||
}, [clearThreeD])
|
||||
}, [clearThreeD, clearImages])
|
||||
|
||||
return { items, ready, toggleFavourite, clearHistory }
|
||||
return { items, ready, addImageEntry, toggleFavourite, clearHistory }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useModels } from '../hooks/useModels'
|
||||
import {
|
||||
CAP_DIARIZATION, CAP_IMAGE, CAP_VIDEO, CAP_3D, CAP_3D_ANIMATION, CAP_TTS, CAP_SOUND_GENERATION, CAP_AUDIO_TRANSFORM,
|
||||
} from '../utils/capabilities'
|
||||
import { upscaleModels } from '../utils/upscale'
|
||||
import Icon from '../components/Icon'
|
||||
import '../components/studio/studio.css'
|
||||
|
||||
@@ -132,6 +133,7 @@ export default function Studio() {
|
||||
) : (
|
||||
<StudioOverview
|
||||
modalities={modalities}
|
||||
upscalers={upscaleModels(models)}
|
||||
modelsLoading={modelsLoading}
|
||||
modelsError={modelsError}
|
||||
refetchModels={refetchModels}
|
||||
|
||||
@@ -28,11 +28,11 @@ import '../components/studio/studio.css'
|
||||
// modelsLoading true until the first answer about installed models
|
||||
// modelsError set when that answer could not be read
|
||||
// refetchModels ask again, used while a model installs
|
||||
export default function StudioOverview({ modalities, modelsLoading, modelsError, refetchModels }) {
|
||||
export default function StudioOverview({ modalities, upscalers, modelsLoading, modelsError, refetchModels }) {
|
||||
const { t } = useTranslation('media')
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const { items, ready, toggleFavourite, clearHistory } = useStudioWork()
|
||||
const { items, ready, addImageEntry, toggleFavourite, clearHistory } = useStudioWork()
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [group, setGroup] = useState(true)
|
||||
// The composer's state lives here, so opening a lineage and coming back, or a
|
||||
@@ -63,6 +63,8 @@ export default function StudioOverview({ modalities, modelsLoading, modelsError,
|
||||
<LineageView
|
||||
key={workId}
|
||||
items={items}
|
||||
upscalers={upscalers}
|
||||
onAddImage={addImageEntry}
|
||||
workId={workId}
|
||||
modalities={modalities}
|
||||
defaultModel={defaultModel}
|
||||
|
||||
Vendored
+7
@@ -296,6 +296,13 @@ export const agentJobsApi = {
|
||||
|
||||
// Image generation
|
||||
export const imageApi = {
|
||||
upscale: async ({ model, image, scale }) => {
|
||||
const form = new FormData()
|
||||
form.append('model', model)
|
||||
form.append('image', image)
|
||||
form.append('scale', String(scale))
|
||||
return handleResponse(await fetch(apiUrl('/v1/images/upscale'), { method: 'POST', body: form }))
|
||||
},
|
||||
generate: (body) => postJSON(API_CONFIG.endpoints.imageGenerations, body),
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CAP_EDIT = 'FLAG_EDIT'
|
||||
export const CAP_EMBEDDINGS = 'FLAG_EMBEDDINGS'
|
||||
export const CAP_RERANK = 'FLAG_RERANK'
|
||||
export const CAP_IMAGE = 'FLAG_IMAGE'
|
||||
export const CAP_UPSCALE = 'FLAG_UPSCALE'
|
||||
export const CAP_TRANSCRIPT = 'FLAG_TRANSCRIPT'
|
||||
export const CAP_TTS = 'FLAG_TTS'
|
||||
export const CAP_SOUND_GENERATION = 'FLAG_SOUND_GENERATION'
|
||||
|
||||
+3
-2
@@ -39,7 +39,7 @@ const TYPE_BY_MEDIA = Object.fromEntries(
|
||||
Object.entries(TYPE_INFO).filter(([, v]) => v.media).map(([key, v]) => [v.media, key]),
|
||||
)
|
||||
|
||||
export const EDGE_KINDS = ['take', 'animate', 'to-3d', 'variation', 'transform', 'diarize']
|
||||
export const EDGE_KINDS = ['upscale', 'take', 'animate', 'to-3d', 'variation', 'transform', 'diarize']
|
||||
|
||||
// What each kind of result can be sent on to. `supported` is whether the
|
||||
// destination workspace accepts that result as a starting point today. The
|
||||
@@ -121,7 +121,8 @@ export function parseSize(size) {
|
||||
// Width over height for a tile. Only what the entry recorded: an entry with no
|
||||
// size is drawn square rather than guessed.
|
||||
export function aspectOf(item) {
|
||||
const size = parseSize(item?.params?.size)
|
||||
const output = item?.params?.outputDimensions
|
||||
const size = output?.width > 0 && output?.height > 0 ? output : parseSize(item?.params?.size)
|
||||
return size ? size.width / size.height : 1
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
import { CAP_UPSCALE } from './capabilities.js'
|
||||
|
||||
export function upscaleModels(models) {
|
||||
return models.filter(m => !m.disabled && m.capabilities?.includes(CAP_UPSCALE) &&
|
||||
Number.isFinite(m.upscaleScale) && m.upscaleScale > 0)
|
||||
.map(({ id, upscaleScale }) => ({ id, upscaleScale }))
|
||||
}
|
||||
|
||||
export async function sourceImageFile(source, base = window.location.href, fetcher = fetch) {
|
||||
if (source instanceof File) return source
|
||||
const url = new URL(source, base)
|
||||
if (url.origin !== new URL(base).origin || !['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('Source image must be same-origin')
|
||||
}
|
||||
const response = await fetcher(url.href, { credentials: 'same-origin', mode: 'same-origin', redirect: 'error' })
|
||||
if (!response.ok) throw new Error(`Image fetch failed (HTTP ${response.status})`)
|
||||
if (!response.headers.get('content-type')?.toLowerCase().startsWith('image/')) throw new Error('Source is not an image')
|
||||
const blob = await response.blob()
|
||||
return new File([blob], url.pathname.split('/').pop() || 'source.png', { type: blob.type })
|
||||
}
|
||||
|
||||
export function imageDimensions(source) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const objectUrl = source instanceof Blob ? URL.createObjectURL(source) : null
|
||||
const image = new Image()
|
||||
const finish = (error) => {
|
||||
clearTimeout(timer)
|
||||
image.onload = image.onerror = null
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
||||
if (error || !image.naturalWidth || !image.naturalHeight) reject(new Error('Could not decode image dimensions'))
|
||||
else resolve({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
}
|
||||
const timer = setTimeout(() => finish(true), 30000)
|
||||
image.onload = () => finish(false)
|
||||
image.onerror = () => finish(true)
|
||||
image.src = objectUrl || source
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { upscaleModels, sourceImageFile } from './upscale.js'
|
||||
import { toWorkItem, EDGE_KINDS } from './studioWork.js'
|
||||
|
||||
test('only enabled upscalers with positive finite scale are offered', () => {
|
||||
assert.deepEqual(upscaleModels([
|
||||
{ id: 'yes', capabilities: ['FLAG_UPSCALE'], upscaleScale: 4 },
|
||||
{ id: 'image', capabilities: ['FLAG_IMAGE'], upscaleScale: 4 },
|
||||
...[0, -1, undefined, Infinity].map(upscaleScale => ({ capabilities: ['FLAG_UPSCALE'], upscaleScale })),
|
||||
{ capabilities: ['FLAG_UPSCALE'], upscaleScale: 2, disabled: true },
|
||||
]), [{ id: 'yes', upscaleScale: 4 }])
|
||||
})
|
||||
|
||||
test('URL source is same-origin, checked and named', async () => {
|
||||
let called = false
|
||||
const fetcher = async (url, options) => {
|
||||
called = true
|
||||
assert.equal(url, 'https://local.test/generated/source.png')
|
||||
assert.equal(options.credentials, 'same-origin')
|
||||
return new Response('png', { headers: { 'Content-Type': 'image/png' } })
|
||||
}
|
||||
const file = await sourceImageFile('/generated/source.png', 'https://local.test/app/studio', fetcher)
|
||||
assert.equal(file.name, 'source.png')
|
||||
assert.equal(file.type, 'image/png')
|
||||
called = false
|
||||
await assert.rejects(sourceImageFile('https://other.test/a.png', 'https://local.test/', fetcher), /origin/)
|
||||
assert.equal(called, false)
|
||||
for (const response of [new Response('bad', { status: 500 }), new Response('html', { headers: { 'Content-Type': 'text/html' } })]) {
|
||||
await assert.rejects(sourceImageFile('/a', 'https://local.test/', async () => response))
|
||||
}
|
||||
await assert.rejects(sourceImageFile('/a', 'https://local.test/', async () => { throw new Error('offline') }))
|
||||
})
|
||||
|
||||
test('upscale history normalizes as a child image', () => {
|
||||
assert.ok(EDGE_KINDS.includes('upscale'))
|
||||
const item = toWorkItem('images', { id: 'child', parentId: 'source', edge: 'upscale', results: [{ url: '/out.png' }] })
|
||||
assert.equal(item.type, 'images')
|
||||
assert.equal(item.parentId, 'source')
|
||||
assert.equal(item.edge, 'upscale')
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/application"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
@@ -267,10 +269,31 @@ func RegisterOpenAIRoutes(app *echo.Echo,
|
||||
app.POST("/v1/images/inpainting", inpaintingHandler, imageMiddleware...)
|
||||
app.POST("/images/inpainting", inpaintingHandler, imageMiddleware...)
|
||||
|
||||
// upscale endpoint - reuse same middleware config as images
|
||||
// Upscaling has its own model usecase; generation models are not defaults.
|
||||
upscaleMiddleware := []echo.MiddlewareFunc{
|
||||
nodeHeaderMiddleware,
|
||||
traceMiddleware,
|
||||
// Default: use the first available upscaling model
|
||||
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_UPSCALE)),
|
||||
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }),
|
||||
func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// The default-model filter does not validate explicitly named models.
|
||||
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
|
||||
if !ok || cfg == nil || !cfg.HasUsecases(config.FLAG_UPSCALE) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "model does not support image upscaling")
|
||||
}
|
||||
if err := re.SetOpenAIRequest(c); err != nil {
|
||||
return err
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
upscaleHandler := openai.UpscaleEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
|
||||
app.POST("/v1/images/upscale", upscaleHandler, imageMiddleware...)
|
||||
app.POST("/images/upscale", upscaleHandler, imageMiddleware...)
|
||||
app.POST("/v1/images/upscale", upscaleHandler, upscaleMiddleware...)
|
||||
app.POST("/images/upscale", upscaleHandler, upscaleMiddleware...)
|
||||
|
||||
// List models
|
||||
app.GET("/v1/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.AuthDB()))
|
||||
|
||||
@@ -822,6 +822,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
NodeStatus string `json:"node_status"`
|
||||
}
|
||||
type modelCapability struct {
|
||||
UpscaleScale int `json:"upscaleScale,omitempty"`
|
||||
ThreeDOperations []schema.ThreeDOperation `json:"three_d_operations,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
@@ -873,6 +874,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
result = append(result, modelCapability{
|
||||
ID: cfg.Name,
|
||||
Capabilities: cfg.KnownUsecaseStrings,
|
||||
UpscaleScale: config.UpscaleScaleFromOptions(cfg),
|
||||
ThreeDOperations: cfg.ThreeDOperations(),
|
||||
Backend: cfg.Backend,
|
||||
Disabled: cfg.IsDisabled(),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/application"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/routes"
|
||||
"github.com/mudler/LocalAI/core/services/galleryop"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
)
|
||||
|
||||
func TestUIUpscaleCapabilities(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := system.GetSystemState(system.WithModelPath(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl := config.NewModelConfigLoader(dir)
|
||||
for name, options := range map[string]string{
|
||||
"upscaler": "options: [upscale_scale:4]\n",
|
||||
"absent": "",
|
||||
"malformed": "options: [upscale_scale:bad]\n",
|
||||
"nonpositive": "options: [upscale_scale:0]\n",
|
||||
"negative": "options: [upscale_scale:-1]\n",
|
||||
"overflow": "options: [upscale_scale:2147483648]\n",
|
||||
"duplicate": "options: [upscale_scale:4, upscale_scale:4]\n",
|
||||
} {
|
||||
path := filepath.Join(dir, name+".yaml")
|
||||
if err := os.WriteFile(path, []byte("name: "+name+"\nbackend: stablediffusion-ggml\nknown_usecases: [upscale]\n"+options), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cl.ReadModelConfig(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ac := config.NewApplicationConfig()
|
||||
ac.SystemState = st
|
||||
ml := model.NewModelLoader(st)
|
||||
gs := galleryop.NewGalleryService(ac, ml)
|
||||
app := echo.New()
|
||||
routes.RegisterUIAPIRoutes(app, cl, ml, ac, gs, galleryop.NewOpCache(gs), &application.Application{}, func(next echo.HandlerFunc) echo.HandlerFunc { return next })
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/models/capabilities", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Scale *int `json:"upscaleScale"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Data) != 7 {
|
||||
t.Fatalf("unexpected model count: %d", len(response.Data))
|
||||
}
|
||||
for _, entry := range response.Data {
|
||||
if len(entry.Capabilities) != 1 || entry.Capabilities[0] != "FLAG_UPSCALE" {
|
||||
t.Fatalf("unexpected capabilities: %+v", entry)
|
||||
}
|
||||
if entry.ID == "upscaler" {
|
||||
if entry.Scale == nil || *entry.Scale != 4 {
|
||||
t.Fatalf("unexpected scale: %+v", entry)
|
||||
}
|
||||
} else if entry.Scale != nil {
|
||||
t.Fatalf("invalid scale exposed: %+v", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/application"
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/http/routes"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Upscale route model selection", func() {
|
||||
It("uses upscale-only models on both aliases without admitting generation models", func() {
|
||||
root := GinkgoT().TempDir()
|
||||
app, err := application.New(config.WithDataPath(root), config.WithGeneratedContentDir(root), config.WithDisableLocalAIAssistant(true), config.WithSystemState(&system.SystemState{Model: system.Model{ModelsPath: root}, Backend: system.Backend{BackendsPath: root}}))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() { Expect(app.Shutdown()).To(Succeed()) })
|
||||
upscale, image := config.FLAG_UPSCALE, config.FLAG_IMAGE
|
||||
configs := []config.ModelConfig{
|
||||
{Name: "esrgan", Backend: "stablediffusion-ggml", KnownUsecases: &upscale},
|
||||
{Name: "stable-diffusion-x4-upscaler", Backend: "diffusers", KnownUsecases: &upscale},
|
||||
{Name: "ordinary-image", Backend: "diffusers", KnownUsecases: &image},
|
||||
{Name: "incompatible", Backend: "whisper"},
|
||||
}
|
||||
for i := range configs {
|
||||
configs[i].SetDefaults()
|
||||
configs[i].Model = configs[i].Name
|
||||
}
|
||||
original := backend.ImageUpscaleFunc
|
||||
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
|
||||
served := ""
|
||||
backend.ImageUpscaleFunc = func(_ context.Context, _, dst string, _ int, _ *model.ModelLoader, cfg config.ModelConfig, _ *config.ApplicationConfig) (func() error, error) {
|
||||
served = cfg.Name
|
||||
return func() error { return os.WriteFile(dst, []byte("PNG"), 0600) }, nil
|
||||
}
|
||||
e := echo.New()
|
||||
selected := ""
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
err := next(c)
|
||||
if cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig); ok && cfg != nil {
|
||||
selected = cfg.Name
|
||||
}
|
||||
return err
|
||||
}
|
||||
})
|
||||
routes.RegisterOpenAIRoutes(e, middleware.NewRequestExtractor(app.ModelConfigLoader(), app.ModelLoader(), app.ApplicationConfig()), app)
|
||||
for _, path := range []string{"/v1/images/upscale", "/images/upscale"} {
|
||||
for _, cfg := range configs {
|
||||
app.ModelConfigLoader().ReplaceModelConfigs([]config.ModelConfig{cfg})
|
||||
for _, explicit := range []bool{true, false} {
|
||||
served, selected = "", ""
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
if explicit {
|
||||
Expect(form.WriteField("model", cfg.Name)).To(Succeed())
|
||||
}
|
||||
Expect(form.WriteField("scale", "4")).To(Succeed())
|
||||
file, err := form.CreateFormFile("image", "image.png")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = file.Write([]byte("IMAGE"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(form.Close()).To(Succeed())
|
||||
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||
req.Header.Set("Content-Type", form.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
eligible := cfg.HasUsecases(config.FLAG_UPSCALE)
|
||||
if explicit && eligible {
|
||||
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
|
||||
Expect(served).To(Equal(cfg.Name))
|
||||
} else {
|
||||
// The endpoint still requires a form model even when middleware selects a default.
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest), rec.Body.String())
|
||||
Expect(served).To(BeEmpty())
|
||||
}
|
||||
if !explicit {
|
||||
if eligible {
|
||||
Expect(selected).To(Equal(cfg.Name))
|
||||
} else {
|
||||
Expect(selected).NotTo(Equal(cfg.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -275,6 +275,7 @@ type ModelsDataResponse struct {
|
||||
// served by the LocalAI-specific /v1/models/capabilities endpoint so clients can
|
||||
// route attachments (image/audio/video) to a model only when it can handle them.
|
||||
type ModelCapabilities struct {
|
||||
UpscaleScale int `json:"upscale_scale,omitempty"`
|
||||
ThreeDOperations []ThreeDOperation `json:"three_d_operations,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
|
||||
@@ -135,6 +135,83 @@ options:
|
||||
|
||||
Reach for this when the decode fails but sampling completed, which the backend log shows as `sampling completed` followed by a VAE allocation error. Two cases hit it: cards without the VRAM for a full-frame decode, and drivers that cap a single allocation regardless of free memory (Mesa RADV reports a 4 GiB `maxMemoryAllocationSize`, so a large decode fails there even with tens of GB free). Lowering the output resolution avoids it too, at the cost of the resolution.
|
||||
|
||||
#### ESRGAN image upscaling
|
||||
|
||||
The `stablediffusion-ggml` backend supports standalone ESRGAN upscaling through
|
||||
`POST /v1/images/upscale`. This is a separate, upscale-only model, not a diffusion
|
||||
pipeline. Image generation does **not** automatically upscale its results.
|
||||
|
||||
Install the gallery model:
|
||||
|
||||
```bash
|
||||
local-ai models install realesrgan-x4plus-anime-6b
|
||||
```
|
||||
|
||||
This uses the official [RealESRGAN_x4plus_anime_6B.pth release](https://github.com/xinntao/Real-ESRGAN/releases/tag/v0.2.2.4),
|
||||
a six-block model for anime images with a native **4x** scale. The PyTorch `.pth`
|
||||
checkpoint is supported directly by [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp/blob/f89d9b13d730eabeede7314ce49dacd18d3c90c2/docs/esrgan.md);
|
||||
no GGUF conversion is needed. The upstream project is licensed under
|
||||
[BSD-3-Clause](https://github.com/xinntao/Real-ESRGAN/blob/v0.2.2.4/LICENSE).
|
||||
|
||||
For manual installation, download that checkpoint into your models directory and
|
||||
save the following as `realesrgan-x4plus-anime-6b.yaml` alongside it (the gallery
|
||||
installer configures these settings for you):
|
||||
|
||||
```yaml
|
||||
name: realesrgan-x4plus-anime-6b
|
||||
backend: stablediffusion-ggml
|
||||
parameters:
|
||||
model: RealESRGAN_x4plus_anime_6B.pth
|
||||
known_usecases: [upscale]
|
||||
options:
|
||||
- upscale_scale:4
|
||||
- upscale_tile_size:128
|
||||
```
|
||||
|
||||
`upscale_scale` and `upscale_tile_size` are backend options, expressed as
|
||||
`key:value` strings in `options`, not root-level configuration fields.
|
||||
`upscale_scale` must be a positive integer. Keep `known_usecases` explicitly
|
||||
set to `[upscale]`: do not add `image` or `diffusion_model` options to this
|
||||
configuration. Options are forwarded unchanged; the backend rejects invalid
|
||||
or duplicate recognized settings.
|
||||
|
||||
The multipart fields `model`, `image`, and **`scale` are required**. The HTTP
|
||||
endpoint accepts only integer scales from **1 through 16**, rejecting missing,
|
||||
malformed, zero, negative, or larger values with HTTP 400. This range is not a
|
||||
promise that each model supports every scale: for ESRGAN, the configured
|
||||
`upscale_scale`, the loaded model's detected native scale, and the request's
|
||||
`scale` must all match exactly. Mismatches fail rather than silently resizing or
|
||||
running multiple passes. For this model, always request `scale=4`.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/v1/images/upscale \
|
||||
-F model=realesrgan-x4plus-anime-6b \
|
||||
-F scale=4 \
|
||||
-F image=@input.png
|
||||
```
|
||||
|
||||
The response follows the image-generation response format, with the output URL
|
||||
in `data[0].url` under `/generated-images`. A successful 4x upscale multiplies
|
||||
both width and height by four.
|
||||
|
||||
`upscale_tile_size` controls ESRGAN tiling in pixels: omit it or set it to
|
||||
`0` for stable-diffusion.cpp full-frame processing; set a positive value (such
|
||||
as the gallery entry's `128`) to enable tiling. Smaller tiles reduce working
|
||||
memory at the cost of more tile work. Full-frame processing can use much more
|
||||
memory. `upscale_direct` is a separate convolution-algorithm flag, not a tile
|
||||
control. Tile settings apply to ESRGAN, not the diffusion model's separate VAE
|
||||
tiling settings.
|
||||
|
||||
In **Studio**, open an existing image, choose **Upscale**, select an installed
|
||||
upscaler, and submit. The scale is read-only and comes from the selected model's
|
||||
configured scale. A successful result becomes an **Upscale child node** of the
|
||||
source image, leaving the original intact. Open that child to continue the
|
||||
workflow with the available image actions, including another upscale or image
|
||||
editing; each upscale is an explicit operation, not a post-generation default.
|
||||
|
||||
Unlike the Diffusers upscaling described below, this path uses ESRGAN inference
|
||||
and does not fall back to Lanczos resizing.
|
||||
|
||||
#### Distributed inference (RPC workers)
|
||||
|
||||
The `stablediffusion-ggml` backend can offload computation to remote `ggml` RPC workers, sharding a model that does not fit on a single machine. It reuses the **same backend-agnostic `rpc-server` workers as the llama.cpp backend**, so one worker pool can serve both.
|
||||
@@ -166,8 +243,10 @@ By default the RPC devices join the pool and participate in placement; combine w
|
||||
|
||||
#### Image upscaling
|
||||
|
||||
LocalAI can upscale an uploaded image by a factor of 2 or 4 through
|
||||
`POST /v1/images/upscale`. Install the included Stable Diffusion x4 upscaler
|
||||
The Diffusers backend can upscale an uploaded image through
|
||||
`POST /v1/images/upscale`. The multipart `scale` field is required and must be an
|
||||
integer from 1 through 16 at the HTTP boundary; actual scaling behavior depends
|
||||
on the selected pipeline. Install the included Stable Diffusion x4 upscaler
|
||||
gallery model first:
|
||||
|
||||
```bash
|
||||
@@ -186,7 +265,12 @@ curl http://localhost:8080/v1/images/upscale \
|
||||
The response uses the same format as image generation and returns the generated
|
||||
image under `/generated-images`. The `diffusers` backend uses a loaded
|
||||
`StableDiffusionUpscalePipeline` or `StableDiffusionLatentUpscalePipeline` when
|
||||
configured. Other diffusers pipelines fall back to Lanczos resizing.
|
||||
configured. Other diffusers pipelines fall back to Lanczos resizing. Upscale
|
||||
routes select models with `known_usecases: [upscale]`, not ordinary image
|
||||
generation models. If you installed the x4 upscaler before it declared this
|
||||
usecase, update its model YAML to `known_usecases: [upscale]` or reinstall the
|
||||
gallery entry. Custom Diffusers configurations intended for upscaling should
|
||||
also explicitly declare the `upscale` usecase.
|
||||
|
||||
#### Model setup
|
||||
|
||||
|
||||
+31
-1
@@ -21331,6 +21331,36 @@
|
||||
- filename: llama-cpp/models/deepseek-ai.DeepSeek-V3.2.Q4_K_M-00029-of-00029.gguf
|
||||
sha256: 013af4e9d2f84e484f77c7bae2a02652607f0f0179bd2815ffdf401c3ada5184
|
||||
uri: https://huggingface.co/DevQuasar/deepseek-ai.DeepSeek-V3.2-GGUF/resolve/main/deepseek-ai.DeepSeek-V3.2.Q4_K_M-00029-of-00029.gguf
|
||||
# Official PyTorch checkpoint recommended by stable-diffusion.cpp docs/esrgan.md.
|
||||
# SHA256 measured from the release download (17,938,799 bytes).
|
||||
- name: realesrgan-x4plus-anime-6b
|
||||
url: github:mudler/LocalAI/gallery/virtual.yaml@master
|
||||
urls:
|
||||
- https://github.com/xinntao/Real-ESRGAN
|
||||
- https://github.com/xinntao/Real-ESRGAN/releases/tag/v0.2.2.4
|
||||
- https://github.com/xinntao/Real-ESRGAN/blob/v0.2.2.4/LICENSE
|
||||
- https://github.com/leejet/stable-diffusion.cpp/blob/f89d9b13d730eabeede7314ce49dacd18d3c90c2/docs/esrgan.md
|
||||
description: |
|
||||
RealESRGAN x4plus anime 6B is a six-block ESRGAN model for anime images,
|
||||
with a native 4x scale. Uses the official PyTorch .pth checkpoint through
|
||||
stable-diffusion.cpp, without conversion. Upscale-only: not a text-to-image model.
|
||||
license: BSD-3-Clause
|
||||
tags:
|
||||
- image-upscaling
|
||||
- super-resolution
|
||||
- stablediffusion-ggml
|
||||
overrides:
|
||||
backend: stablediffusion-ggml
|
||||
known_usecases: [upscale]
|
||||
options:
|
||||
- upscale_scale:4
|
||||
- upscale_tile_size:128
|
||||
parameters:
|
||||
model: RealESRGAN_x4plus_anime_6B.pth
|
||||
files:
|
||||
- filename: RealESRGAN_x4plus_anime_6B.pth
|
||||
uri: https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth
|
||||
sha256: f872d837d3c90ed2e05227bed711af5671a6fd1c9f7d7e91c911a61f155e99da
|
||||
- name: stable-diffusion-x4-upscaler
|
||||
url: github:mudler/LocalAI/gallery/virtual.yaml@master
|
||||
urls:
|
||||
@@ -21350,7 +21380,7 @@
|
||||
diffusers:
|
||||
pipeline_type: StableDiffusionUpscalePipeline
|
||||
known_usecases:
|
||||
- image
|
||||
- upscale
|
||||
parameters:
|
||||
model: stabilityai/stable-diffusion-x4-upscaler
|
||||
artifacts:
|
||||
|
||||
Reference in new issue
Block a user