Post

Syncing AD Profile Pictures to Local Windows Accounts (CodeTwo + PowerShell)

I run CodeTwo in my homelab AD domain, mostly just to mess with email signature templates, but it also happens to do a decent job of pushing profile photos into the thumbnailPhoto attribute for each user. Great for Outlook — everyone’s photo shows up there. What it does not do is put that picture anywhere Windows itself looks: the lock screen, the Start menu tile, the login screen avatar. Those are all driven by a set of resized local .jpg files and a registry key per user, and nothing syncs that automatically.

So there’s a gap between “photo lands in AD” and “photo actually shows up on the machine I’m sitting at.” I closed it with a small PowerShell script, logoff.ps1 (yes, it runs at logon — the name is a leftover from an earlier version and I never got around to fixing it), deployed as a Scheduled Task logon trigger.

What it needs to do

  1. Find every real user profile on the machine (not Public, not the default profile template).
  2. Look each one up in AD and check for a thumbnailPhoto.
  3. Resize that photo into every size Windows expects.
  4. Drop the files locally and point the right registry key at them.

Resizing the image

Windows doesn’t take one arbitrary image for the account picture — it wants specific pixel sizes depending on where it’s rendering the avatar. The resize function itself is fairly boilerplate System.Drawing:

1
2
3
4
5
$bmp = [System.Drawing.Image]::FromStream($ms, $true)
# ... calculate aspect-ratio-preserving width/height ...
$graph.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graph.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$graph.DrawImage($bmp, 0, 0 , $newWidth, $newHeight)

While cleaning this script up I noticed an old $ms.Write($imageBytes, ...) call sitting right after the MemoryStream constructor — which already loads $imageBytes. That line was just appending a second copy of the image onto the end of the stream for no reason, harmlessly wasting memory on every single run. Removed it.

The list of sizes is the part actually worth writing down somewhere:

1
$img_sizes = @(32, 40, 48, 96, 192, 200, 240, 448)

200 and 448 are the two that caught me out. Leave either one off and certain Windows builds quietly fall back to the default grey silhouette instead of erroring — which makes it look like a permissions or caching problem when it’s actually just a missing size.

Finding every profile and pulling from AD

Rather than hardcoding a username, the script enumerates every real local profile on the machine with Get-CimInstance -ClassName Win32_UserProfile, filters out system/default accounts, resolves the SID to a SAM account name, and looks that user up in AD:

1
2
3
$UserProfiles = Get-CimInstance -ClassName Win32_UserProfile | Where-Object {
    $_.Special -eq $false -and $_.LocalPath -notmatch "Public" -and $_.LocalPath -notmatch "defaultuser"
}

Because it loops over all profiles on the machine rather than just the currently logged-in user, this also catches shared/kiosk-style machines with several accounts on them, not just single-user devices. For each profile with a photo, the script:

  1. Creates a hidden folder under C:\Users\Public\AccountPictures\<SID>.
  2. Writes a resized .jpg for every size in the list above.
  3. Writes a matching HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AccountPicture\Users\<SID> registry value pointing at each file.

Deployment: CodeTwo feeds it, Task Scheduler runs it

The actual pipeline is pretty simple end to end:

  • CodeTwo writes the photo into thumbnailPhoto in AD — that part’s already handled, this script doesn’t touch it.
  • Task Scheduler runs logoff.ps1 on a logon trigger on each domain PC, so every sign-in refreshes that user’s local picture from whatever’s currently in AD.

Update a photo once wherever it feeds into CodeTwo, and it rolls out to every machine I log into around the house, no manual per-PC work required.

Gotchas I ran into

  • Silent success on a stale file. In the write loop, a failed Set-Content falls into a catch block that checks Test-Path on the target file — and if an old file from a previous run is still sitting there, it gets counted as a success and the registry still points at it. That can mask a real permissions or file-lock issue behind a picture that just never actually updates. I want to tighten this to check a fresh write timestamp instead of “a file exists,” but haven’t circled back to it yet.
  • No AD line-of-sight, no update. The AD lookup uses ADSISearcher, which needs a domain controller reachable at logon time. A laptop signing in off VPN just skips that user for the run — silently, aside from a Write-Warning — so don’t be surprised if remote logons lag behind.
  • Image quality is hardcoded to 100 for every size, including the 32px icon, which is overkill but harmless — just a slightly bigger file on disk than it needs to be.
  • Cleanup matters more than it looks. ResizeImage runs once per size per user, so every Bitmap/Graphics/MemoryStream gets explicitly disposed at the end of the function — skip that and a machine with a dozen profiles will leak GDI+ handles fast.

Full script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
Function ResizeImage {
    Param (
        [Parameter(Mandatory = $True, HelpMessage = "The image as bytes")]
        [ValidateNotNull()]
        $imageSource,
        
        [Parameter(Mandatory = $true, HelpMessage = "The canvas size can be between 16px and 1000px")]
        [ValidateRange(16, 1000)]
        $canvasSize,
        
        [Parameter(Mandatory = $true, HelpMessage = "The image quality can be between 1 and 100")]
        [ValidateRange(1, 100)]
        $ImgQuality = 100
    )
    
    try {
        [void][System.Reflection.Assembly]::LoadWithPartialName("System.Drawing")
        $imageBytes = [byte[]]$imageSource
        $ms = New-Object IO.MemoryStream($imageBytes, 0, $imageBytes.Length)
        
        $bmp = [System.Drawing.Image]::FromStream($ms, $true)
        
        $canvasWidth = $canvasSize
        $canvasHeight = $canvasSize
        
        $myEncoder = [System.Drawing.Imaging.Encoder]::Quality
        $encoderParams = New-Object System.Drawing.Imaging.EncoderParameters(1)
        $encoderParams.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter($myEncoder, $ImgQuality)
        
        $myImageCodecInfo = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
        
        $ratioX = $canvasWidth / $bmp.Width;
        $ratioY = $canvasHeight / $bmp.Height;
        $ratio = if ($ratioX -le $ratioY) { $ratioX } else { $ratioY }
        
        $newWidth = [int] ($bmp.Width * $ratio)
        $newHeight = [int] ($bmp.Height * $ratio)
        
        $bmpResized = New-Object System.Drawing.Bitmap($newWidth, $newHeight)
        $graph = [System.Drawing.Graphics]::FromImage($bmpResized)
        
        $graph.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
        $graph.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
        $graph.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
        
        $graph.Clear([System.Drawing.Color]::White)
        $graph.DrawImage($bmp, 0, 0 , $newWidth, $newHeight)
        
        $outStream = New-Object IO.MemoryStream
        $bmpResized.Save($outStream, $myImageCodecInfo, $($encoderParams))
        
        $resultBytes = $outStream.ToArray()
        
        $graph.Dispose()
        $bmpResized.Dispose()
        $bmp.Dispose()
        $outStream.Dispose()
        $ms.Dispose()
        
        return $resultBytes
    }
    catch {
        Write-Warning "Interner Fehler in ResizeImage-Funktion: $_"
        return $null
    }
}

# --- PRODUKTIONS-LOGIK: ERMITTELT ALLE REALEN BENUTZER AUF DIESEM PC ---
$UserProfiles = Get-CimInstance -ClassName Win32_UserProfile | Where-Object { 
    $_.Special -eq $false -and $_.LocalPath -notmatch "Public" -and $_.LocalPath -notmatch "defaultuser"
}

Write-Host "Gefundene Profile auf diesem PC: $($UserProfiles.Count)" -ForegroundColor Cyan

ForEach ($Profile in $UserProfiles) {
    $ADUserInfo_sid = $Profile.SID
    
    try {
        $UserAccount = [System.Security.Principal.SecurityIdentifier]($ADUserInfo_sid)
        $FullUserName = $UserAccount.Translate([System.Security.Principal.NTAccount]).Value
        $SamAccountName = $FullUserName.Split("\")[-1]
        
        Write-Host "`n=== Verarbeite Benutzer: $FullUserName ($ADUserInfo_sid) ===" -ForegroundColor Yellow
    } catch {
        Write-Warning "Konnte SID $ADUserInfo_sid keinem Windows-Konto zuordnen. Überspringe..."
        continue
    }

    try {
        $ADUserInfo = ([ADSISearcher]"(&(objectCategory=User)(SAMAccountName=$SamAccountName))").FindOne().Properties
    } catch {
        Write-Warning "Fehler beim AD-Zugriff für Benutzer $SamAccountName. Überspringe..."
        continue
    }

    If ($ADUserInfo.thumbnailphoto) {
        Write-Host "-> thumbnailPhoto (CodeTwo) im AD gefunden. Konvertierung gestartet..." -ForegroundColor Green
        
        $img_sizes = @(32, 40, 48, 96, 192, 200, 240, 448)
        $img_base = "C:\Users\Public\AccountPictures"
        $reg_key = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AccountPicture\Users\$ADUserInfo_sid"

        If ((Test-Path -Path $img_base) -eq $false) { 
            New-Item -ItemType directory -Path $img_base -Force | Out-Null 
        }

        If ((Test-Path -Path $reg_key) -eq $false) { 
            New-Item -Path $reg_key -Force | Out-Null 
        }

        Try {
            ForEach ($size in $img_sizes) {
                $dir = $img_base + "\" + $ADUserInfo_sid
                If ((Test-Path -Path $dir) -eq $false) { 
                    $newDir = New-Item -ItemType directory -Path $dir -Force
                    $newDir.Attributes = "Hidden"
                }
                $file_name = "Image$($size).jpg"
                $path = $dir + "\" + $file_name
                
                if (Test-Path -Path $path) {
                    try {
                        Remove-Item -Path $path -Force -ErrorAction SilentlyContinue
                    } catch {}
                }

                $fileCreatedSuccessfully = $false
                try {
                    $resizedBytes = ResizeImage -imageSource $($ADUserInfo.thumbnailphoto) -canvasSize $size -ImgQuality 100
                    if ($null -ne $resizedBytes) {
                        $resizedBytes | Set-Content -Path $path -Encoding Byte -Force -ErrorAction Stop
                        $fileCreatedSuccessfully = $true
                    }
                }
                catch {
                    If (Test-Path -Path $path) {
                        $fileCreatedSuccessfully = $true
                    } else {
                        Write-Warning "Bild konnte nicht geschrieben werden für Größe $size : [$path]. Fehler: $_"
                    }
                }
                
                if ($fileCreatedSuccessfully) {
                    $name = "Image$size"
                    try {
                        $null = New-ItemProperty -Path $reg_key -Name $name -Value $path -PropertyType String -Force -ErrorAction Stop
                    }
                    catch {
                        Write-Warning "Registry Fehler bei [$name]"
                    }
                }
            }
            Write-Host "-> Profilbilder für $SamAccountName erfolgreich aktualisiert!" -ForegroundColor Green
        }
        Catch {
            Write-Error "Allgemeiner Fehler bei den Datei- oder Registry-Berechtigungen für $SamAccountName!"
        }
    } else {
        Write-Host "-> Kein thumbnailPhoto im AD für $SamAccountName gefunden." -ForegroundColor Gray
    }
}

Write-Host "`n=== Synchronisation abgeschlossen! ===" -ForegroundColor Cyan

Small script, but it closes a real gap: without it, “I update my photo in AD” and “that photo shows up on the Windows login screen” were two completely disconnected events. If you’re already feeding thumbnailPhoto from CodeTwo (or anything else) in your own domain and want that same last-mile sync, this should drop in with minimal changes.

This post is licensed under CC BY 4.0 by the author.