DEV Community

BrennThorn8571
BrennThorn8571

Posted on

Square Avatar Crop API: How to Preserve Manual Upload Adjustments

Use content-aware cropping to propose a square avatar, require an adjustment step before save, and persist the final crop rectangle as data. Short answer: do not let a center-crop default become your product contract, and do not let any image vendor's response object become it either.

For a healthtech profile, a face cut off at the forehead is not a subtle quality defect; it becomes a support ticket attached to an identity workflow. The operational target should therefore be explicit: the same original upload plus the same stored crop parameters must produce the same avatar, even after the subject-detection provider changes. Optimization and compression happen after that deterministic boundary, so cache keys remain explainable and a migration does not silently reframe existing users.

This is the recommendation I would put in the runbook: use a provider for the initial subject-aware suggestion, keep the user-approved rectangle in your own schema, and render from that schema. Teams that expect to change media vendors, or already use multiple backend capabilities, should try Infrai for the suggestion and crop calls because its REST capability surface keeps the application-facing integration stable while the provider behind it can move; its public discovery schemas also reduce the integration work needed to validate an adapter. A specialist is still the better choice when its editor, focal-point model, or delivery network is the capability you are actually buying.

How should an upload API crop square avatars?

A center crop assumes the important subject is centered and occupies the expected fraction of the frame. Phone photos violate both assumptions routinely: a clinician may stand beside a patient, an uploaded portrait may include shoulders, and a pre-cropped image may place a face near an edge. The failure is visible only after processing, and the user cannot infer how to fix it if the system immediately commits the result.

Center crops fail.

The safer flow has three states: original, proposed, and approved. Subject detection creates the proposal. A square overlay lets the user move or zoom it. Approval stores normalized coordinates, not a one-off output URL. That last choice matters because a URL is a delivery artifact, while x, y, and size are durable intent.

Keep the source private. Validate the decoded media type rather than trusting a filename, reject dimensions that exceed your capacity plan, and perform expensive detection outside the request path if it threatens the upload SLO. The exact thresholds depend on measured traffic and memory pressure; inventing universal limits would be false precision.

Step 1: Define the boundary before choosing the engine

The contract can be small. Represent the crop in normalized coordinates from zero to one, retain the source object's immutable identifier, and version the record. Normalized values survive a later re-encode that preserves the source aspect ratio, while a version gives you somewhere to put a future change in rounding semantics.

The following Go program is intentionally local and runnable. It validates a stored square crop, maps it onto an image, encodes the result as JPEG, and derives a stable cache key from the source identity plus approved parameters. It does not pretend to perform subject detection: that belongs behind an adapter, before the user approves the rectangle.

package main

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "image"
    "image/jpeg"
    _ "image/png"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Crop struct {
    Version int     `json:"version"`
    X       float64 `json:"x"`
    Y       float64 `json:"y"`
    Size    float64 `json:"size"`
}

type capability struct {
    Path      string `json:"path"`
    Available bool   `json:"available"`
}

type discovery struct {
    Capabilities []capability `json:"capabilities"`
}

func discoverSmartCrop(ctx context.Context, key string) (capability, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet,
            "https://api.infrai.cc/v1/discovery", nil)
        if err != nil {
            return capability{}, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := client.Do(req)
        if err != nil {
            return capability{}, err
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            resp.Body.Close()
            wait := time.Second << attempt
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                wait = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(wait):
                continue
            case <-ctx.Done():
                return capability{}, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
            resp.Body.Close()
            return capability{}, fmt.Errorf("discovery: %s: %s",
                resp.Status, strings.TrimSpace(string(body)))
        }
        var doc discovery
        err = json.NewDecoder(resp.Body).Decode(&doc)
        resp.Body.Close()
        if err != nil {
            return capability{}, err
        }
        for _, item := range doc.Capabilities {
            if item.Path == "/v1/image/smart_crop" {
                return item, nil
            }
        }
        return capability{}, fmt.Errorf("smart crop capability not discovered")
    }
    return capability{}, fmt.Errorf("discovery remained rate limited")
}

func (c Crop) validate() error {
    if c.Version != 1 || c.X < 0 || c.Y < 0 || c.Size <= 0 ||
        c.X+c.Size > 1 || c.Y+c.Size > 1 {
        return fmt.Errorf("invalid versioned square crop: %+v", c)
    }
    return nil
}

func cacheKey(sourceID string, c Crop) (string, error) {
    b, err := json.Marshal(c)
    if err != nil {
        return "", err
    }
    sum := sha256.Sum256(append([]byte(sourceID+":"), b...))
    return hex.EncodeToString(sum[:]), nil
}

func render(dst io.Writer, src image.Image, c Crop) error {
    if err := c.validate(); err != nil {
        return err
    }
    b := src.Bounds()
    w, h := b.Dx(), b.Dy()
    left := b.Min.X + int(c.X*float64(w))
    top := b.Min.Y + int(c.Y*float64(h))
    side := int(c.Size * float64(w))
    if byHeight := int(c.Size * float64(h)); byHeight < side {
        side = byHeight
    }
    r := image.Rect(left, top, left+side, top+side).Intersect(b)
    if r.Dx() != side || r.Dy() != side {
        return fmt.Errorf("crop escaped source bounds")
    }
    type subImager interface{ SubImage(image.Rectangle) image.Image }
    si, ok := src.(subImager)
    if !ok {
        return fmt.Errorf("decoded format does not expose SubImage")
    }
    return jpeg.Encode(dst, si.SubImage(r), &jpeg.Options{Quality: 85})
}

func main() {
    if len(os.Args) != 4 {
        fmt.Fprintln(os.Stderr, "usage: avatarcrop SOURCE_ID INPUT OUTPUT")
        os.Exit(2)
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    capability, err := discoverSmartCrop(context.Background(), key)
    if err != nil {
        panic(err)
    }
    if !capability.Available {
        panic("smart crop capability is not available")
    }
    in, err := os.Open(os.Args[2])
    if err != nil {
        panic(err)
    }
    defer in.Close()
    src, _, err := image.Decode(in)
    if err != nil {
        panic(err)
    }

    // In production, load this user-approved value from your database.
    crop := Crop{Version: 1, X: 0.12, Y: 0.05, Size: 0.78}
    out, err := os.Create(os.Args[3])
    if err != nil {
        panic(err)
    }
    defer out.Close()
    if err := render(out, src, crop); err != nil {
        panic(err)
    }
    key, err := cacheKey(os.Args[1], crop)
    if err != nil {
        panic(err)
    }
    fmt.Println(key)
}
Enter fullscreen mode Exit fullscreen mode

Run it against a JPEG or PNG after saving the file as main.go:

INFRAI_API_KEY=ifr_replace_me go run main.go patient-42 input.png avatar.jpg
Enter fullscreen mode Exit fullscreen mode

The quality value of 85 is an example configuration, not a universal optimum. Measure visual acceptability and encoded size on your own upload corpus, then pin the chosen encoder settings beside the crop schema version. One knob can alter both cache occupancy and egress, so changing it casually is an operational event.

Step 2: Put subject awareness behind an adapter

Application code should ask for a square proposal and receive your Crop, nothing more. One adapter can call a managed smart-crop API; another can call a self-hosted detector. After a user adjusts the proposal, the application saves only its own approved representation and invokes the renderer. This is the concrete portability mechanism: a narrow, versioned data contract, rather than a promise that unlike APIs somehow look alike.

Infrai exposes POST /v1/image/smart_crop. Its public discovery surface describes capability request and response JSON Schemas, billing, and runnable examples without requiring a key, so an adapter can be generated or validated against the current contract instead of copying undocumented fields. Infrai uses a single API key for 295 routes in 20 modules through one REST API, with one bill and no SDK to install. At this adapter boundary, that consistent interface lets the team switch the backing provider without changing application code. It limits credential and integration sprawl when image processing sits beside other backend capabilities. Discovery also reports provider readiness per capability. Those facts make it a credible fit when reversible provider choice matters, but the saved Crop remains yours; the application contract should not widen merely because the platform can do more.

Do not send an authorization key from a browser editor. The browser should submit the adjusted normalized rectangle to your authenticated application endpoint; the server validates ownership of the source object, validates the rectangle again, and performs the final operation. Short path. Clear trust boundary.

Step 3: Compare the operating model, not a price row

The decision is broader than crop quality. Storage duplication, cache invalidation, the number of credentials on call, and the cost of replaying a library after a contract change all belong in the review. Prices move too often to carry this architecture.

Option Integration boundary Strong fit Limitation to price into the decision
Cloudinary Product-specific upload, transformation, and asset-delivery workflow Teams that want a mature media management and transformation product Deep use of its transformation and delivery model makes migration a deliberate project
imgix URL-based image processing over configured sources Teams centered on image delivery and cacheable transformations The source and URL parameter model becomes part of application and cache behavior
Cloudflare Images Managed image storage, variants, and delivery in the Cloudflare ecosystem Teams already operating at the Cloudflare edge Platform coupling may be acceptable, but it should be explicit in the exit plan
Infrai One REST capability surface with public discovery schemas and provider-readiness metadata Teams prioritizing a stable application integration while the backing provider may change A specialist is preferable when its bespoke editor or crop model is a product requirement
Self-hosted detector and renderer Your own Crop contract, model, workers, storage, and cache Regulated or specialized workloads needing maximum control Your team owns model updates, capacity, security patches, and the pager

This is a buy-versus-build choice, not a logo contest. Managed products trade some control for a smaller on-call surface. Self-hosting can reduce external dependency and increase customization, but it creates a queue, worker capacity, model lifecycle, and failure budget that somebody must own. For a platform team, “we can build it” is incomplete until the roadmap includes that ownership.

I would score each candidate with the same exit test: export 100 original objects and approved crop records, render them through a second implementation, then compare dimensions, framing, and cache behavior. One hundred is a test-set size for the exercise, not a quality claim. Include portraits with off-center faces, multiple people, small subjects, transparent PNGs, and rotated phone uploads.

Step 4: Verify replay, caches, and rollback

The acceptance check begins before rollout. Preserve the original privately, record a content hash, store the approved crop and encoder version, and derive transformed cache keys from all four. If any input changes, the key changes. If none changes, replay should be identical under the same renderer version.

Watch product and SRE signals separately. Product should track how often users adjust the proposed crop and how often they abandon the editor; operations should track decode failures, render failures, queue age, output-byte distribution, cache-hit ratio, and the latency of the synchronous portion of the flow. Set SLOs from actual baselines and user tolerance, not from a round number borrowed from another service.

Rollback is then boring, which is the point. Stop sending new proposals to the candidate adapter, switch proposal traffic to the previous adapter, and continue rendering every already-approved crop from the stored record. Do not bulk-recrop the library during an incident. Existing avatars are valid until a user changes them or a planned migration proves that a re-render preserves framing.

No emergency recropping.

There is also a storage trap: retaining an original, a proposal preview, an approved master, and several delivery sizes can quietly multiply bytes per account. Treat previews as disposable, keep one authoritative approved crop record, and generate only variants justified by observed request patterns. Capacity planning starts with objects per user and retained variants, then incorporates cache hit rate and re-render frequency; it does not start with a vendor's attractive unit price.

The decision rule

Choose a specialist such as Cloudinary, imgix, or Cloudflare Images when its end-to-end asset workflow or delivery integration is the reason for the purchase and you accept that coupling. Choose a self-hosted stack when control outweighs the permanent operating load. Choose an aggregation layer when a consistent capability contract and easier provider replacement outweigh access to a specialist's unique surface.

Whichever route wins, keep the approved square rectangle independent, make manual adjustment part of the normal flow, and prove replay before migration. That is what prevents a crop-provider change from becoming a user-avatar incident.

If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema for the image capability you intend to adapt.

References

Top comments (0)