Skip to content

Commit 8a68f35

Browse files
feat(api): add POST /v1/images/upscale endpoint (#10227)
* feat(api): add POST /v1/images/upscale endpoint Add a new image upscaling endpoint that accepts a source image and returns an upscaled version. Supports selectable upscaler models (e.g. realesrgan) and a configurable scale factor (2x or 4x). - backend.proto: add UpscaleImage RPC and UpscaleImageRequest message - pkg/grpc: implement UpscaleImage in Backend interface, client, server and embed shim - core/backend/upscale.go: new backend helper (mirrors ImageGeneration) - core/http/endpoints/openai/upscale.go: new multipart/form-data handler - core/http/routes/openai.go: register POST /v1/images/upscale - core/http/auth/features.go: gate upscale routes under FeatureImages - backend/python/diffusers/backend.py: implement UpscaleImage — uses diffusers upscale pipeline when loaded, falls back to Lanczos resize * fix(grpc): add UpscaleImage stub to Base backend All Go backends embedding Base now satisfy the AIModel interface without needing to implement UpscaleImage explicitly. * fix(images): complete upscale endpoint integration Store generated upscales under the served images directory, validate scale factors, document and advertise the endpoint, and add a functional Stable Diffusion x4 gallery model. Assisted-by: Codex:gpt-5 --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
1 parent fd4ec08 commit 8a68f35

22 files changed

Lines changed: 577 additions & 1 deletion

File tree

backend/backend.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ service Backend {
1515
rpc PredictStream(PredictOptions) returns (stream Reply) {}
1616
rpc Embedding(PredictOptions) returns (EmbeddingResult) {}
1717
rpc GenerateImage(GenerateImageRequest) returns (Result) {}
18+
rpc UpscaleImage(UpscaleImageRequest) returns (Result) {}
1819
rpc GenerateVideo(GenerateVideoRequest) returns (Result) {}
1920
rpc Generate3D(Generate3DRequest) returns (Result) {}
2021
rpc AudioTranscription(TranscriptRequest) returns (TranscriptResult) {}
@@ -637,6 +638,12 @@ message GenerateImageRequest {
637638
string ModelIdentity = 13;
638639
}
639640

641+
message UpscaleImageRequest {
642+
string src = 1; // input image path
643+
string dst = 2; // output image path
644+
int32 scale = 3; // upscale factor (e.g. 2 or 4)
645+
}
646+
640647
message GenerateVideoRequest {
641648
string prompt = 1;
642649
string negative_prompt = 2; // Negative prompt for video generation

backend/python/diffusers/backend.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,34 @@ def GenerateImage(self, request, context):
883883

884884
return backend_pb2.Result(message="Media generated", success=True)
885885

886+
def UpscaleImage(self, request, context):
887+
try:
888+
if not request.src:
889+
return backend_pb2.Result(success=False, message="No source image provided")
890+
if not request.dst:
891+
return backend_pb2.Result(success=False, message="No destination path provided")
892+
893+
scale = request.scale if request.scale > 0 else 2
894+
image = Image.open(request.src).convert("RGB")
895+
896+
# If the loaded pipeline supports upscaling (e.g. StableDiffusionUpscalePipeline),
897+
# use it; otherwise fall back to high-quality Lanczos resize.
898+
if self.pipe is not None and self.PipelineType in ("StableDiffusionUpscalePipeline", "StableDiffusionLatentUpscalePipeline"):
899+
print(f"UpscaleImage: using diffusers upscale pipeline ({self.PipelineType})", file=sys.stderr)
900+
upscaled = self.pipe(prompt="", image=image).images[0]
901+
else:
902+
# Fallback: high-quality Lanczos resize
903+
print(f"UpscaleImage: no upscale pipeline loaded, using Lanczos resize (scale={scale})", file=sys.stderr)
904+
new_w = image.width * scale
905+
new_h = image.height * scale
906+
upscaled = image.resize((new_w, new_h), Image.LANCZOS)
907+
908+
upscaled.save(request.dst)
909+
return backend_pb2.Result(message="Image upscaled", success=True)
910+
except Exception as e:
911+
print(f"UpscaleImage error: {e}", file=sys.stderr)
912+
return backend_pb2.Result(success=False, message=str(e))
913+
886914
def GenerateVideo(self, request, context):
887915
try:
888916
prompt = request.prompt

core/backend/upscale.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package backend
2+
3+
import (
4+
"context"
5+
6+
"github.com/mudler/LocalAI/core/config"
7+
"github.com/mudler/LocalAI/pkg/grpc/proto"
8+
model "github.com/mudler/LocalAI/pkg/model"
9+
)
10+
11+
// ImageUpscale loads the model specified in modelConfig and calls UpscaleImage
12+
// on the backend, writing the result to dst.
13+
func ImageUpscale(ctx context.Context, src, dst string, scale int, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func() error, error) {
14+
opts := ModelOptions(modelConfig, appConfig, model.WithContext(ctx))
15+
inferenceModel, err := loader.Load(opts...)
16+
if err != nil {
17+
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
18+
return nil, err
19+
}
20+
21+
fn := func() error {
22+
_, err := inferenceModel.UpscaleImage(
23+
ctx,
24+
&proto.UpscaleImageRequest{
25+
Src: src,
26+
Dst: dst,
27+
Scale: int32(scale),
28+
},
29+
)
30+
return err
31+
}
32+
33+
return fn, nil
34+
}
35+
36+
// ImageUpscaleFunc is a test-friendly indirection.
37+
var ImageUpscaleFunc = ImageUpscale

core/config/backend_capabilities.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const (
4444
MethodPredictStream GRPCMethod = "PredictStream"
4545
MethodEmbedding GRPCMethod = "Embedding"
4646
MethodGenerateImage GRPCMethod = "GenerateImage"
47+
MethodUpscaleImage GRPCMethod = "UpscaleImage"
4748
MethodGenerateVideo GRPCMethod = "GenerateVideo"
4849
MethodGenerate3D GRPCMethod = "Generate3D"
4950
MethodAudioTranscription GRPCMethod = "AudioTranscription"
@@ -348,7 +349,7 @@ var BackendCapabilities = map[string]BackendCapability{
348349

349350
// --- Image/video generation backends ---
350351
"diffusers": {
351-
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodGenerateVideo},
352+
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodUpscaleImage, MethodGenerateVideo},
352353
PossibleUsecases: []string{UsecaseImage, UsecaseVideo},
353354
DefaultUsecases: []string{UsecaseImage},
354355
Description: "HuggingFace diffusers — Stable Diffusion, Flux, video generation",

core/http/auth/features.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ var RouteFeatureRegistry = []RouteFeature{
3939
{"POST", "/images/generations", FeatureImages},
4040
{"POST", "/v1/images/inpainting", FeatureImages},
4141
{"POST", "/images/inpainting", FeatureImages},
42+
{"POST", "/v1/images/upscale", FeatureImages},
43+
{"POST", "/images/upscale", FeatureImages},
4244

4345
// Audio transcription
4446
{"POST", "/v1/audio/transcriptions", FeatureAudioTranscription},
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package openai
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"net/url"
8+
"os"
9+
"path/filepath"
10+
"strconv"
11+
"time"
12+
13+
"github.com/google/uuid"
14+
"github.com/labstack/echo/v4"
15+
"github.com/mudler/xlog"
16+
17+
"github.com/mudler/LocalAI/core/backend"
18+
"github.com/mudler/LocalAI/core/config"
19+
"github.com/mudler/LocalAI/core/http/middleware"
20+
"github.com/mudler/LocalAI/core/schema"
21+
model "github.com/mudler/LocalAI/pkg/model"
22+
)
23+
24+
// UpscaleEndpoint handles POST /v1/images/upscale
25+
//
26+
// @Summary Image upscaling
27+
// @Description Upscale an image using a specified model (e.g. stable-diffusion-x4-upscaler). Accepts multipart/form-data.
28+
// @Tags images
29+
// @Accept multipart/form-data
30+
// @Produce application/json
31+
// @Param model formData string true "Upscaler model identifier (e.g. stable-diffusion-x4-upscaler)"
32+
// @Param image formData file true "Input image file"
33+
// @Param scale formData int false "Upscale factor: 2 or 4 (default 2)"
34+
// @Success 200 {object} schema.OpenAIResponse
35+
// @Failure 400 {object} map[string]string
36+
// @Failure 500 {object} map[string]string
37+
// @Router /v1/images/upscale [post]
38+
func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
39+
return func(c echo.Context) error {
40+
modelName := c.FormValue("model")
41+
scaleStr := c.FormValue("scale")
42+
43+
if modelName == "" {
44+
xlog.Error("Upscale Endpoint - missing model")
45+
return echo.NewHTTPError(http.StatusBadRequest, "missing model")
46+
}
47+
48+
scale := 2
49+
if scaleStr != "" {
50+
v, err := strconv.Atoi(scaleStr)
51+
if err != nil || (v != 2 && v != 4) {
52+
return echo.NewHTTPError(http.StatusBadRequest, "scale must be 2 or 4")
53+
}
54+
scale = v
55+
}
56+
57+
// Read uploaded image
58+
imageFile, err := c.FormFile("image")
59+
if err != nil {
60+
xlog.Error("Upscale Endpoint - missing image file", "error", err)
61+
return echo.NewHTTPError(http.StatusBadRequest, "missing image file")
62+
}
63+
64+
imgSrc, err := imageFile.Open()
65+
if err != nil {
66+
return err
67+
}
68+
defer imgSrc.Close()
69+
imgBytes, err := io.ReadAll(imgSrc)
70+
if err != nil {
71+
return err
72+
}
73+
74+
// Get model config from middleware context
75+
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
76+
if !ok || cfg == nil {
77+
xlog.Error("Upscale Endpoint - model config not found in context")
78+
return echo.ErrBadRequest
79+
}
80+
81+
tmpDir := filepath.Join(appConfig.GeneratedContentDir, "images")
82+
if err := os.MkdirAll(tmpDir, 0750); err != nil {
83+
return echo.NewHTTPError(http.StatusInternalServerError, "failed to prepare storage")
84+
}
85+
86+
// Write input image to a temp file
87+
srcTmp, err := os.CreateTemp(tmpDir, "upscale_src_")
88+
if err != nil {
89+
return err
90+
}
91+
if _, err := srcTmp.Write(imgBytes); err != nil {
92+
_ = srcTmp.Close()
93+
_ = os.Remove(srcTmp.Name())
94+
return err
95+
}
96+
if err := srcTmp.Close(); err != nil {
97+
xlog.Warn("Upscale Endpoint - failed to close src temp file", "error", err)
98+
}
99+
srcPath := srcTmp.Name()
100+
defer os.Remove(srcPath)
101+
102+
// Prepare output file path
103+
id := uuid.New().String()
104+
dstPath := filepath.Join(tmpDir, fmt.Sprintf("upscale_%s.png", id))
105+
106+
fn, err := backend.ImageUpscaleFunc(c.Request().Context(), srcPath, dstPath, scale, ml, *cfg, appConfig)
107+
if err != nil {
108+
return err
109+
}
110+
if err := fn(); err != nil {
111+
_ = os.Remove(dstPath)
112+
return err
113+
}
114+
115+
baseURL := middleware.BaseURL(c)
116+
imgURL, err := url.JoinPath(baseURL, "generated-images", filepath.Base(dstPath))
117+
if err != nil {
118+
_ = os.Remove(dstPath)
119+
return err
120+
}
121+
122+
created := int(time.Now().Unix())
123+
resp := &schema.OpenAIResponse{
124+
ID: id,
125+
Created: created,
126+
Data: []schema.Item{{URL: imgURL}},
127+
Usage: &schema.OpenAIUsage{
128+
InputTokensDetails: &schema.InputTokensDetails{},
129+
},
130+
}
131+
132+
return c.JSON(http.StatusOK, resp)
133+
}
134+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package openai
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"net/http"
8+
"net/http/httptest"
9+
"os"
10+
"path/filepath"
11+
12+
"github.com/labstack/echo/v4"
13+
"github.com/mudler/LocalAI/core/backend"
14+
"github.com/mudler/LocalAI/core/config"
15+
"github.com/mudler/LocalAI/core/http/middleware"
16+
"github.com/mudler/LocalAI/core/schema"
17+
model "github.com/mudler/LocalAI/pkg/model"
18+
. "github.com/onsi/ginkgo/v2"
19+
. "github.com/onsi/gomega"
20+
)
21+
22+
var _ = Describe("Image upscaling", func() {
23+
var (
24+
appConfig *config.ApplicationConfig
25+
tmpDir string
26+
)
27+
28+
BeforeEach(func() {
29+
var err error
30+
tmpDir, err = os.MkdirTemp("", "upscale")
31+
Expect(err).ToNot(HaveOccurred())
32+
appConfig = config.NewApplicationConfig(config.WithGeneratedContentDir(tmpDir))
33+
})
34+
35+
AfterEach(func() {
36+
Expect(os.RemoveAll(tmpDir)).To(Succeed())
37+
})
38+
39+
It("stores the result in the directory served by /generated-images", func() {
40+
original := backend.ImageUpscaleFunc
41+
backend.ImageUpscaleFunc = func(_ context.Context, _, dst string, scale int, _ *model.ModelLoader, _ config.ModelConfig, _ *config.ApplicationConfig) (func() error, error) {
42+
Expect(scale).To(Equal(4))
43+
return func() error {
44+
return os.WriteFile(dst, []byte("PNGDATA"), 0o644)
45+
}, nil
46+
}
47+
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
48+
49+
req, _ := makeMultipartRequest(
50+
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "4"},
51+
map[string][]byte{"image": []byte("IMAGEDATA")},
52+
)
53+
rec := httptest.NewRecorder()
54+
ctx := echo.New().NewContext(req, rec)
55+
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
56+
57+
Expect(UpscaleEndpoint(nil, nil, appConfig)(ctx)).To(Succeed())
58+
Expect(rec.Code).To(Equal(http.StatusOK))
59+
60+
var response schema.OpenAIResponse
61+
Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed())
62+
Expect(response.Data).To(HaveLen(1))
63+
Expect(response.Data[0].URL).To(ContainSubstring("/generated-images/upscale_"))
64+
65+
filename := filepath.Base(response.Data[0].URL)
66+
contents, err := os.ReadFile(filepath.Join(tmpDir, "images", filename))
67+
Expect(err).ToNot(HaveOccurred())
68+
Expect(contents).To(Equal([]byte("PNGDATA")))
69+
})
70+
71+
It("rejects unsupported scale factors", func() {
72+
req, _ := makeMultipartRequest(
73+
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "3"},
74+
map[string][]byte{"image": []byte("IMAGEDATA")},
75+
)
76+
rec := httptest.NewRecorder()
77+
ctx := echo.New().NewContext(req, rec)
78+
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
79+
80+
err := UpscaleEndpoint(nil, nil, appConfig)(ctx)
81+
var httpErr *echo.HTTPError
82+
Expect(err).To(MatchError(ContainSubstring("scale must be 2 or 4")))
83+
Expect(err).To(BeAssignableToTypeOf(httpErr))
84+
httpErr = err.(*echo.HTTPError)
85+
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
86+
Expect(httpErr.Message).To(Equal("scale must be 2 or 4"))
87+
Expect(bytes.TrimSpace(rec.Body.Bytes())).To(BeEmpty())
88+
})
89+
})

core/http/routes/openai.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,11 @@ func RegisterOpenAIRoutes(app *echo.Echo,
254254
app.POST("/v1/images/inpainting", inpaintingHandler, imageMiddleware...)
255255
app.POST("/images/inpainting", inpaintingHandler, imageMiddleware...)
256256

257+
// upscale endpoint - reuse same middleware config as images
258+
upscaleHandler := openai.UpscaleEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
259+
app.POST("/v1/images/upscale", upscaleHandler, imageMiddleware...)
260+
app.POST("/images/upscale", upscaleHandler, imageMiddleware...)
261+
257262
// List models
258263
app.GET("/v1/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.AuthDB()))
259264
app.GET("/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.AuthDB()))

core/services/nodes/health_mock_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ func (c *fakeBackendClient) Predict(_ context.Context, _ *pb.PredictOptions, _ .
154154
func (c *fakeBackendClient) GenerateImage(_ context.Context, _ *pb.GenerateImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
155155
return nil, nil
156156
}
157+
func (c *fakeBackendClient) UpscaleImage(_ context.Context, _ *pb.UpscaleImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
158+
return nil, nil
159+
}
157160
func (c *fakeBackendClient) GenerateVideo(_ context.Context, _ *pb.GenerateVideoRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
158161
return nil, nil
159162
}

core/services/nodes/inflight.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ func (c *InFlightTrackingClient) GenerateImage(ctx context.Context, in *pb.Gener
138138
return res, c.reconcile(err)
139139
}
140140

141+
func (c *InFlightTrackingClient) UpscaleImage(ctx context.Context, in *pb.UpscaleImageRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
142+
defer c.track(ctx)()
143+
res, err := c.inner.UpscaleImage(ctx, in, opts...)
144+
return res, c.reconcile(err)
145+
}
146+
141147
func (c *InFlightTrackingClient) GenerateVideo(ctx context.Context, in *pb.GenerateVideoRequest, opts ...ggrpc.CallOption) (*pb.Result, error) {
142148
defer c.track(ctx)()
143149
res, err := c.inner.GenerateVideo(ctx, in, opts...)

0 commit comments

Comments
 (0)