diff --git a/backend/go/stablediffusion-ggml/cpp/gosd.cpp b/backend/go/stablediffusion-ggml/cpp/gosd.cpp
index 676163a8c..f324df28e 100644
--- a/backend/go/stablediffusion-ggml/cpp/gosd.cpp
+++ b/backend/go/stablediffusion-ggml/cpp/gosd.cpp
@@ -1,5 +1,7 @@
#include "stable-diffusion.h"
#include
+#include
+#include
#include
#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();
+ 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(handle)->ctx); }
+ catch (...) { return 0; }
+}
+
+void gosd_upscaler_destroy(void* handle) {
+ delete static_cast(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 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(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; }
+}
diff --git a/backend/go/stablediffusion-ggml/cpp/gosd.h b/backend/go/stablediffusion-ggml/cpp/gosd.h
index 4b94b6009..d0b790998 100644
--- a/backend/go/stablediffusion-ggml/cpp/gosd.h
+++ b/backend/go/stablediffusion-ggml/cpp/gosd.h
@@ -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);
diff --git a/backend/go/stablediffusion-ggml/gosd.go b/backend/go/stablediffusion-ggml/gosd.go
index 9970cefd5..24d5492bd 100644
--- a/backend/go/stablediffusion-ggml/gosd.go
+++ b/backend/go/stablediffusion-ggml/gosd.go
@@ -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
+}
diff --git a/backend/go/stablediffusion-ggml/gosd_test.go b/backend/go/stablediffusion-ggml/gosd_test.go
index beca7b3da..4b0cc0b59 100644
--- a/backend/go/stablediffusion-ggml/gosd_test.go
+++ b/backend/go/stablediffusion-ggml/gosd_test.go
@@ -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")
+ }
+ })
+ }
+}
diff --git a/backend/go/stablediffusion-ggml/main.go b/backend/go/stablediffusion-ggml/main.go
index 2f2badca8..be288fe69 100644
--- a/backend/go/stablediffusion-ggml/main.go
+++ b/backend/go/stablediffusion-ggml/main.go
@@ -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"},
diff --git a/core/backend/options_internal_test.go b/core/backend/options_internal_test.go
index 13ad13b57..a83ccef69 100644
--- a/core/backend/options_internal_test.go
+++ b/core/backend/options_internal_test.go
@@ -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))
+ })
+})
diff --git a/core/backend/upscale.go b/core/backend/upscale.go
index ad3885174..48c12491a 100644
--- a/core/backend/upscale.go
+++ b/core/backend/upscale.go
@@ -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
diff --git a/core/backend/upscale_test.go b/core/backend/upscale_test.go
new file mode 100644
index 000000000..7e654ea7c
--- /dev/null
+++ b/core/backend/upscale_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/core/config/backend_capabilities.go b/core/config/backend_capabilities.go
index 96d53c4e2..9de40568c 100644
--- a/core/config/backend_capabilities.go
+++ b/core/config/backend_capabilities.go
@@ -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",
},
diff --git a/core/config/backend_capabilities_test.go b/core/config/backend_capabilities_test.go
index 6f683c0bf..8ac46c6ae 100644
--- a/core/config/backend_capabilities_test.go
+++ b/core/config/backend_capabilities_test.go
@@ -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))
+ })
+})
diff --git a/core/config/meta/constants.go b/core/config/meta/constants.go
index cc1027f2b..008afe64f 100644
--- a/core/config/meta/constants.go
+++ b/core/config/meta/constants.go
@@ -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"},
diff --git a/core/config/meta/registry_test.go b/core/config/meta/registry_test.go
index 221533050..bd8c43bb8 100644
--- a/core/config/meta/registry_test.go
+++ b/core/config/meta/registry_test.go
@@ -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"))
+ }
+ })
+})
diff --git a/core/config/model_capabilities.go b/core/config/model_capabilities.go
index 36a2f18da..5d99510f3 100644
--- a/core/config/model_capabilities.go
+++ b/core/config/model_capabilities.go
@@ -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)
diff --git a/core/config/model_capabilities_test.go b/core/config/model_capabilities_test.go
index e228d1cb8..e86460b52 100644
--- a/core/config/model_capabilities_test.go
+++ b/core/config/model_capabilities_test.go
@@ -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))
+ })
+})
diff --git a/core/config/model_config.go b/core/config/model_config.go
index b2b727628..9b280a7d1 100644
--- a/core/config/model_config.go
+++ b/core/config/model_config.go
@@ -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",
diff --git a/core/config/model_config_test.go b/core/config/model_config_test.go
index b9d46f1c2..96dec68c5 100644
--- a/core/config/model_config_test.go
+++ b/core/config/model_config_test.go
@@ -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
diff --git a/core/config/upscale_options.go b/core/config/upscale_options.go
new file mode 100644
index 000000000..71ce44c55
--- /dev/null
+++ b/core/config/upscale_options.go
@@ -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
+}
diff --git a/core/config/upscale_options_test.go b/core/config/upscale_options_test.go
new file mode 100644
index 000000000..5745b7203
--- /dev/null
+++ b/core/config/upscale_options_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/core/gallery/esrgan_entries_test.go b/core/gallery/esrgan_entries_test.go
new file mode 100644
index 000000000..b89740724
--- /dev/null
+++ b/core/gallery/esrgan_entries_test.go
@@ -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))
+ })
+})
diff --git a/core/http/endpoints/openai/list_capabilities.go b/core/http/endpoints/openai/list_capabilities.go
index b7694a393..745663804 100644
--- a/core/http/endpoints/openai/list_capabilities.go
+++ b/core/http/endpoints/openai/list_capabilities.go
@@ -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)
diff --git a/core/http/endpoints/openai/list_capabilities_test.go b/core/http/endpoints/openai/list_capabilities_test.go
index 47fe7c94b..eae2d5200 100644
--- a/core/http/endpoints/openai/list_capabilities_test.go
+++ b/core/http/endpoints/openai/list_capabilities_test.go
@@ -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"))
diff --git a/core/http/endpoints/openai/upscale.go b/core/http/endpoints/openai/upscale.go
index eab000f8e..f63aa33d2 100644
--- a/core/http/endpoints/openai/upscale.go
+++ b/core/http/endpoints/openai/upscale.go
@@ -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
}
}
diff --git a/core/http/endpoints/openai/upscale_test.go b/core/http/endpoints/openai/upscale_test.go
index 2e13c8fc0..37fd9a6e4 100644
--- a/core/http/endpoints/openai/upscale_test.go
+++ b/core/http/endpoints/openai/upscale_test.go
@@ -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
+ }
+}
diff --git a/core/http/react-ui/e2e/studio-fixtures.js b/core/http/react-ui/e2e/studio-fixtures.js
index 15f33b7b4..cf7554ae7 100644
--- a/core/http/react-ui/e2e/studio-fixtures.js
+++ b/core/http/react-ui/e2e/studio-fixtures.js
@@ -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.
diff --git a/core/http/react-ui/e2e/studio-lineage.spec.js b/core/http/react-ui/e2e/studio-lineage.spec.js
index b64f8dcc5..dc9b18d6d 100644
--- a/core/http/react-ui/e2e/studio-lineage.spec.js
+++ b/core/http/react-ui/e2e/studio-lineage.spec.js
@@ -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)
+ })
+ }
+})
diff --git a/core/http/react-ui/public/locales/en/media.json b/core/http/react-ui/public/locales/en/media.json
index 614d8268d..8e2eae66c 100644
--- a/core/http/react-ui/public/locales/en/media.json
+++ b/core/http/react-ui/public/locales/en/media.json
@@ -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": {
diff --git a/core/http/react-ui/src/components/studio/LineageView.jsx b/core/http/react-ui/src/components/studio/LineageView.jsx
index 95cb2a829..77bbbb90d 100644
--- a/core/http/react-ui/src/components/studio/LineageView.jsx
+++ b/core/http/react-ui/src/components/studio/LineageView.jsx
@@ -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 (
)}
- {expanded && (draft ? (
+ {expanded && (draft?.edge === 'upscale' ? (
+ {
+ const id = onAddImage(entry)
+ setSelectedId(id)
+ setDraft(null)
+ }} />
+ ) : draft ? (
{t('studio.lineage.makeFrom')}
@@ -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 &&
{t('studio.lineage.noBranch', { type: label(selected.type) })}}
{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 (
startDraft(step)} data-testid={`step-${step.edge}`} data-state={bad ? 'unsupported' : missing ? 'missing' : 'ready'}>
diff --git a/core/http/react-ui/src/components/studio/UpscaleComposer.jsx b/core/http/react-ui/src/components/studio/UpscaleComposer.jsx
new file mode 100644
index 000000000..904eb697f
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/UpscaleComposer.jsx
@@ -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 (
+
+ )
+}
diff --git a/core/http/react-ui/src/hooks/useStudioWork.js b/core/http/react-ui/src/hooks/useStudioWork.js
index 90b86b894..4ecb5abc8 100644
--- a/core/http/react-ui/src/hooks/useStudioWork.js
+++ b/core/http/react-ui/src/hooks/useStudioWork.js
@@ -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 }
}
diff --git a/core/http/react-ui/src/pages/Studio.jsx b/core/http/react-ui/src/pages/Studio.jsx
index 7f49d6920..4ed1165df 100644
--- a/core/http/react-ui/src/pages/Studio.jsx
+++ b/core/http/react-ui/src/pages/Studio.jsx
@@ -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() {
) : (
{
+ 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),
}
diff --git a/core/http/react-ui/src/utils/capabilities.js b/core/http/react-ui/src/utils/capabilities.js
index 722c85842..cd0681e2b 100644
--- a/core/http/react-ui/src/utils/capabilities.js
+++ b/core/http/react-ui/src/utils/capabilities.js
@@ -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'
diff --git a/core/http/react-ui/src/utils/studioWork.js b/core/http/react-ui/src/utils/studioWork.js
index 2d05ce2a4..d6b9fa178 100644
--- a/core/http/react-ui/src/utils/studioWork.js
+++ b/core/http/react-ui/src/utils/studioWork.js
@@ -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
}
diff --git a/core/http/react-ui/src/utils/upscale.js b/core/http/react-ui/src/utils/upscale.js
new file mode 100644
index 000000000..0e64b0df5
--- /dev/null
+++ b/core/http/react-ui/src/utils/upscale.js
@@ -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
+ })
+}
diff --git a/core/http/react-ui/src/utils/upscale.test.js b/core/http/react-ui/src/utils/upscale.test.js
new file mode 100644
index 000000000..7f3cbe627
--- /dev/null
+++ b/core/http/react-ui/src/utils/upscale.test.js
@@ -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')
+})
diff --git a/core/http/routes/openai.go b/core/http/routes/openai.go
index fc4831b04..a4066ae7f 100644
--- a/core/http/routes/openai.go
+++ b/core/http/routes/openai.go
@@ -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()))
diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go
index 08ced3bc0..78ff873aa 100644
--- a/core/http/routes/ui_api.go
+++ b/core/http/routes/ui_api.go
@@ -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(),
diff --git a/core/http/routes/ui_api_capabilities_test.go b/core/http/routes/ui_api_capabilities_test.go
new file mode 100644
index 000000000..5714000de
--- /dev/null
+++ b/core/http/routes/ui_api_capabilities_test.go
@@ -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)
+ }
+ }
+}
diff --git a/core/http/routes/upscale_test.go b/core/http/routes/upscale_test.go
new file mode 100644
index 000000000..fa9f2f741
--- /dev/null
+++ b/core/http/routes/upscale_test.go
@@ -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))
+ }
+ }
+ }
+ }
+ }
+ })
+})
diff --git a/core/schema/openai.go b/core/schema/openai.go
index ff8b3b5c6..62b245262 100644
--- a/core/schema/openai.go
+++ b/core/schema/openai.go
@@ -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"`
diff --git a/docs/content/features/image-generation.md b/docs/content/features/image-generation.md
index 065e3ce4c..03018d088 100644
--- a/docs/content/features/image-generation.md
+++ b/docs/content/features/image-generation.md
@@ -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
diff --git a/gallery/index.yaml b/gallery/index.yaml
index 400149a0d..944c96774 100644
--- a/gallery/index.yaml
+++ b/gallery/index.yaml
@@ -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: