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
- Find every real user profile on the machine (not
Public, not the default profile template). - Look each one up in AD and check for a
thumbnailPhoto. - Resize that photo into every size Windows expects.
- 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 theMemoryStreamconstructor — 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:
- Creates a hidden folder under
C:\Users\Public\AccountPictures\<SID>. - Writes a resized
.jpgfor every size in the list above. - 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
thumbnailPhotoin AD — that part’s already handled, this script doesn’t touch it. - Task Scheduler runs
logoff.ps1on 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-Contentfalls into a catch block that checksTest-Pathon 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 aWrite-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.
ResizeImageruns once per size per user, so everyBitmap/Graphics/MemoryStreamgets 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.