Originally published on Zenn (Japanese). Cross-posted here.
When you try to generate photorealistic images in ComfyUI, you get "soft detail," "broken faces," and "everything falls apart when you raise the resolution." Most of the time the culprit is a single-shot KSampler. In this article I rebuilt that into a 2-pass Hires fix + FreeU_V2 + 4x-UltraSharp and actually ran it on an M1 Mac (ComfyUI :8000), publishing every node and every parameter (seed included) in a form you can reproduce all the way up to a 2688×3456 output.
If there's just one thing to take away, it's this: the two-stage approach of "generate a small base, upscale it in steps, and tighten the second pass with a denoise of 1.0→0.42" is the minimal, reproducible configuration for eliminating the breakdowns in local photorealistic generation. This article covers only the "foundation" up to the 2-pass Hires. Face locking (IPAdapter / PuLID) — the models are already in stock, but they are unused in this article = the next stage — and I draw that line honestly in the second half.
Up front: this article is a hands-on record of settings and visual comparison. Generation time, VRAM / unified memory consumption, it/s, and any quantitative score against the single-shot version are all unmeasured, so I won't cite numbers for them. The only numbers I cite are the actual values in the WF JSON and the measured pixels of the output PNGs.
The problem: a single-shot KSampler makes photorealistic images soft and broken
Load an SDXL photorealistic model and generate along the shortest path: Checkpoint → KSampler → VAEDecode → Save. Many tutorials take this shape. What comes out is an image that is "stuck at the base resolution, with flat skin, and that breaks down when enlarged." The concrete breakdowns I confirmed with the single-shot version were two: skin goes flat and plastic-like, losing pores and texture, and at 1:1 or larger, strands of hair and the edges of contours blur and melt. Applying an upscale to this state only stretches the blurred information — the detail never comes back.
This configuration is also explicitly prohibited in our internal design-department doctrine. The canonical document that defines the workflow policy for photorealistic and diagram work states:
Prohibited: the textbook single-shot KSampler (professional multi-stage = Hires fix 2 passes + IPAdapter / PuLID face locking + 4x-UltraSharp are mandatory).
— from our internal design department's canonical photorealistic workflow ("the single-shot KSampler is prohibited")
Why is the single shot bad? SDXL is trained around the 1024px class, so aiming straight for the final resolution stabilizes neither composition nor detail. On the other hand, hammering a high-resolution EmptyLatent in one go either breaks (e.g. the same face appearing twice) or eats the M1's memory all at once. What avoids both at the same time is the 2-pass Hires fix. We look at the difference in the comparison figure and crop image below.
The overall solution: 2-pass Hires fix + FreeU_V2 + 4x-UltraSharp
Dropping the single shot, I added three weapons.
- Two passes: generate the base a bit small (896×1152), upscale the latent by 1.5×, then add detail in the second pass.
- FreeU_V2: a quiet move that adjusts the UNet's skip connections to boost detail.
- 4x-UltraSharp: lift by 4× in pixel space, then bring it back down by 0.5× to condense the detail.
Models are switched by purpose. Photorealistic = RealVisXL_V5.0_fp16; diagrams and text-in-image = flux1-dev. The SDXL family is weak at the thin lines and characters of diagrams, so the criterion is not to force photorealistic and diagram work through the same model (this article is the photorealistic = RealVisXL line).

Figure ① (verified — the star of this article's visual verification): a node-flow comparison of the single-shot KSampler (left / bad version) and the actual 2-pass Hires configuration (right / good version). Node names and parameters are the actual values from PRO_RealVisXL_2pass.json.
The full node configuration and actual parameters (reproducible as-is)
I list the node sequence of the PRO_RealVisXL_2pass.json I actually ran, from top to bottom, all with their actual values. Build with the same values and you get the same chain (the API-format JSON and the Python script pro_gen_realvisxl_2pass.py that is fed into :8000 match on identical parameters).
| # | class_type | key inputs (actual values) |
|---|---|---|
| 1 | CheckpointLoaderSimple | RealVisXL_V5.0_fp16.safetensors |
| 2 | FreeU_V2 | b1=1.3, b2=1.4, s1=0.9, s2=0.2 |
| 3 | CLIPTextEncode (pos) | cinematic editorial portrait, 85mm lens, detailed realistic skin texture with pores, film grain … |
| 4 | CLIPTextEncode (neg) | worst quality, blurry, deformed hands, plastic skin, waxy, cartoon, 3d render … |
| 5 | EmptyLatentImage | width=896, height=1152, batch_size=1 |
| 6 | KSampler ① | seed=770077, steps=32, cfg=5.5, sampler=dpmpp_2m_sde, scheduler=karras, denoise=1.0 |
| 7 | LatentUpscaleBy | method=nearest-exact, scale_by=1.5 |
| 8 | KSampler ② (Hires fix) | seed=770077, steps=20, cfg=5.5, dpmpp_2m_sde / karras, denoise=0.42 |
| 9 | VAEDecode | latent → pixel |
| 10 | UpscaleModelLoader | 4x-UltraSharp.pth |
| 11 | ImageUpscaleWithModel | ×4 with 4x-UltraSharp |
| 12 | ImageScaleBy | method=area, scale_by=0.5 |
| 13 | SaveImage | filename_prefix=PRO_RealVisXL_2pass |
Pulling out just the difference between the two KSamplers looks like this. This is the heart of the 2-pass approach.
# Pass 1: build the composition from scratch (denoise = 1.0)
KSampler(seed=770077, steps=32, cfg=5.5,
sampler_name="dpmpp_2m_sde", scheduler="karras", denoise=1.0)
LatentUpscaleBy(upscale_method="nearest-exact", scale_by=1.5)
# Pass 2 = Hires fix: keep the composition, add only detail (denoise = 0.42)
KSampler(seed=770077, steps=20, cfg=5.5,
sampler_name="dpmpp_2m_sde", scheduler="karras", denoise=0.42)
The key to the prompt is to "explicitly call for skin texture, and kill plastic skin and the 3D-render look with the negative."
positive: … detailed realistic skin texture with pores, film grain, 85mm lens …
negative: worst quality, blurry, deformed hands, plastic skin, waxy, cartoon, 3d render …
The resolution chain: computed from actual values (matches the measured output)
Multiplying the scale values of each node gives the final resolution. This matches the measured pixels of the output PNG exactly.

Figure ② (verified): the resolution chain. 896×1152 →(latent×1.5)→ 1344×1728 →(4x-UltraSharp)→ 5376×6912 →(area×0.5)→ **2688×3456. This computed value matches the sips-measured pixels of the output PNG.
Why these particular steps (design intent readable from the actual values)
I write the "why" of the parameters only to the extent I can state it from actual values, not guesswork.
The meaning of denoise 1.0 → 0.42
Pass 1 is denoise=1.0 = build the composition completely from noise. Pass 2 is denoise=0.42 = destroy only 42% of the input (the upscaled latent) to add detail. Set this too high and the composition and face from pass 1 change; set it too low and it's just a blurry upscale. 0.42 is the practical operating value that "keeps the composition while layering on detail."
Why lift to 4× and then tighten with 0.5×
With 4x-UltraSharp, lift once to 5376×6912, then bring it back by 0.5× using the area method. Rather than aiming for an effective 2× from the start, deliberately creating an excess resolution and then shrinking it condenses detail by area averaging, smoothing out edge jaggies and the noise characteristic of upscalers. The effective magnification is 2×, but the way it tightens up changes depending on the path.
The two-stage approach of keeping the base at 896×1152 and stepping up with latent×1.5
Instead of hammering a large EmptyLatent from the start, step through 896×1152 → latent×1.5. This reads as a design that avoids eating the M1's unified memory all at once. That said, the causal link to avoiding OOM is a design-level inference, and VRAM / memory consumption is unmeasured. Here I separate the fact ("it is built this way") from the inference ("probably for this reason").
[Retraction] The doctrine says "face locking is mandatory," but the actual WF has no face-locking node
This is the backbone of the article. The doctrine cited above says "professional multi-stage = Hires fix 2 passes + IPAdapter / PuLID face locking + 4x-UltraSharp are mandatory." And yet, the PRO_RealVisXL_2pass.json I actually ran contains not a single IPAdapter / PuLID node. What it contains is only FreeU_V2 + 2-pass Hires + 4x-UltraSharp.
In other words, having written "face locking is mandatory" myself, I was running the foundation WF without any face locking. I judged that writing this openly as a stage improves reproducibility. The framing is this:
- Face locking (IPAdapter-FaceID / PuLID) is a separate-line weapon. First, with just the foundation's 2-pass Hires, the goal this time is to see how far the photorealistic breakdowns (blur, flatness, enlargement breakdown) disappear.
- The face-locking models are already in stock and can be connected as the next stage (the
models/ipadapter,models/pulid, andmodels/insightfacedirectories actually exist).
Explicitly stating "I haven't achieved the full doctrine-compliant loadout" as the endpoint keeps readers from getting lost when they build up their own environment in stages. Rather than showing the fully loaded finished form right away, isolating how far the foundation works is more practical.
The actual output and the evidence that it reproduces
Here are the actual things I ran. The measured pixels of the 2 output PNGs match the WF's computed chain (Figure ②). This is the evidence that it "reproduces."
| Actual item / dependency | measured value |
|---|---|
| PRO_RealVisXL_2pass_00001_.png (Jul 13) | sips-measured 2688×3456 · approx. 11.8MB |
| PRO_RealVisXL_2pass_00002_.png (Jul 14) | sips-measured 2688×3456 · approx. 11.8MB |
| RealVisXL_V5.0_fp16.safetensors | approx. 6.94GB |
| 4x-UltraSharp.pth | approx. 67MB |
| Platform | ComfyUI :8000 resident via launchd / base_path=/path/to/ComfyUI
|

A 1:1 crop of the single-shot KSampler output (left · denoise1.0 · no Hires · 896×1152 naively enlarged) vs the 2-pass Hires + 4x-UltraSharp output (right) (same seed=770077, same prompt, same crop position). On the left the contours, hair, and skin are blurry; on the right it resolves sharply down to individual hairs and skin texture. Also see the node-configuration diagram in Figure ① and the actual-output resolution chain in Figure ② (which matches the output PNG's measured 2688×3456). I speak of the difference by visual comparison only (quantitative score unmeasured).
Being honest: what I did not measure
To prevent misreading, I spell out the range where I do not cite numbers.
- Generation time: unmeasured. The polling in the submission script is a timeout ceiling of "3 seconds × up to 120 times = 360 seconds," not a measured time. So I won't write "about N minutes."
- VRAM / unified memory consumption: unmeasured (no log).
- it/s · sampling speed: unmeasured.
- Quantitative comparison score against the single-shot version: unmeasured. I speak of the quality difference only through visual comparison of the output images.
Summary: the reproducible takeaways
- The main cause of local photorealistic breakdown is the single-shot KSampler. A 2-pass Hires fix (denoise 1.0 → 0.42) is the minimal effective move.
- Condense detail with FreeU_V2 + 4x-UltraSharp (lift ×4, then tighten with ×0.5).
- On the M1, step through a base of 896×1152 → latent×1.5, and don't create a large-resolution latent all at once.
- Publishing all parameters including seed=770077 = anyone can reproduce with the same values, and it matches the measured output of 2688×3456.
- Face locking (IPAdapter / PuLID) is the next stage on top of the foundation. Looking, in stages, at how far the foundation's 2 passes alone eliminate the breakdowns is honest and easy to implement.
The next stage connects the in-stock face-locking line (IPAdapter-FaceID / PuLID) onto this 2-pass foundation. This article goes only as far as the full disclosure of that foundation. Build with the same values, and first confirm the foundation's effect with your own eyes.
Top comments (0)