kit初版,模型引入,agent优化

This commit is contained in:
2026-04-13 00:41:23 +08:00
parent 9b053e302b
commit c435ab15cf
14097 changed files with 5032 additions and 2676248 deletions

View File

@@ -4,6 +4,356 @@
## 记录 ## 记录
### [CL-20260413-0800] 2026-04-13 08:00 — 修复 InstantStyle ReadTimeout + 多次生成丢失参考图
- **tags**: bug修复, Replicate, InstantStyle, timeout, 参考图, Agent Loop, wait, SDK
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/agent/loop.py
- **what**: 修复 InstantStyle 首次调用 ReadTimeout (61.4s) + 后续调用丢失参考图返回 "No input, Save money"
- **why**: 两个运行时日志证实的独立问题:(1) `wait=60` 时 SDK `_create_prediction_timeout` 生成 `httpx.Timeout(5.0, read=60.5)` 作为 per-request timeout 传给 `httpx.AsyncClient.request()`,优先级高于客户端级 300s timeout(2) 成功生成后执行 `ref_image_url = None`,但 LLM 可能在后续轮次继续调用工具,此时 InstantStyle 因没有 `style_image` 返回 "No input, Save money"
- **decisions**: (1) `wait=60``wait=False`:不发 `Prefer: wait` headercreate 请求立即返回 prediction IDSDK 自动进入 `prediction.async_wait()` 轮询路径。轮询请求不带 per-request timeout使用客户端级 `read=300s`。代价:比 `Prefer: wait` 模式多几秒轮询延迟,但绝不会 timeout。(2) 完全移除 `ref_image_url = None`:消息构建(附加参考图到 LLM 消息在外层循环前一次性完成不受影响。ref_image_url 只在 `execute_tool()` 中使用,整个对话期间都应该传递
- **notes**: 迭代了 CL-0700 和 CL-0730 的方案。wait 参数完整语义:`True` = `Prefer: wait` + read=60.5s`int(n)` = `Prefer: wait={n}` + read={n+0.5}sn 必须 1-60`False` = 不等待、纯轮询。对慢模型InstantStyle ~128s配合大 payload1.6MB base64`wait=False` 是唯一可靠选项
### [CL-20260413-0730] 2026-04-13 07:30 — 修复生图失败后 LLM 重试丢失参考图
- **tags**: bug修复, Agent Loop, 参考图, InstantStyle, ref_image_url
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: 修复 LLM 第一次生图工具调用失败后重试时参考图丢失InstantStyle 返回 "No input, Save money"
- **why**: `loop.py` 在每轮工具调用循环结束后无条件执行 `ref_image_url = None`。当第一次调用超时失败后LLM 决定重试,但此时 ref_image_url 已被清空第二次工具调用没有参考图。InstantStyle 模型必须有 `style_image` 才能工作
- **decisions**: 改为条件性清除:在工具调用循环中标记 `tc_data["_had_images"] = True`,循环结束后检查是否有任何成功产出,只有 `any_success` 时才清除 ref_image_url。失败时保留参考图供后续重试
- **notes**: 这个改动对所有参考图模型生效Kolors、InstantStyle 等。只要本轮所有工具调用都失败了ref_image_url 会一直保留直到某次成功为止
### [CL-20260413-0700] 2026-04-13 07:00 — 修复 InstantStyle 生图 422 + ReadTimeoutwait 参数超限与 SDK 内部 timeout 覆盖
- **tags**: bug修复, Replicate, InstantStyle, API参数, async_run, Prefer header, timeout
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- **what**: 修复 InstantStyle 生图 422`Prefer: wait=x must be 1-60`)和后续 ReadTimeout61.4s 超时)
- **why**: 上次修超时时将 `async_run(wait=300)` 设为 300 秒。Replicate SDK 将 int 类型的 wait 值设为 `Prefer: wait=300` header → API 返回 422。改为 `wait=True` 后 SDK 生成内部 `httpx.Timeout(read=60.5)` 作为请求级 timeout 覆盖了客户端级 300s timeout → 上传 1.6MB base64 style_image + 等待响应超过 60s → ReadTimeout
- **decisions**: 最终改为 `wait=60`API 允许的最大 int 值。SDK 行为:(1) `Prefer: wait=60` header合法(2) 内部 timeout `read=60.5s` 用于初始 create prediction 请求;(3) 60s 内未完成自动 fallback 到 `prediction.async_wait()` 轮询;(4) 轮询请求用客户端级 `httpx.Timeout(read=300s)`
- **notes**: Replicate SDK wait 参数语义:`True` = `Prefer: wait` + read=60.5s`int(n)` = `Prefer: wait={n}` + read={n+0.5}sn 必须 1-60`False` = 不等待。对慢模型用 `wait=60` + 长客户端 timeout 是最优组合
### [CL-20260413-0630] 2026-04-13 06:30 — 修复 Replicate SDK 不走代理 + 超时:自定义 Client 注入代理和长超时
- **tags**: bug修复, Replicate, InstantStyle, 代理, httpx, 超时, 图像生成
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- **what**: 修复 InstantStyle 调用时 ConnectTimeout / ReadTimeout + "URL missing protocol" 连环错误
- **why**: Replicate Python SDK 的 `_build_httpx_client` 显式传入 `transport=AsyncHTTPTransport()`,导致 httpx 跳过环境变量中的 `HTTPS_PROXY` 代理配置直连 Replicate API。网络不稳定时直连超时SDK 内部状态异常导致后续请求报 "URL missing protocol"。同时默认 read timeout 30s 不够 InstantStyle 的 ~128s 生成时间
- **decisions**: 创建 `_make_replicate_client()` 工厂函数,从环境变量读取代理并注入到 `AsyncHTTPTransport(proxy=...)` 中传给 `ReplicateClient`。read timeout 300s、connect timeout 30s。`_download_image` 也加了代理和 60s 超时
- **notes**: Replicate SDK 的代理绕过是个已知设计缺陷(显式 transport 覆盖了 httpx 的代理自动检测)。此修复对所有通过 `_replicate_client` 的 Replicate API 调用生效。无代理环境下也兼容(`proxy=None` 时 httpx 不走代理)
### [CL-20260413-0600] 2026-04-13 06:00 — 新增 InstantStyle 模型:强风格迁移选项
- **tags**: 模型注册, InstantStyle, 风格迁移, IP-Adapter, 模型配置
- **affected_files**:
- art-agent/backend/app/config.py
- **what**: 在模型注册表中新增 InstantStyle`jyoung105/instant-style`),专门做风格分离迁移,与 Kolors IP-Adapter 并列为两个参考图模型选项
- **why**: Kolors IP-Adapter 的风格迁移弱(内容+风格+构图混合提取),经调研 InstantStyle 是 Replicate 上风格迁移能力最强的可用模型,能从参考图中分离出纯画风(线条、着色、色调)
- **decisions**: 利用现有 `supports_ref_image` + `ref_image_param` 抽象机制InstantStyle 的 `style_image` 参数名通过配置映射,无需修改 `_build_input` 逻辑。默认 `block_mode: "style-only"`(只迁移风格不迁移构图),`style_strength: 1.0`
- **notes**: InstantStyle 较贵(~$0.12/次)且慢(~128s适合风格定调阶段使用Kolors 保留为低成本快速测试选项。社区模型需版本 hash
### [CL-20260413-0540] 2026-04-13 05:40 — 非 vision LLM + 参考图时禁止猜测风格,避免 prompt 与参考图风格冲突
- **tags**: prompt 工程, IP-Adapter, 风格一致性, system prompt, 非vision模型
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- **what**: 修改 system prompt 和非 vision 模型的参考图文字提示,明确要求 LLM 不要猜测/指定画风关键词,风格完全交给 IP-Adapter 从参考图提取
- **why**: DeepSeek 等非 vision LLM 看不到参考图内容,会根据"2D 游戏"等关键词脑补风格(如 pixel art导致 prompt 中的风格描述与参考图实际风格严重冲突。IP-Adapter 优先服从 prompt 的显式风格指令,参考图的风格影响被稀释
- **decisions**: 双重约束——system prompt 中加入"看不到参考图时不猜风格"的通用指引 + 注入的文字提示中用编号列表明确三条规则。保留了 vision 模型路径不变vision 模型能看图,可以写风格词)
- **notes**: 这是过渡方案,等切换到 vision LLM如 GPT-4oLLM 可直接看图描述风格,此约束自动失效(走 vision_capable 分支)
### [CL-20260413-0520] 2026-04-13 05:20 — 多图生成改为单次 API 调用 + 修复空错误信息
- **tags**: 性能优化, Replicate, 速率限制, 错误处理, 模型配置
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- **what**: 多图生成从 for 循环多次 API 调用改为单次调用(利用模型原生批量参数),同时修复异常 str() 为空时的错误信息丢失
- **why**: LLM 传 num_images=3 时会发 3 个独立 API 请求在低余额账户的严格速率限制burst=1下后续请求会被 429 拦截。改为单次调用后只消耗 1 次请求配额。空错误信息(`[生成失败: ]`)导致用户无法诊断失败原因
- **decisions**: 新增 `num_images_param` 配置字段Flux 用 `num_outputs`Kolors 用 `number_of_images`),由 `_build_input` 统一注入。从 kolors 的 default_params 中移除了硬编码的 `number_of_images: 1`,改为动态设置
- **notes**: 空异常现在显示为 `{TypeName}: {repr(e)}`,确保始终有诊断信息
### [CL-20260413-0500] 2026-04-13 05:00 — 生成图片后显示使用的模型名称
- **tags**: 模型溯源, SSE, 图片生成, 前端展示
- **affected_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/agent/tools.py
- art-agent/backend/app/agent/loop.py
- art-agent/frontend/src/lib/types.ts
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/components/chat/chat-messages.tsx
- **what**: 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),失败时也附带模型名称
- **why**: 用户无法确认前端选择的模型是否真的被用于生图,特别是在多模型切换场景下需要明确溯源
- **decisions**: `generate_images` 返回值从 `list[str]` 改为 `GenerateResult` 数据类(包含 urls + model_name + model_id模型名称通过 SSE `image_result``tool_error` 事件透传到前端。ChatMessage 类型新增 `modelName` 可选字段,消息归档后仍可查看模型来源
- **notes**: 模型名称来自 config.py 注册表中的 `name` 字段,所以显示的是友好名称而非 Replicate 的 model_id
### [CL-20260413-0440] 2026-04-13 04:40 — 修复 Kolors IP-Adapter 模型 404 错误:补全 Replicate 版本 hash
- **tags**: bug修复, Replicate, Kolors, IP-Adapter, 模型配置
- **affected_files**:
- art-agent/backend/app/config.py
- **what**: 在 kolors-ipadapter 的 model_id 中补全版本 hash`fofr/kolors-with-ipadapter` 改为 `fofr/kolors-with-ipadapter:5a1a92b2...`
- **why**: Replicate SDK `async_run` 对非 official 模型(社区模型)只用 `owner/model` 格式调用时,会尝试 official model predictions 端点,该端点返回 404。必须使用 `owner/model:version_hash` 格式才能正确创建 prediction
- **decisions**: 只修复 kolors 模型唯一报错的其他三个模型flux-schnell、flux-dev、sdxl均为 official/热门模型,`owner/model` 格式能正常工作
- **notes**: 未来新增社区模型时,需确保 model_id 包含版本 hash。经实测加版本 hash 后生图成功
### [CL-20260413-0430] 2026-04-13 04:30 — 图片生成失败时将具体错误信息透传到前端
- **tags**: 错误处理, Agent Loop, SSE, 调试体验
- **affected_files**:
- art-agent/backend/app/agent/loop.py
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/lib/api.ts
- **what**: 新增 `tool_error` SSE 事件类型,将图片生成工具的具体错误信息(如 Replicate API 报错详情)直接透传到前端展示
- **why**: 之前工具调用失败时,错误信息只传回给 LLMLLM 会自行"翻译"错误(如说成"速率限制"),用户无法看到真实报错,排查困难。典型场景:使用 Kolors IP-Adapter 生图失败,前端只看到 LLM 的模糊描述,无法定位是参数错误、超时还是 API 限制
- **decisions**: 错误信息同时发给前端展示和 LLM 继续对话,不互斥。前端用 ⚠️ 前缀醒目显示错误详情
- **notes**: 此改动不影响 LLM 的重试机制(工具结果仍然回传给 LLM只是额外增加了一条面向用户的 SSE 事件
### [CL-20260413-0330] 2026-04-13 03:30 — README 补充访问地址汇总 + 穿透管理文档
- **tags**: 文档, README, 穿透, Cloudflare, cloudflared
- **affected_files**:
- art-agent/README.md (新增"访问地址"章节 + 重写"远程访问"章节)
- **what**: README 新增三种访问场景(本机/局域网/外网)的地址汇总表,重写远程访问章节补充 cloudflared 安装指引、穿透管理操作表、代理配置排错
- **why**: 原 README 远程访问章节过于简略,缺少 cloudflared 安装方式、穿透的启动/停止/排错说明,用户实际操作时需要这些信息
- **decisions**:
- 访问地址章节放在"使用"之后、"配置说明"之前,让用户启动后立即看到访问方式
- cloudflared 安装提供 winget 和手动下载两种方式winget 安装后可能不在 PATH 中)
- 穿透管理用表格呈现(启动/停止/查看状态/重启),简洁直观
- 代理排错放在注意事项中,因为国内环境常需代理才能连上 Cloudflare
- **notes**: 无
### [CL-20260412-2345] 2026-04-12 23:45 — 参考图上传独立化:进度条 + 状态反馈 + 失败重试
- **tags**: 前端, 后端, 参考图, 上传, UX, 进度反馈
- **affected_files**:
- art-agent/backend/app/api/chat.py (新增 POST /api/upload-ref-image 独立上传端点chat 端点新增 ref_image_url 字段)
- art-agent/frontend/src/lib/api.ts (新增 uploadRefImage 函数XMLHttpRequest 实现进度回调sendChat 参数从 File 改为服务端路径字符串)
- art-agent/frontend/src/components/chat/chat-input.tsx (重写上传流程选图即上传、环形进度条、状态角标、失败重试、input value 重置)
- art-agent/frontend/src/app/page.tsx (handleSend 适配新签名:接收服务端路径而非 File标注截图也走独立上传)
- **what**: 将参考图从"随消息一起提交"改为"选中后立即独立上传",全程有进度条、状态提示、失败重试入口
- **why**: 原方案图片上传绑定在发送消息时才执行,无任何进度反馈。大图或走 Cloudflare Tunnel 时用户完全无法判断上传是否开始、是否成功、是否卡住。多次上传参考图后 file input 不重置也导致交互异常
- **decisions**: 使用 XMLHttpRequest 而非 fetchfetch 不支持 upload progress 事件);后端同时保留 ref_image 文件上传和 ref_image_url 路径两种方式,向后兼容
- **notes**: 上传超时设为 120 秒;支持 AbortController 取消file input 每次选择后立即重置 value确保同一文件可重复选择
### [CL-20260412-2315] 2026-04-12 23:15 — 修复 Cloudflare Tunnel 跨域开发警告
- **tags**: 前端, 配置, Next.js, Cloudflare, 跨域
- **affected_files**:
- art-agent/frontend/next.config.ts (新增 allowedDevOrigins)
- **what**: 在 next.config.ts 中添加 `allowedDevOrigins: ["*.trycloudflare.com"]`,消除 Cloudflare Tunnel 穿透时的跨域警告
- **why**: Next.js 检测到来自 trycloudflare.com 的跨域请求,发出警告提示未来版本将强制要求配置
- **decisions**: 使用通配符 `*.trycloudflare.com` 覆盖所有 Quick Tunnel 随机域名
- **notes**: 仅影响开发模式,生产构建不受影响
### [CL-20260412-2300] 2026-04-12 23:00 — 集成 Kolors IP-Adapter 模型,参考图可直接用于风格生成
- **tags**: 后端, 前端, IP-Adapter, 风格迁移, 参考图, 模型注册, Replicate, 架构
- **affected_files**:
- art-agent/backend/app/config.py (新增 kolors-ipadapter 模型注册 + supports_ref_image / ref_image_param 通用字段)
- art-agent/backend/app/services/image_gen.py (ReplicateProvider._build_input 支持参考图注入 + to_data_uri 公共工具函数)
- art-agent/backend/app/agent/loop.py (复用 to_data_uri 替代原 _to_vision_url移除冗余 import)
- art-agent/frontend/src/lib/types.ts (ImageModelInfo 新增 supports_ref_image 字段)
- art-agent/frontend/src/components/chat/model-selector.tsx (模型列表中显示"参考图"标记)
- **what**: 集成 Kolors IP-Adapter 模型fofr/kolors-with-ipadapter参考图可真正参与图像生成的风格控制架构上为后续切换其他 IP-Adapter 模型预留了通用扩展机制
- **why**: 之前参考图仅由 LLM 侧理解(且 DeepSeek 看不到图),生图工具侧完全忽略了参考图。用户希望参考图能真正影响生成结果的风格
- **decisions**:
- 模型注册表新增两个通用字段supports_ref_image布尔标记和 ref_image_param参考图参数名不同模型可能不同
- _build_input 根据 supports_ref_image 自动走不同构建逻辑,新增 IP-Adapter 模型只需加注册表配置
- 参考图通过 base64 data URI 传递给 Replicate避免本地 URL 不可达的问题)
- to_data_uri 抽为 image_gen.py 中的公共函数loop.py 复用(替代原 _to_vision_url
- Kolors IP-Adapter 默认 ip_adapter_weight=0.8(非 1.0),给 prompt 文字留一些影响空间
- 前端模型列表中为支持参考图的模型显示绿色"参考图"徽章
- get_image_models_list 返回 supports_ref_image 字段,前端据此渲染标记
- **notes**:
- Kolors IP-Adapter 成本极低(~$0.004/次),速度快(~4s
- 后续要换其他 IP-Adapter 模型(如 Flux IP-Adapter v2只需在 IMAGE_MODELS 中新增配置
- 分层组合产线方案IP-Adapter → LoRA → ControlNet已记录为延期方案 [style-pipeline-layered]
### [CL-20260412-2230] 2026-04-12 22:30 — 用户消息气泡中显示参考图缩略图
- **tags**: 前端, 参考图, UX, chat-messages
- **affected_files**:
- art-agent/frontend/src/lib/types.ts (ChatMessage 新增 refImageUrl 字段)
- art-agent/frontend/src/app/page.tsx (构造 userMessage 时保存参考图 Blob URL)
- art-agent/frontend/src/components/chat/chat-messages.tsx (用户消息气泡中渲染参考图缩略图)
- **what**: 用户上传参考图发送后,在聊天气泡中显示参考图缩略图,让用户确认图片确实被附带发送了
- **why**: 之前用户上传参考图后,发送消息后气泡中看不到参考图,无法确认是否上传成功,体验不好
- **decisions**:
- 使用 URL.createObjectURL 创建 Blob URL 用于当前会话内显示(轻量、即时)
- Blob URL 不持久化到 localStorage刷新后失效避免存储 base64 data URI 的空间开销
- 参考图缩略图显示在消息文本上方,最大尺寸 160×120px
- **notes**:
- Blob URL 仅在当前标签页有效刷新页面后旧消息中的参考图缩略图不可见MVP 可接受)
- 如需持久化,后续可改为存储后端上传路径(需要等后端返回 upload URL 后再构造 userMessage
### [CL-20260412-2200] 2026-04-12 22:00 — 修复 DeepSeek 不支持 image_url 导致参考图上传报错
- **tags**: bug修复, 后端, Agent Loop, vision, DeepSeek, 多模态
- **affected_files**:
- art-agent/backend/app/agent/loop.py (新增 vision 能力检测,非 vision 模型走文字提示)
- **what**: 修复使用 DeepSeek 作为 LLM 时,上传参考图后报 400 错误 `unknown variant 'image_url', expected 'text'`
- **why**: 代码原本假设所有 LLM 都支持 OpenAI Vision API 的 `image_url` content type但 DeepSeek 的 Chat API 只支持 `text` 类型
- **decisions**:
- 通过模型名称关键词检测 vision 能力gpt-4o / gpt-4-vision / claude 视为支持)
- 不支持 vision 的模型:用文字提示告知 LLM 有参考图存在,参考图仍由图像生成工具侧处理
- 支持 vision 的模型:保持原有 image_url 格式不变
- **notes**:
- 此方案确保参考图功能在任何 LLM 下都不会报错
- 非 vision 模型无法"看到"参考图,但生图工具仍会收到参考图用于 img2img 场景
- 如需更精确的 vision 能力检测,可后续改为在 config.py 中按模型注册 vision 标志
### [CL-20260412-2130] 2026-04-12 21:30 — 修复图片生成失败时前端显示破碎图标
- **tags**: bug修复, 前端, 后端, 图像生成, 错误处理
- **affected_files**:
- art-agent/backend/app/agent/tools.py (分离有效 URL 和错误信息)
- art-agent/frontend/src/app/page.tsx (过滤无效 URL空列表时不渲染图片网格)
- **what**: 修复 Replicate API 失败时前端出现 4 个破碎图标框的问题
- **why**: 用户在 Replicate 余额不足402时生图错误字符串被当作图片 URL 传给 `<img>` 标签
- **decisions**:
- 后端 tools.py将 generate_images 返回值分为 images有效 URL和 errors错误信息两个字段
- 前端 page.tsx过滤以 `[` 开头的无效 URL无有效图片时直接 break 不创建 ImageAsset
- **notes**:
- 根因是 Replicate 账户余额不足,需用户到 https://replicate.com/account/billing#billing 充值
- 此修复确保任何 API 错误场景下前端都不会显示破碎图标
### [CL-20260412-2100] 2026-04-12 21:00 — 生图模型动态切换Provider 抽象 + 前端模型选择器
- **tags**: 后端, 前端, 图像生成, 模型切换, Provider, 重构, Replicate
- **affected_files**:
- art-agent/backend/app/config.py (新增 IMAGE_MODELS 注册表 + get_image_model_config / get_image_models_list / get_default_image_model_id)
- art-agent/backend/app/services/image_gen.py (重构ImageProvider 抽象基类 + ReplicateProvider 实现 + generate_images 接受 model_id)
- art-agent/backend/app/api/chat.py (新增 GET /api/models 端点 + POST /api/chat 增加 image_model 参数)
- art-agent/backend/app/agent/loop.py (run_agent_loop 增加 image_model 参数透传)
- art-agent/backend/app/agent/tools.py (execute_tool 增加 image_model 参数透传)
- art-agent/backend/.env (IMAGE_MODEL 改为短 ID 格式)
- art-agent/backend/.env.example (同步更新)
- art-agent/frontend/src/lib/types.ts (新增 ImageModelInfo 类型)
- art-agent/frontend/src/lib/api.ts (新增 fetchModels() + sendChat 增加 imageModel 参数)
- art-agent/frontend/src/components/chat/model-selector.tsx (新建:模型选择下拉组件)
- art-agent/frontend/src/components/chat/chat-input.tsx (集成 ModelSelector + onSend 签名扩展)
- art-agent/frontend/src/app/page.tsx (handleSend 透传 imageModel 到 sendChat)
- **what**: 将图像生成服务从 Replicate 硬绑定重构为 Provider 抽象架构,支持 Replicate 上多模型flux-schnell / flux-dev / SDXL按消息粒度切换
- **why**: 用户希望能在对话中随时切换生图模型来对比不同模型的效果
- **decisions**:
- Provider 模式:抽象基类 ImageProvider + 具体实现(当前只有 ReplicateProvider预留 DalleProvider/ComfyUIProvider 等扩展点
- 模型注册表放在 config.py 中Python dict新增模型只需加一条配置
- 前端模型列表由后端 API 驱动GET /api/models不在前端硬编码
- 切换粒度为每条消息级(最灵活),通过 FormData 传递 image_model 参数
- 模型选择持久化到 localStorage作为后续消息的默认值
- Flux 系列使用 aspect_ratio 参数SDXL 使用 width/height 参数,由 ReplicateProvider._build_input 内部分流处理
- **notes**:
- 向后兼容image_model 参数可选,不传时使用 .env 中的默认模型
- .env 中 IMAGE_MODEL 支持短 IDflux-schnell和完整 Replicate IDblack-forest-labs/flux-schnell自动转换
### [CL-20260412-1800] 2026-04-12 18:00 — 移动端适配 + Cloudflare Tunnel 内网穿透
- **tags**: 前端, 响应式, 移动端, 部署, Cloudflare, 穿透, 配置
- **affected_files**:
- art-agent/frontend/src/app/layout.tsx (viewport meta 配置)
- art-agent/frontend/src/app/globals.css (iOS bounce 防护、tap highlight、dvh 支持、遮罩动画)
- art-agent/frontend/src/app/page.tsx (桌面端展开按钮加 hidden md:flex)
- art-agent/frontend/src/app/gallery/page.tsx (网格 gap/列数适配、hover 操作移动端始终可见、选择框可见)
- art-agent/frontend/src/components/sidebar/sidebar.tsx (移动端固定定位抽屉 + 遮罩)
- art-agent/frontend/src/components/layout/top-nav.tsx (新增汉堡菜单按钮、间距微调)
- art-agent/frontend/src/components/detail/image-detail-panel.tsx (移动端全屏 overlay)
- art-agent/frontend/src/components/chat/chat-messages.tsx (气泡宽度 90%、间距收紧)
- art-agent/frontend/src/components/chat/chat-input.tsx (padding 收紧)
- art-agent/frontend/src/components/chat/image-grid.tsx (max-width 适配、操作栏移动端可见)
- art-agent/frontend/src/lib/app-context.tsx (小屏默认折叠侧边栏、切换会话自动收起)
- art-agent/frontend/next.config.ts (trycloudflare.com + replicate.delivery 白名单)
- art-agent/start-tunnel.ps1 (新建:一键穿透脚本)
- art-agent/README.md (新增远程访问章节、uvicorn 加 --host 0.0.0.0)
- **what**: 为实现手机浏览器远程访问完成移动端响应式适配9 个组件)和 Cloudflare Quick Tunnel 穿透方案
- **why**: 用户希望把项目发给自己和朋友在手机上通过浏览器使用
- **decisions**:
- 移动端断点统一用 `md:` (768px),与 Tailwind 默认保持一致
- 侧边栏移动端用 fixed + overlay 抽屉模式,桌面端保持原有 w-0/w-[280px] 切换
- 图片详情面板移动端全屏fixed inset-0不做半屏因为手机屏幕空间有限
- 穿透方案选 Cloudflare Quick Tunnel免费、免注册不选 ngrok有连接限制
- 穿透脚本自动管理 .env.local 的更新和恢复,减少手动操作
- hover 操作在移动端始终显示(无 hover 事件),避免操作不可达
- 路线 B云服务器正式部署记录为延期方案待后续实施
- **notes**:
- 构建验证通过,无 lint 错误
- cloudflared 通过 winget 安装,版本 2025.8.1
- Quick Tunnel 每次启动分配随机域名xxx.trycloudflare.com非固定
- 穿透启动后需重启前端才能读取新的 NEXT_PUBLIC_API_URL
- project-launcher Skill 中的 uvicorn 命令也同步加了 --host 0.0.0.0
### [CL-20260412-1530] 2026-04-12 15:30 — 修复 .env 配置加载时序问题
- **tags**: 后端, bug修复, 配置, dotenv, Python
- **affected_files**:
- art-agent/backend/app/main.py (load_dotenv 前置 + override=True)
- art-agent/backend/app/config.py (模块级常量 → 函数式懒读取)
- art-agent/backend/app/agent/loop.py (引用改为函数调用)
- art-agent/backend/app/services/image_gen.py (引用改为函数调用)
- art-agent/backend/.env (OPENAI_BASE_URL 去掉 /v1)
- **what**: 修复切换 DeepSeek API 后报 "Model Not Exist" 400 错误
- **why**: 用户修改 .env 切换到 DeepSeek但实际发出的请求仍使用默认模型名 gpt-4o-mini
- **decisions**:
- load_dotenv(override=True) 放在 main.py 所有业务 import 之前,确保环境变量在任何模块读取前就位
- config.py 从模块级常量改为函数get_llm_model() 等),避免 import 时求值被固化
- 不使用 lru_cache避免首次调用在 load_dotenv 前就缓存了空值)
- **notes**:
- 根因有两层import 时序config.py 在 load_dotenv 前被 import+ override 默认行为(不覆盖已有环境变量)
- 通过在 uvicorn 同环境中运行 Python 脚本成功复现了 API 调通,证明问题在进程内加载顺序而非 API 本身
- DeepSeek base_url 使用 https://api.deepseek.com不带 /v1与官方文档一致
### [CL-20260412-1500] 2026-04-12 15:00 — 品牌重命名 EPEEKit + API 配置集中化
- **tags**: EPEEKit, 品牌, 配置, 后端, 前端, 重构
- **affected_files**:
- art-agent/frontend/src/app/layout.tsx (title/description)
- art-agent/frontend/src/components/layout/top-nav.tsx (Logo SVG + 品牌名)
- art-agent/frontend/src/app/page.tsx (欢迎文案)
- art-agent/frontend/src/lib/store.ts (localStorage 键名)
- art-agent/frontend/src/app/gallery/page.tsx (下载前缀)
- art-agent/frontend/src/components/detail/image-detail-panel.tsx (下载前缀)
- art-agent/frontend/src/components/chat/image-grid.tsx (下载前缀)
- art-agent/frontend/package.json (项目名)
- art-agent/frontend/package-lock.json (项目名)
- art-agent/backend/app/main.py (FastAPI title)
- art-agent/backend/app/config.py (新建:集中配置)
- art-agent/backend/app/agent/loop.py (引用集中配置)
- art-agent/backend/app/services/image_gen.py (引用集中配置 + 简化重复分支)
- art-agent/backend/.env (重组为分组结构 + 新增 LLM_MODEL/IMAGE_MODEL 等)
- art-agent/backend/.env.example (新建)
- art-agent/README.md (全文更新)
- **what**: 将应用从 "Art Agent" 正式命名为 "EPEEKit",同时将后端所有硬编码的 API/模型配置抽取到 .env 文件,通过 config.py 集中管理
- **why**: 用户要求正式命名应用并希望能方便地切换 LLM 和图像生成模型进行测试
- **decisions**:
- 品牌名全部统一为 "EPEEKit"包括页面标题、Logo、下载文件名、localStorage 键名
- Logo 采用内联 SVG 设计(主题色圆角方块 + A/i 组合),不引入外部图片资源
- API 配置走 .env + config.py 方案(非 settings.py 类模式),保持极简
- 切换 LLM 只需改 .env 中 3 个值OPENAI_BASE_URL + OPENAI_API_KEY + LLM_MODEL
- image_gen.py 中去除了参考图分支的重复代码(两个分支逻辑完全相同)
- **notes**:
- localStorage 键名变更意味着旧数据不会自动迁移MVP 阶段可接受)
- .env 文件中含真实密钥,需确认 .gitignore 覆盖
### [CL-20260412-0300] 2026-04-12 03:00 — 完成交互原型全面重构
- **tags**: art-agent, 前端, 交互设计, 三栏布局, 标签系统, Canvas标注, 资源库, 后端
- **affected_files**:
- art-agent/frontend/src/app/page.tsx (重写)
- art-agent/frontend/src/app/layout.tsx (添加 AppProvider)
- art-agent/frontend/src/app/globals.css (增加样式)
- art-agent/frontend/src/app/gallery/page.tsx (新建)
- art-agent/frontend/src/lib/types.ts (新建:全局类型定义)
- art-agent/frontend/src/lib/store.ts (新建localStorage 持久化存储)
- art-agent/frontend/src/lib/app-context.tsx (新建:全局状态 Context)
- art-agent/frontend/src/lib/api.ts (更新类型兼容)
- art-agent/frontend/src/components/layout/top-nav.tsx (新建)
- art-agent/frontend/src/components/sidebar/sidebar.tsx (新建)
- art-agent/frontend/src/components/sidebar/session-list.tsx (新建)
- art-agent/frontend/src/components/sidebar/tag-filter.tsx (新建)
- art-agent/frontend/src/components/chat/chat-messages.tsx (适配新类型)
- art-agent/frontend/src/components/chat/chat-input.tsx (增加拖拽上传)
- art-agent/frontend/src/components/chat/image-grid.tsx (增加操作栏)
- art-agent/frontend/src/components/detail/image-detail-panel.tsx (新建)
- art-agent/frontend/src/components/detail/annotation-canvas.tsx (新建)
- art-agent/backend/app/agent/loop.py (System Prompt + base64 转换)
- **what**: 从单页对话 MVP 全面重构为完整交互原型,三个 Phase 一次性完成
- **why**: 用户要求完整设计交互原型,通过规划讨论确定了三栏布局 + 标签系统 + 标注 + 资源库的完整方案
- **decisions**:
- 数据持久化使用 localStorageMVP 阶段),后续可迁移到后端数据库
- 全局状态管理使用 React Context + useReducer 模式,不引入第三方状态库
- 标注功能使用原生 Canvas API不引入第三方画板库减少依赖
- 标注坐标使用归一化比例0-1与图片实际分辨率解耦
- 资源库使用 Modal overlay 展示详情,而非独立路由页面
- 标注截图通过 base64 data URI 直接传给 OpenAI Vision后端自动处理本地路径转换
- **notes**:
- 新增 12 个源文件,修改 6 个现有文件,无新依赖引入
- 内置标签 6 个UI、Icon、原画、风格探索、立绘、概念图
- 标注工具 4 种:矩形框选、箭头、自由画笔、文字,含撤销/清除
- 构建验证通过,无 linter 错误
- 未来可优化:标签自动推荐逻辑(当前仅预留接口)、后端会话持久化、图片搜索索引
### [CL-20260411-1500] 2026-04-11 15:00 — 完成美术 Agent 产品决策和技术选型 ### [CL-20260411-1500] 2026-04-11 15:00 — 完成美术 Agent 产品决策和技术选型
- **tags**: art-agent, 产品决策, 技术选型, 规划 - **tags**: art-agent, 产品决策, 技术选型, 规划
- **affected_files**: - **affected_files**:

View File

@@ -2,6 +2,28 @@
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。 最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
- [CL-20260413-0800] 修复 InstantStyle ReadTimeout + 多次生成丢失参考图wait 改 False 走纯轮询 + 不再清除 ref_image_url
- [CL-20260413-0730] 修复生图失败后 LLM 重试丢失参考图ref_image_url 改为仅在成功产出图片后才清除
- [CL-20260413-0700] 修复 InstantStyle 生图 422 + ReadTimeout`wait=300` 超限改为 `wait=60`SDK 超时自动 fallback 轮询
- [CL-20260413-0630] 修复 Replicate SDK 不走代理 + 超时不够:注入 HTTPS_PROXY 到 transport + read 300s / connect 30s解决 ConnectTimeout 连环错误
- [CL-20260413-0600] 新增 InstantStyle 模型(强风格迁移),与 Kolors 并列为参考图模型选项block_mode=style-only
- [CL-20260413-0540] 非 vision LLM + 参考图时禁止猜测风格prompt 只描述内容不写画风,风格交由 IP-Adapter 处理
- [CL-20260413-0520] 多图生成改为单次 API 调用(利用模型原生批量参数),避免速率限制 + 修复空错误信息显示
- [CL-20260413-0500] 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),便于模型溯源
- [CL-20260413-0440] 修复 Kolors IP-Adapter 404 错误:社区模型需 `owner/model:version_hash` 格式,补全版本 hash 后生图成功
- [CL-20260413-0430] 图片生成失败时将具体错误信息Replicate API 报错等)透传到前端展示,新增 tool_error SSE 事件
- [CL-20260413-0330] README 补充访问地址汇总(本机/局域网/外网)+ 穿透管理文档(安装/启停/排错)
- [CL-20260412-2345] 参考图上传独立化:选图即上传 + 环形进度条 + 成功/失败状态反馈 + 失败重试,后端新增独立上传端点
- [CL-20260412-2315] 修复 Cloudflare Tunnel 跨域开发警告next.config.ts 新增 allowedDevOrigins 通配符
- [CL-20260412-2300] 集成 Kolors IP-Adapter 模型:参考图可直接用于风格生成,模型注册表新增通用参考图支持机制
- [CL-20260412-2230] 用户消息气泡中显示参考图缩略图ChatMessage 新增 refImageUrl发送后在气泡中渲染参考图
- [CL-20260412-2200] 修复 DeepSeek 不支持 image_url 参考图上传报错:新增 vision 能力检测,非 vision 模型走文字提示
- [CL-20260412-2130] 修复图片生成失败时前端显示破碎图标:后端分离错误信息与有效 URL前端过滤无效 URL 不渲染空图片
- [CL-20260412-2100] 生图模型动态切换Provider 抽象架构 + 前端模型选择器 + Replicate 多模型flux-schnell/flux-dev/SDXL按消息粒度切换
- [CL-20260412-1800] 移动端适配 + Cloudflare Tunnel 内网穿透响应式布局改造9 个组件)+ 一键穿透脚本 + cloudflared 安装
- [CL-20260412-1530] 修复 .env 配置加载时序问题load_dotenv 前置 + override=True + config.py 改函数式懒读取
- [CL-20260412-1500] 品牌重命名 Art Agent → EPEEKit + 后端 API/模型配置集中化到 .env + config.py
- [CL-20260412-0300] 完成交互原型全面重构:三栏布局 + 多会话标签系统 + Canvas 标注 + 资源库页面
- [CL-20260411-1630] 安装 Node.js v24.14.1 + Python venv + 前后端依赖,后端启动验证通过 - [CL-20260411-1630] 安装 Node.js v24.14.1 + Python venv + 前后端依赖,后端启动验证通过
- [CL-20260411-1600] 从零创建美术 Agent MVP 全部前后端代码FastAPI + Next.js + Agent Loop + SSE - [CL-20260411-1600] 从零创建美术 Agent MVP 全部前后端代码FastAPI + Next.js + Agent Loop + SSE
- [CL-20260411-1500] 完成美术 Agent 产品决策5 个核心问题和技术选型8 个维度)两份文档 - [CL-20260411-1500] 完成美术 Agent 产品决策5 个核心问题和技术选型8 个维度)两份文档

View File

@@ -3,17 +3,108 @@
最近 ~10 次改动的摘要记录,按时间倒序排列。 最近 ~10 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。 当 Agent 检测到当前任务与近期改动相关时自动读取。
### [CL-20260411-1630] 2026-04-11完成开发环境搭建和依赖安装 ### [CL-20260413-0800] 2026-04-13修复 InstantStyle ReadTimeout + 多次生成丢失参考图
- **tags**: art-agent, 环境搭建, Node.js, Python - **tags**: bug修复, Replicate, InstantStyle, timeout, 参考图, Agent Loop, wait
- **affected_files**: art-agent/backend/venv/, art-agent/frontend/node_modules/ - **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py
- **summary**: 安装 Node.js v24.14.1winget、创建 Python venv 并安装后端依赖、安装前端 npm 依赖。后端启动验证通过。遇到并解决了 OpenAI 延迟初始化、PowerShell && 不支持、脚本执行策略限制等问题 - **summary**: 运行时日志证实两个问题:(1) `wait=60` 时 SDK 内部 `read=60.5s` timeout 覆盖客户端 300s timeout上传 1.6MB base64 + 等待初始响应超过 60s → ReadTimeout。改为 `wait=False`create 请求立即返回 prediction IDSDK 自动走 `prediction.async_wait()` 轮询,轮询用客户端级 read=300s timeout。(2) 成功生成后清除 ref_image_url 导致后续工具调用丢失参考图InstantStyle 返回 "No input, Save money"。完全移除清除逻辑ref_image_url 在整个对话期间保持有效
### [CL-20260411-1600] 2026-04-11搭建美术 Agent MVP 全部前后端代码 ### [CL-20260413-0730] 2026-04-13修复生图失败后 LLM 重试丢失参考图
- **tags**: art-agent, MVP, 前端, 后端, FastAPI, Next.js, Agent Loop - **tags**: bug修复, Agent Loop, 参考图, InstantStyle, ref_image_url
- **affected_files**: art-agent/backend/app/*, , art-agent/frontend/src/*, docs/art-agent/MVP-PLAN.md - **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: 从零创建完整 MVP后端 FastAPIAgent Loop + Replicate 图像生成 + SSE+ 前端 Next.jsChat UI + 参考图上传 + 图片下载 + 暗色主题)。范围刻意最小化:无数据库、无 Skill 机制、无用户认证。Agent Loop 硬编码 system prompt使用 GPT-4o-mini + flux-schnell - **summary**: LLM 第一次生图工具调用失败后重试时参考图丢失。InstantStyle 返回 "No input, Save money"。根因:`loop.py` 在每轮工具调用结束后无条件清除 `ref_image_url`。修复:改为只在本轮至少有一次成功产出图片时才清除,失败时保留参考图供 LLM 重试
### [CL-20260413-0700] 2026-04-13 — 修复 InstantStyle 生图 422 + ReadTimeoutwait 参数超限
- **tags**: bug修复, Replicate, InstantStyle, API参数, async_run, timeout
- **affected_files**: art-agent/backend/app/services/image_gen.py
- **summary**: InstantStyle 生图 422`Prefer: wait=x must be 1-60`+ ReadTimeout61.4s)。两个问题同根因:`async_run(wait=300)` 超出 API 限制,改为 `wait=True` 后 SDK 内部 60.5s timeout 覆盖了客户端 300s timeout。最终改为 `wait=60`API 最大合法值),初始请求 60s 内未完成则 SDK 自动 fallback 到异步轮询,轮询用客户端级 read=300s timeout。
### [CL-20260413-0630] 2026-04-13 — 修复 Replicate SDK 不走代理 + 超时不够
- **tags**: bug修复, Replicate, InstantStyle, 代理, httpx, 超时
- **affected_files**: art-agent/backend/app/services/image_gen.py
- **summary**: Replicate SDK 内部显式传 `transport` 给 httpx绕过了环境变量代理配置导致 ConnectTimeout且默认 read 30s 不够 InstantStyle 的 ~128s。修复`_make_replicate_client()` 工厂函数从 `HTTPS_PROXY` 读取代理注入 `AsyncHTTPTransport(proxy=...)`read 300s / connect 30s。`_download_image` 同步加了代理。
### [CL-20260413-0600] 2026-04-13 — 新增 InstantStyle 模型:强风格迁移选项
- **tags**: 模型注册, InstantStyle, 风格迁移, IP-Adapter
- **affected_files**: art-agent/backend/app/config.py
- **summary**: 注册 InstantStyle`jyoung105/instant-style`)到模型注册表,`block_mode: "style-only"` + `style_strength: 1.0`,通过 `ref_image_param: "style_image"` 适配现有抽象。与 Kolors 并列为参考图模型,提供强风格迁移能力。
### [CL-20260413-0540] 2026-04-13 — 非 vision LLM + 参考图时禁止猜测风格
- **tags**: prompt 工程, IP-Adapter, 风格一致性, system prompt
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: 修改 system prompt 和非 vision 模型的参考图提示明确禁止猜测画风。LLM 只描述画面内容,风格交给 IP-Adapter 从参考图提取。解决了 DeepSeek 看不到参考图时脑补 "pixel art" 导致生成结果与参考图风格不一致的问题。
### [CL-20260413-0520] 2026-04-13 — 多图生成改为单次 API 调用 + 修复空错误信息
- **tags**: 性能优化, Replicate, 速率限制, 错误处理, 模型配置
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/config.py
- **summary**: 多图生成从 for 循环改为单次 API 调用(新增 `num_images_param` 配置),避免低余额账户的速率限制。同时修复空异常时 `[生成失败: ]` 信息丢失的问题,改为显示异常类型名和 repr。
### [CL-20260413-0500] 2026-04-13 — 生成图片后显示使用的模型名称
- **tags**: 模型溯源, SSE, 图片生成, 前端展示
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/tools.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx
- **summary**: `generate_images` 返回 `GenerateResult`(含 model_nameSSE 事件携带模型名称前端在图片下方和错误提示中展示。ChatMessage 新增 `modelName` 字段,归档后仍可溯源。
### [CL-20260413-0440] 2026-04-13 — 修复 Kolors IP-Adapter 模型 404 错误:补全 Replicate 版本 hash
- **tags**: bug修复, Replicate, Kolors, IP-Adapter, 模型配置
- **affected_files**: art-agent/backend/app/config.py
- **summary**: Kolors IP-Adapter 生图 404 的根因是 Replicate SDK 对社区模型需要 `owner/model:version_hash` 格式。在 config.py 中补全 kolors-ipadapter 的版本 hash 后,实测生图成功。
### [CL-20260413-0430] 2026-04-13 — 图片生成失败时将具体错误信息透传到前端
- **tags**: 错误处理, Agent Loop, SSE, 调试体验
- **affected_files**: art-agent/backend/app/agent/loop.py, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/api.ts
- **summary**: 新增 `tool_error` SSE 事件类型。Agent Loop 在工具执行产生错误时,额外 yield 一个 tool_error 事件携带具体报错信息;前端处理该事件并以 ⚠️ 前缀展示在对话流中。解决了之前工具失败时用户只能看到 LLM 模糊翻译、无法获取真实错误详情的问题。
### [CL-20260413-0330] 2026-04-13 — README 补充访问地址汇总 + 穿透管理文档
- **tags**: 文档, README, 穿透, Cloudflare, cloudflared
- **affected_files**: art-agent/README.md
- **summary**: README 新增"访问地址"章节(本机/局域网/外网三种场景表格),重写"远程访问(外网穿透)"章节,补充 cloudflared 安装指引winget + 手动下载)、穿透管理操作表(启动/停止/查看状态/重启)、代理配置排错说明。
### [CL-20260412-2345] 2026-04-12 — 参考图上传独立化:进度条 + 状态反馈 + 失败重试
- **tags**: 前端, 后端, 参考图, 上传, UX, 进度反馈
- **affected_files**: art-agent/backend/app/api/chat.py, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx
- **summary**: 参考图从"随消息提交"改为"选中即独立上传"。后端新增 POST /api/upload-ref-image 端点;前端用 XMLHttpRequest 获取上传进度事件ChatInput 显示环形进度条、成功/失败角标、失败可重选。sendChat 参数从 File 改为服务端路径。同时修复 file input 多次上传不重置的问题。
### [CL-20260412-2315] 2026-04-12 — 修复 Cloudflare Tunnel 跨域开发警告
- **tags**: 前端, 配置, Next.js, Cloudflare, 跨域
- **affected_files**: art-agent/frontend/next.config.ts
- **summary**: Next.js 检测到来自 trycloudflare.com 的跨域请求发出警告。在 next.config.ts 中添加 `allowedDevOrigins: ["*.trycloudflare.com"]` 消除警告,通配符覆盖所有 Quick Tunnel 随机域名。
### [CL-20260412-2300] 2026-04-12 — 集成 Kolors IP-Adapter参考图可直接用于风格生成
- **tags**: 后端, 前端, IP-Adapter, 风格迁移, 参考图, 模型注册, Replicate, 架构
- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/components/chat/model-selector.tsx
- **summary**: 集成 Kolors IP-Adapter 模型fofr/kolors-with-ipadapter~$0.004/次),参考图通过 base64 data URI 直接传给 Replicate 的 IP-Adapter 参与风格生成。模型注册表新增 supports_ref_image / ref_image_param 通用字段,新增 IP-Adapter 模型只需加配置。to_data_uri 抽为公共函数供 loop.py 和 image_gen.py 共用。前端模型列表显示绿色"参考图"徽章。
### [CL-20260412-2230] 2026-04-12 — 用户消息气泡中显示参考图缩略图
- **tags**: 前端, 参考图, UX, chat-messages
- **affected_files**: art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx
- **summary**: 用户上传参考图后发送消息之前聊天气泡中不显示参考图无法确认是否成功附带。修复ChatMessage 类型新增 refImageUrl 字段page.tsx 构造用户消息时用 URL.createObjectURL 保存预览 URLchat-messages.tsx 在用户消息气泡中渲染缩略图(最大 160×120px。Blob URL 仅当前会话有效,不持久化。
### [CL-20260412-2200] 2026-04-12 — 修复 DeepSeek 不支持 image_url 导致参考图上传报错
- **tags**: bug修复, 后端, Agent Loop, vision, DeepSeek, 多模态
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: 上传参考图时使用 OpenAI Vision 格式(`image_url` content type发送给 DeepSeek API但 DeepSeek 只支持 `text` 类型,导致 400 错误。修复:新增 vision 能力检测(基于模型名称关键词),不支持 vision 的模型改为文字提示方式告知 LLM 有参考图,参考图仍传给图像生成工具处理。
### [CL-20260412-2130] 2026-04-12 — 修复图片生成失败时前端显示破碎图标
- **tags**: bug修复, 前端, 后端, 图像生成, 错误处理
- **affected_files**: art-agent/backend/app/agent/tools.py, art-agent/frontend/src/app/page.tsx
- **summary**: Replicate API 调用失败(如余额不足 402错误字符串 `[生成失败: ...]` 被混入 images 列表传给前端,导致 `<img>` 标签渲染破碎图标。修复:后端 tools.py 分离有效 URL 和错误信息为 images/errors 两个字段;前端 page.tsx 过滤掉以 `[` 开头的无效 URL无有效图片时不渲染图片网格。
### [CL-20260412-2100] 2026-04-12 — 生图模型动态切换Provider 抽象 + 前端模型选择器
- **tags**: 后端, 前端, 图像生成, 模型切换, Provider, 重构, Replicate
- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/api/chat.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/agent/tools.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/model-selector.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx
- **summary**: 将图像生成从 Replicate 硬绑定重构为 Provider 抽象层。后端config.py 新增 IMAGE_MODELS 注册表flux-schnell / flux-dev / SDXLimage_gen.py 引入 ImageProvider 基类 + ReplicateProvider 实现chat.py 新增 GET /api/models + POST /api/chat 增加 image_model 参数agent loop / tools 全链路透传。前端:新建 ModelSelector 下拉组件集成到 ChatInput每条消息可选不同模型选择持久化到 localStorage。
### [CL-20260412-1800] 2026-04-12 — 移动端适配 + Cloudflare Tunnel 内网穿透
- **tags**: 前端, 响应式, 移动端, 部署, Cloudflare, 穿透, 配置
- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/app/globals.css, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/sidebar/sidebar.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/src/lib/app-context.tsx, art-agent/frontend/next.config.ts, art-agent/start-tunnel.ps1, art-agent/README.md
- **summary**: 为实现"手机浏览器远程访问"做了两大块工作。(1) 移动端响应式适配侧边栏改为固定定位抽屉式md 以下),顶栏新增汉堡菜单,图片详情面板移动端全屏 overlay气泡/输入框/Gallery 网格间距微调hover 操作在移动端始终可见,默认小屏折叠侧边栏。(2) 穿透方案:安装 cloudflared编写 start-tunnel.ps1 一键脚本自动创建双隧道 + 更新 .env.local + 退出时恢复。Next.js 图片白名单加 trycloudflare.com。uvicorn 改绑 0.0.0.0。路线 B云服务器部署记为延期方案。
### [CL-20260412-1530] 2026-04-12 — 修复 .env 配置加载时序问题
- **tags**: 后端, bug修复, 配置, dotenv, Python
- **affected_files**: art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env
- **summary**: 切换 DeepSeek 后报 "Model Not Exist"。根因:(1) load_dotenv() 在业务模块 import 之后执行config.py 的模块级变量在 import 时就固化为默认值 gpt-4o-mini(2) load_dotenv 默认 override=False 不覆盖已有环境变量。修复load_dotenv(override=True) 提到所有 import 之前config.py 常量改为函数式懒读取;.env base_url 改为不带 /v1。
### [CL-20260412-1500] 2026-04-12 — 品牌重命名 EPEEKit + API 配置集中化
- **tags**: EPEEKit, 品牌, 配置, 后端, 前端, 重构
- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/store.ts, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/package.json, art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/README.md
- **summary**: 应用正式命名为 "EPEEKit",全部用户可见文案 + 文件名前缀 + localStorage 键名统一替换。后端新增 config.py 集中管理 LLM_MODEL、IMAGE_MODEL 等原硬编码配置,.env 重组为带注释的分组结构,切换 LLM 只需改 3 个环境变量。同时新增 .env.example 模板和 Logo SVG。
### [CL-20260411-1500] 2026-04-11 — 完成美术 Agent 产品决策和技术选型
- **tags**: art-agent, 产品决策, 技术选型, 规划
- **affected_files**: docs/art-agent/DECISIONS.md, docs/art-agent/TECH-STACK.md
- **summary**: 5 轮结构化讨论确定产品决策Chat-first Web App、对话驱动交互、三层风格定义等。8 维度技术选型Next.js / FastAPI / 自建 Agent Loop / PostgreSQL / Replicate / Vercel+Railway。DECISIONS.md 和 TECH-STACK.md 两份文档产出。

View File

@@ -2,7 +2,94 @@
## Active Items ## Active Items
(暂无延期方案) ### [cloud-deploy-route-b] 路线 B云服务器正式部署
- **status**: deferred
- **tags**: deployment, docker, nginx, cloud, production
- **recorded**: 2026-04-12
- **source_chat**: [发布到手机浏览器的方案讨论](discussion-publish-routes)
- **prerequisite**: 路线 A内网穿透 + 移动端适配)完成后
- **related_files**:
- art-agent/backend/app/main.py
- art-agent/frontend/next.config.ts
- art-agent/frontend/.env.local
- art-agent/backend/.env
- **context**: |
买轻量云服务器2C2G ~50元/月),编写前后端 Dockerfile + docker-compose
配置 Nginx/Caddy 反向代理统一入口HTTPS 自动签证书,
环境变量搬迁(去掉本地代理配置),可选绑定域名。
预估工作量 1-2 天。
- **chosen_alternative**: 路线 A — 本地运行 + 内网穿透Cloudflare Tunnel / ngrok
- **deferred_reason**: 当前阶段只需自己和朋友试用,内网穿透足够
### [style-pipeline-layered] 分层组合风格一致性产线
- **status**: deferred
- **tags**: 风格一致性, IP-Adapter, LoRA, ControlNet, 量产, 美术产线, InstantStyle
- **recorded**: 2026-04-12
- **updated**: 2026-04-13
- **source_chat**: [风格一致性技术方案讨论](style-consistency-discussion)
- **prerequisite**: EPEEKit 进入美术资源量产阶段(风格方向已确定、需要批量产出同风格素材)
- **related_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- **context**: |
分层组合的风格一致性产线:
1. 探索期当前Prompt + 参考图,快速试错确定风格方向
2. 锁定期IP-Adapter / ControlNet选 3-5 张精选图作为风格锚点,推理时注入风格
3. 量产期LoRA + ControlNet用精选图训练 LoRA20-50 张),批量产出同风格资源
4. 贯穿全程:标准化 Prompt 模板 + Negative Prompt 模板,确保品质下限
各阶段可叠加使用不互斥。LoRA 训练需要同风格样本积累到足够数量。
2026-04-13 调研补充 — 风格迁移模型层级:
- Kolors IP-Adapter当前在用通用 IP-Adapter风格迁移弱内容+风格+构图混合提取
- InstantStyleReplicate 可用):专门分离内容/风格,风格迁移强,但贵且慢
- Style IPAdapter for NoobAI-XLCivitAI最强画风迁移线条+着色技法),需 ComfyUI
- ICAS 框架(学术前沿 2025.04IP-Adapter + ControlNet 组合,多主体风格一致性最优
锁定期的最佳方案:迁移到 HF Inference Endpoints 后,用 NoobAI-XL + Style IPAdapter + ControlNet
- **chosen_alternative**: 当前使用 Prompt Engineering + LLM 参考图理解(探索期方案)
- **deferred_reason**: 风格方向尚未确定,过早引入 LoRA/ControlNet 是过度优化
### [hf-inference-endpoints] 迁移到 Hugging Face + Inference Endpoints
- **status**: deferred
- **tags**: 基础设施, Hugging Face, Inference Endpoints, ComfyUI, 自定义模型, 风格迁移
- **recorded**: 2026-04-13
- **source_chat**: [Kolors 风格迁移排查与模型调研](kolors-style-investigation)
- **prerequisite**: 探索期结束、确定要用的模型组合后
- **related_files**:
- art-agent/backend/app/services/image_gen.py
- art-agent/backend/app/config.py
- **context**: |
将生图基础设施从 Replicate API 迁移到 Hugging Face Inference Endpoints
1. 可部署任意 ComfyUI 工作流NoobAI-XL + Style IPAdapter + ControlNet 等任意组合)
2. 不受 Replicate 模型作者暴露的参数限制,完全控制推理流程
3. CivitAI 上的任何 LoRA、IP-Adapter 权重都能直接加载
4. 按 GPU 时长计费,批量生成时远比 Replicate 按次付费便宜
5. 是 style-pipeline-layered 延期方案中"锁定期 → 量产期"的基础设施
背景:当前在 Replicate 上可用的风格迁移模型有限——
- Kolors IP-Adapter风格迁移弱混合提取内容+风格+构图)
- InstantStylejyoung105/instant-style风格迁移强但贵$0.12/次)且慢(~128s
- Style IPAdapter for NoobAI-XL最强画风迁移但需 ComfyUI 环境Replicate 上无法使用
迁移到 HF Inference Endpoints 后可自由组合以上所有方案。
- **chosen_alternative**: 当前使用 Replicate APIKolors IP-Adapter + Flux + SDXL
- **deferred_reason**: 探索期用 Replicate 足够快速迭代,迁移工作量较大(需搭建自定义推理服务)
### [vision-model-asset-library] 专用 Vision 模型用于资源库功能
- **status**: deferred
- **tags**: 资源库, vision, CLIP, BLIP, 图片搜索, 自动标签, embedding
- **recorded**: 2026-04-12
- **source_chat**: [参考图风格解析方案讨论](vision-model-discussion)
- **prerequisite**: 资源库功能进入增强阶段(基础 Gallery 已完成)
- **related_files**:
- art-agent/frontend/src/app/gallery/page.tsx
- art-agent/backend/app/services/image_gen.py
- **context**: |
引入专用 Vision 模型CLIP / BLIP 等)为资源库提供智能化能力:
1. 自动标签生成的图片自动分类UI/角色/场景/图标等)
2. 以图搜图:上传一张图,从资源库中找风格最相似的
3. 语义搜索:用自然语言搜索图片(如"蓝色水晶质感的按钮"
4. 聚类分组:自动将图片按视觉相似性分组
可通过 Replicate 调用或本地部署,成本极低。
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
- **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化
--- ---

23
.cursor/hooks.json Normal file
View File

@@ -0,0 +1,23 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/session-init.ps1",
"timeout": 5
}
],
"stop": [
{
"type": "prompt",
"prompt": "You are a changelog compliance checker. Return ONLY valid JSON, nothing else.\n\nSTEP 1 — EXEMPTION CHECK (if ANY condition is true, return {} immediately):\n- The conversation is in Debug mode, debug-mode, or any debugging/troubleshooting context\n- The agent's recent actions were ONLY: reading files, running shell commands, adding log/debug statements, or modifying .cursor/ files\n- The agent did NOT use Write or StrReplace tools on files outside .cursor/ directory\n- The agent was acknowledging a previous hook reminder\n- There is a CURSOR_SKIP_CHANGELOG environment variable set\n\nIf ANY exemption matches → return: {}\n\nSTEP 2 — Only if NO exemption matched:\nCheck if the agent used Write or StrReplace on source files (*.py, *.tsx, *.ts, *.css, *.json outside .cursor/) AND did NOT also write to all three: .cursor/changelog/changelog-full.md, changelog-recent.md, changelog-headlines.md.\n\nIf changelog writes were missed → return: {\"followup_message\":\"Changelog not synced. Run dev-changelog Skill operation A to write all three changelog layers.\"}\n\nOtherwise → return: {}\n\nHere is the hook input: $ARGUMENTS",
"timeout": 15,
"loop_limit": 1
},
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/check-changelog.ps1",
"timeout": 10
}
]
}
}

View File

@@ -0,0 +1,72 @@
# Changelog sync guard: compare source file mtime vs changelog mtime.
# If source files are newer than changelog, inject a followup_message
# reminding the agent to write changelog entries.
#
# 静默条件(不触发提醒):
# 1. 环境变量 CURSOR_SKIP_CHANGELOG 被设置(由 sessionStart hook 在非 agent 模式设置)
# 2. Debug 模式 — 项目根目录存在 debug-*.log 文件,说明正在调试
# 3. changelog 文件不存在
# 4. .changelog-ack 标记文件存在且足够新(本会话已确认过 changelog 状态)
$input = [Console]::In.ReadToEnd()
# 检测 sessionStart 设置的跳过标志(覆盖一开始就是 debug/ask 模式的场景)
if ($env:CURSOR_SKIP_CHANGELOG) {
Write-Output '{}'
exit 0
}
$changelog = ".cursor\changelog\changelog-headlines.md"
$srcDir = "art-agent"
if (-not (Test-Path $changelog)) {
Write-Output '{}'
exit 0
}
# 检测 debug 模式:项目中存在活跃的 debug 日志文件
$debugLogs = Get-ChildItem -Path "." -Filter "debug-*.log" -Recurse -Depth 3 -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notlike "*node_modules*" -and $_.FullName -notlike "*venv*" }
if ($debugLogs) {
Write-Output '{}'
exit 0
}
$clMtime = (Get-Item $changelog).LastWriteTime
# 检测 ack 标记文件Agent 已确认 changelog 状态(写入或跳过),本会话不再提醒
$ackFile = ".cursor\changelog\.changelog-ack"
if ((Test-Path $ackFile) -and (Get-Item $ackFile).LastWriteTime -ge $clMtime) {
Write-Output '{}'
exit 0
}
$extensions = @("*.py", "*.tsx", "*.ts", "*.css")
$excludeDirs = @("node_modules", ".next", "__pycache__", "venv")
$newerFile = $null
foreach ($ext in $extensions) {
$files = Get-ChildItem -Path $srcDir -Filter $ext -Recurse -ErrorAction SilentlyContinue |
Where-Object {
$skip = $false
foreach ($ex in $excludeDirs) {
if ($_.FullName -like "*\$ex\*") { $skip = $true; break }
}
-not $skip -and $_.LastWriteTime -gt $clMtime
} |
Select-Object -First 1
if ($files) {
$newerFile = $files.Name
break
}
}
if ($newerFile) {
$msg = "[Hook] Source file updated (e.g. $newerFile) but changelog not synced. Run dev-changelog Skill operation A NOW to write all three changelog layers."
$json = '{"followup_message":"' + $msg.Replace('"','\"') + '"}'
Write-Output $json
} else {
Write-Output '{}'
}
exit 0

View File

@@ -0,0 +1,23 @@
# session-init.ps1 — 会话启动时检测 composer_mode非 agent 模式设置跳过标志
#
# sessionStart input 包含 composer_mode 字段("agent" / "ask" / "edit" / "debug" 等)
# 通过 env 输出的环境变量会传递给同会话内所有后续 hook
$input = [Console]::In.ReadToEnd()
try {
$data = $input | ConvertFrom-Json
$mode = $data.composer_mode
} catch {
$mode = $null
}
Remove-Item ".cursor\changelog\.changelog-ack" -ErrorAction SilentlyContinue
if ($mode -and $mode -ne "agent") {
$json = '{"env":{"CURSOR_SKIP_CHANGELOG":"1","CURSOR_COMPOSER_MODE":"' + $mode + '"}}'
Write-Output $json
} else {
Write-Output '{}'
}
exit 0

View File

@@ -0,0 +1,13 @@
# Pitfall Journal
开发过程中踩过的坑,按时间倒序排列。
Agent 进入 Debug mode 或遇到运行时错误时自动检索匹配。
---
### [PF-20260412-1530] Python load_dotenv 默认不覆盖已有环境变量 + 模块级变量在 import 时固化
- **症状**: 修改 .env 切换 API 供应商后,后端仍报旧供应商的错误(如 DeepSeek 报 "Model Not Exist",实际发送的模型名是 OpenAI 的 gpt-4o-mini
- **根因**: 两层叠加:(1) `load_dotenv()` 默认 `override=False`,如果进程/系统中已有同名环境变量,.env 中的新值不会生效;(2) `config.py` 使用模块级常量 `LLM_MODEL = os.getenv(...)` 在 import 时就求值固化,而 `main.py` 的 import 链在 `load_dotenv()` 之前就触发了 config.py 的加载
- **解法**: (1) `load_dotenv(override=True)` 并提到所有业务 import 之前;(2) config.py 改为函数式 `get_llm_model()`,运行时才读取
- **防御**: 任何 Python 项目使用 dotenv 时,始终 `override=True` + 放在文件最顶部(仅在 `import os``from dotenv import load_dotenv` 之后);配置值用函数或属性包装,不要用模块级常量
- **关联**: Python, dotenv, FastAPI, uvicorn, 环境变量, 配置管理, OpenAI SDK

View File

@@ -12,6 +12,32 @@ alwaysApply: true
3. 这些信息帮助 Agent 快速建立位置感:项目进展到什么阶段、最近的工作重心在哪个模块 3. 这些信息帮助 Agent 快速建立位置感:项目进展到什么阶段、最近的工作重心在哪个模块
4. 在后续回复中自然参考,不显式提及"根据开发日志" 4. 在后续回复中自然参考,不显式提及"根据开发日志"
## 主动定位辅助
L3 概要的核心价值之一是帮助 Agent 在**冷启动**(新会话、无上下文)时理解用户意图。
当用户的请求缺少具体文件名或模块名时Agent 应主动利用 L3 进行推断:
### 典型场景
1. **隐式延续**:用户说"继续做昨天那个"、"把那个功能完善一下"
→ 从 L3 中找到最近的相关条目,上溯到 L2 获取具体文件列表
2. **模糊指代**:用户说"那个组件有 bug"、"之前改的那个接口"
→ 用 L3 中的关键词匹配用户描述,定位到具体改动
3. **上下文补全**:用户直接提出一个任务,没有背景说明
→ 用 L3 判断该任务是否与近期某个改动有关联(如同一模块、同一功能线)
### 流程
```
1. 解析用户请求,识别是否存在隐式引用或模糊指代
2. 在 L3 概要中查找语义最匹配的 1-3 条记录
3. 提取匹配条目的锚点 ID上溯到 L2 获取 affected_files 和 tags
4. 如有必要,继续上溯到 L1 获取完整的决策背景
5. 将定位到的文件/模块作为任务的起点,开始执行
```
如果 L3 中没有匹配到任何相关记录,正常处理即可——不是所有任务都与近期改动有关。
## L2 自动触发 ## L2 自动触发
Agent 开始处理一个新任务时,判断是否需要读取近期详细记录: Agent 开始处理一个新任务时,判断是否需要读取近期详细记录:
@@ -42,7 +68,30 @@ Agent 开始处理一个新任务时,判断是否需要读取近期详细记
获取行号后用 Read 工具读取该行号 ±20 行范围 获取行号后用 Read 工具读取该行号 ±20 行范围
4. 一次上溯通常只涉及 1-3 条记录,不批量上溯 4. 一次上溯通常只涉及 1-3 条记录,不批量上溯
## 被动写入提醒 ## 任务完成 Checklist强制
Agent 在完成涉及代码/配置文件实质性改动的任务后,应读取 `dev-changelog` Skill Agent 在即将输出最终回复前,**必须**逐项检查以下清单。
并执行其"操作 A记录写入"流程,静默追加三层记录 这是硬性要求,不是建议——**跳过任何一项都视为执行错误**
1. **本次是否有代码/配置文件改动?**(不含 `.cursor/` 下的基础设施文件)
- 否 → 跳过后续项
- 是 → 继续
2. **是否已写入三层开发日志?**
- 否 → **立即**读取 `dev-changelog` Skill 并执行操作 A写入 L1 + L2 + L3 后再输出回复
- 是 → 继续
3. **是否修改了任何 Skill**(包括 SKILL.md 内容变更)
- 否 → 跳过
- 是 → 检查是否已同步 `epee-orchestrator` 的 `registry.md`,未同步则立即执行
4. **本次是否有 Skill 使用异常?**(遗漏、误触、产物与用户预期偏差)
- 否 → 跳过
- 是 → 执行 `epee-orchestrator.mdc` 中的自迭代确认流程
5. **静默 Hook 重复触发**(每次 checklist 完成后必须执行)
- 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack`
- 这会更新 ack 文件的 mtime阻止 `check-changelog.ps1` hook 在后续回复中重复提醒
**执行原则**:先完成 checklist 中的所有写入动作,再输出面向用户的回复文本。
回复末尾如果执行了第 2 项,附 `[已记录到开发日志]`。

View File

@@ -28,6 +28,9 @@ Agent **必须**执行以下操作:
2. 更新或新增对应 Skill 的条目(格式参见 registry.md 中的条目结构) 2. 更新或新增对应 Skill 的条目(格式参见 registry.md 中的条目结构)
3. 确保条目中的能力描述和触发场景与 Skill 实际内容一致 3. 确保条目中的能力描述和触发场景与 Skill 实际内容一致
> **注意**:此项已纳入 `changelog-recall.mdc` 的"任务完成 Checklist"第 3 项。
> 如果 Agent 在 checklist 阶段发现遗漏,必须立即补执行。
### Skill 自迭代(强制) ### Skill 自迭代(强制)
每个 SKILL.md 必须包含一个"自迭代日志"章节,用于记录使用该 Skill 过程中发现的经验教训。 每个 SKILL.md 必须包含一个"自迭代日志"章节,用于记录使用该 Skill 过程中发现的经验教训。
@@ -38,6 +41,9 @@ Agent **必须**执行以下操作:
2. 用户需要反复补充同类信息 2. 用户需要反复补充同类信息
3. 生成产物与用户预期存在系统性偏差 3. 生成产物与用户预期存在系统性偏差
> **注意**:此项已纳入 `changelog-recall.mdc` 的"任务完成 Checklist"第 4 项。
> Agent 不应等到"下次使用 Skill 时"才想起自迭代——当次就应检查。
**流程** **流程**
1. 识别问题根因,归纳为一条简明的检查项 1. 识别问题根因,归纳为一条简明的检查项

View File

@@ -0,0 +1,31 @@
## 踩坑经验自动检索
### 被动检测触发
Agent 在以下场景中,应自动读取 `.cursor/pitfalls/pitfalls.md` 并进行匹配检索:
1. **进入 Debug mode**:读取全部条目,将当前错误症状与已有记录比对
2. **遇到运行时错误**:提取错误信息关键词,在"症状"字段中检索匹配
3. **同一问题第二次出现**:如果当前会话中某个错误已出现过一次且未解决,强制检索
### 匹配策略
```
1. 提取当前问题的信号:错误信息关键词、涉及文件/模块、技术栈
2. 在 pitfalls.md 中匹配:
- 硬匹配:错误关键词出现在条目的"症状"中
- 软匹配:模块/技术栈出现在条目的"关联"中
3. 命中时在分析开头提示:
> 注意:之前遇到过类似问题 [PF-xxx][标题]。根因是 [xxx],先排查这个方向。
```
### 写入提醒
Agent 在完成涉及 debug/修复的任务后,应读取 `pitfall-journal` Skill 并执行其"操作 A写入记录"流程。
判断标准:问题的根因是否"非显而易见"——如果只看代码逻辑觉得应该没问题,但实际运行时才暴露,就值得记录。
### 注意
- 检索结果是**辅助参考**,不是确定性答案——匹配到不代表根因一定相同
- pitfalls.md 不存在时跳过,不报错
- 每次会话中对同一条 pitfall 最多提醒一次

View File

@@ -0,0 +1,18 @@
---
description: 当用户要求启动项目、运行项目、打开前端/后端服务时触发
globs:
alwaysApply: false
---
## 项目启动快捷指令
当用户表达以下意图时,读取并执行 `project-launcher` Skill
**触发关键词**启动项目、运行项目、跑起来、打开前端和后端、启动服务、start、launch、run dev、启动前端、启动后端
**执行方式**
1. 读取 `.cursor/skills/project-launcher/SKILL.md`
2. 按 Skill 中的流程,在**独立可见的终端窗口**中启动前端和/或后端
3. 必须使用 `Start-Process`PowerShell或 `start cmd`CMD打开新窗口
4. **不要**在 Cursor 内置终端中后台运行——用户需要看到窗口并能手动关闭

View File

@@ -15,7 +15,7 @@ description: >-
| 层级 | 文件 | 信息密度 | 条目数量 | 注入方式 | | 层级 | 文件 | 信息密度 | 条目数量 | 注入方式 |
|------|------|---------|---------|---------| |------|------|---------|---------|---------|
| L1 完整版 | `changelog-full.md` | 高5-15 行/条) | 无上限,只追加 | 用户手动唤醒 | | L1 完整版 | `changelog-full.md` | 高5-15 行/条) | 无上限,只追加 | 用户手动唤醒 |
| L2 中期版 | `changelog-recent.md` | 中3-5 行/条) | 滚动窗口 ~10 条 | 检测到关联时自动读取 | | L2 中期版 | `changelog-recent.md` | 中3-5 行/条) | 滚动窗口 ~20 条 | 检测到关联时自动读取 |
| L3 概要版 | `changelog-headlines.md` | 低1 行/条) | 滚动窗口 ~50 条 | 每次会话自动注入 | | L3 概要版 | `changelog-headlines.md` | 低1 行/条) | 滚动窗口 ~50 条 | 每次会话自动注入 |
所有数据文件存放在 `.cursor/changelog/` 目录下。 所有数据文件存放在 `.cursor/changelog/` 目录下。
@@ -182,12 +182,12 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
## 滚动窗口维护 ## 滚动窗口维护
### L2 窗口(~10 条) ### L2 窗口(~20 条)
``` ```
写入新条目后,检查总条目数: 写入新条目后,检查总条目数:
- <= 10 条:不做处理 - <= 20 条:不做处理
- > 10 条:移除文件底部(最旧的)条目,直到恰好 10 条 - > 20 条:移除文件底部(最旧的)条目,直到恰好 20 条
``` ```
### L3 窗口(~50 条) ### L3 窗口(~50 条)
@@ -246,4 +246,4 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
### 已知必要检查 ### 已知必要检查
(暂无) 1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。

View File

@@ -122,4 +122,4 @@ description: >-
### 已知必要检查 ### 已知必要检查
(暂无) 1. **收尾动作级联遗漏** — 当 Agent 遗漏了一个收尾动作(如 changelog 写入后续的收尾动作自迭代、Registry 同步)也会被一并遗漏,因为它们都在同一个"收尾阶段"。`changelog-recall.mdc` 中的 Checklist 化设计可以打断这种级联——每项独立检查,不依赖前一项的执行记忆。

View File

@@ -39,4 +39,20 @@
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入 - **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md - **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
- **路径**: .cursor/skills/dev-changelog/SKILL.md - **路径**: .cursor/skills/dev-changelog/SKILL.md
- **备注**: 写入由 changelog-recall Rule 的被动写入提醒触发L3 概要每次会话自动注入上下文 - **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文stop Hook 双保险兜底
### pitfall-journal
- **类型**: 基础设施
- **能力**: 记录开发中踩过的坑(非显而易见的问题),在遇到同类问题时自动检索匹配已有经验
- **触发场景**: debug 完成后、"踩坑"、"之前遇到过"、"坑"、进入 Debug mode、同类错误反复出现
- **输出**: .cursor/pitfalls/pitfalls.md 条目
- **路径**: .cursor/skills/pitfall-journal/SKILL.md
- **备注**: 与 dev-changelog 互补——changelog 记事实pitfall 记经验;由 pitfall-recall Rule 触发自动检索
### project-launcher
- **类型**: 项目级
- **能力**: 一键在独立可见终端窗口中启动 Art Agent 前端和后端服务
- **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端"
- **输出**: 两个独立的终端窗口(前端 Next.js + 后端 FastAPI
- **路径**: .cursor/skills/project-launcher/SKILL.md
- **备注**: 项目专属 Skill窗口必须可见用户可直接查看日志和手动关闭

View File

@@ -0,0 +1,116 @@
---
name: pitfall-journal
description: >-
踩坑经验记录系统。在调试完成或发现非显而易见的坑后记录根因和解决方式,
后续遇到同类问题时自动检索匹配,避免重复踩坑。
当 debug 完成、问题反复出现、或用户提到"踩坑"、"之前遇到过"、"坑"时触发。
---
# Pitfall Journal
记录开发过程中遇到的"坑"——那些不看代码逻辑觉得应该没问题、但实际运行时才暴露的问题。
与 dev-changelog 互补changelog 记"做了什么"pitfall-journal 记"踩了什么坑、怎么爬出来的"。
## 数据文件
所有记录存放在 `.cursor/pitfalls/pitfalls.md`
## 条目格式
```markdown
### [PF-YYYYMMDD-HHMM] 一句话标题
- **症状**: 用户/系统看到的错误表现
- **根因**: 技术层面的真正原因
- **解法**: 具体怎么修的
- **防御**: 以后如何避免(可选,如果有通用性的话)
- **关联**: 相关文件、模块、技术栈标签
```
## 操作 A写入记录
### 触发条件
以下任一场景触发:
1. **调试完成后** — 经历了 debug 过程并找到了非显而易见的根因
2. **用户主动提及** — "记录一下这个坑"、"以后别再犯"
3. **Agent 识别到经验价值** — 问题涉及框架/库的隐式行为、配置陷阱、环境差异等
以下情况**不触发**
- 纯拼写错误、简单语法错误
- 问题原因一目了然(如变量名打错)
- 纯业务逻辑调整(不涉及"坑"的语义)
### 流程
```
1. 生成条目 IDPF-YYYYMMDD-HHMM
2. 从调试过程中提取:症状、根因、解法
3. 归纳防御措施(如果有通用性)
4. 读取 pitfalls.md在顶部追加新条目
5. 在回复末尾附:[已记录到踩坑日志]
```
### 静默写入原则
与 dev-changelog 一致——Agent 自己调试出来的问题,不需要用户确认就可以记录。
## 操作 B自动匹配检索
### 触发条件
当 Agent 在当前任务中遇到以下情况时,应主动检索 pitfalls.md
1. **进入 Debug mode** — 读取 pitfalls.md扫描是否有与当前错误症状匹配的记录
2. **同类错误再现** — 错误信息关键词与已有条目的"症状"匹配
3. **涉及已知高危区域** — 当前操作涉及的模块/技术栈在已有条目的"关联"中出现
### 匹配策略
```
1. 提取当前问题的关键信号:
- 错误信息关键词
- 涉及的文件/模块
- 涉及的技术栈/框架
2. 在 pitfalls.md 中匹配:
- 硬匹配:错误信息关键词出现在条目的"症状"中
- 软匹配:涉及的模块/技术栈出现在条目的"关联"中
3. 命中时,在分析中优先考虑已有经验:
> 注意:之前遇到过类似问题 [PF-xxx][一句话描述]。
> 上次的根因是 [xxx],先排查这个方向。
```
## 操作 C手动检索
### 触发条件
用户主动要求回顾踩坑记录。典型话语:
- "之前那个坑是什么来着"
- "看看踩坑日志"
- "有遇到过类似的问题吗"
### 流程
```
1. 读取 pitfalls.md
2. 根据用户描述匹配相关条目
3. 展示匹配结果
```
## 与其他系统的协作
| 系统 | 关系 | 说明 |
|------|------|------|
| `dev-changelog` | 互补 | changelog 记改动事实pitfall 记经验教训 |
| `pitfall-recall` Rule | 下游消费者 | 进入 Debug mode 或遇到错误时自动触发检索 |
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
(暂无)

View File

@@ -0,0 +1,131 @@
---
name: project-launcher
description: >-
Art Agent 项目一键启动。当用户说"启动项目"、"运行项目"、"打开前端和后端"等时触发,
自动在独立的可见终端窗口中启动前端Next.js和后端FastAPI方便用户随时查看和关闭。
---
# Project Launcher
一键启动 Art Agent 的前端和后端服务,在**独立可见的终端窗口**中运行,
用户可以随时查看日志或手动关闭。
## 触发条件
当用户表达以下意图时触发:
- "启动项目"、"运行项目"、"跑起来"
- "打开前端和后端"、"启动服务"
- "start"、"launch"、"run dev"
- "启动后端"、"启动前端"(可单独启动其中一个)
## 项目路径
| 组件 | 路径 | 启动命令 |
|------|------|---------|
| 后端 | `art-agent/backend` | `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` |
| 前端 | `art-agent/frontend` | `npm run dev` |
## 前置条件
后端需要激活 Python 虚拟环境(`art-agent/backend/venv`)。
前端需要 Node.js >= 18。
## 环境 PATH 须知
本机 Node.js 安装在 `C:\Program Files\nodejs\` 但**未加入系统 PATH**。
新开的终端窗口默认找不到 `node` / `npm` 命令。
**解决方式**:在启动前端的命令中,先将 Node.js 路径注入到当前会话的 `$env:PATH` 中。
> 如果后续 Node.js 路径发生变化(如用户重新安装或使用 nvm需要更新此处。
## 核心操作:启动服务
### 流程
```
1. 确定项目根目录workspace 根目录下的 art-agent/
2. 确定操作系统和 Shell 类型Windows / macOS / Linux
3. 启动后端(在独立可见终端窗口中):
- WindowsPowerShell 或 CMD 均适用):
使用 `Start-Process` 或 `start cmd` 打开新的终端窗口
- macOS/Linux
使用对应的终端打开方式
4. 启动前端(在另一个独立可见终端窗口中):
- 同样在新的终端窗口中启动
5. 确认两个服务正在运行,告知用户访问地址
```
### Windows 启动命令
**关键要求**:必须在**新的、可见的终端窗口**中启动,不能在 Cursor 内置终端后台运行。
#### PowerShell 环境
启动后端:
```powershell
Start-Process powershell -ArgumentList '-NoExit', '-Command', "cd 'BACKEND_PATH'; .\venv\Scripts\Activate.ps1; uvicorn app.main:app --reload --host 0.0.0.0 --port 8000" -WindowStyle Normal
```
启动前端(注意注入 Node.js PATH
```powershell
Start-Process powershell -ArgumentList '-NoExit', '-Command', "& { `$env:PATH = 'C:\Program Files\nodejs;' + `$env:PATH; `$Host.UI.RawUI.WindowTitle = 'Art Agent Frontend'; cd 'FRONTEND_PATH'; npm run dev }" -WindowStyle Normal
```
#### CMD 环境
启动后端:
```cmd
start "Art Agent Backend" cmd /k "cd /d BACKEND_PATH && venv\Scripts\activate && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"
```
启动前端(注意注入 Node.js PATH
```cmd
start "Art Agent Frontend" cmd /k "set PATH=C:\Program Files\nodejs;%PATH% && cd /d FRONTEND_PATH && npm run dev"
```
### macOS / Linux 启动命令
根据用户终端环境选择:
```bash
# 后端
osascript -e 'tell application "Terminal" to do script "cd BACKEND_PATH && source venv/bin/activate && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"'
# 前端
osascript -e 'tell application "Terminal" to do script "cd FRONTEND_PATH && npm run dev"'
```
### 执行注意事项
1. **路径拼接**`BACKEND_PATH``FRONTEND_PATH` 必须替换为实际的绝对路径
2. **venv 存在性检查**:启动后端前先确认 `art-agent/backend/venv` 目录存在,
不存在时提醒用户先创建虚拟环境
3. **依赖检查**:如果 `node_modules` 不存在,先提醒用户执行 `npm install`
4. **窗口标题**:尽量为窗口设置有意义的标题(如 "Art Agent Backend"、"Art Agent Frontend"
方便用户在任务栏中识别
5. **不使用 `block_until_ms: 0`**:不要用 Cursor 的后台命令方式,
那样窗口不可见,用户无法直接查看和关闭
## 单独启动
如果用户只说"启动前端"或"启动后端",只启动对应的服务即可,不需要全部启动。
## 访问信息
启动完成后告知用户:
- 后端 APIhttp://localhost:8000
- 后端文档http://localhost:8000/docs
- 前端页面http://localhost:3000
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
1. **Node.js PATH 注入** — 本机 Node.js (`C:\Program Files\nodejs\`) 未加入系统 PATH新开的终端窗口默认找不到 `npm`。启动前端时必须先将此路径注入到会话 PATH 中。

54
.gitignore vendored Normal file
View File

@@ -0,0 +1,54 @@
# ═══════════════════════════════════════════════════════════
# EPEEAIKit .gitignore
# ═══════════════════════════════════════════════════════════
# ─── Python ──────────────────────────────────────────────
__pycache__/
*.py[cod]
*$py.class
*.pyo
# 虚拟环境
venv/
.venv/
env/
# 分发 / 构建
*.egg-info/
*.egg
dist/
build/
# ─── Node.js / Next.js ──────────────────────────────────
node_modules/
.next/
out/
# ─── 环境变量 / 密钥(保留 .env.example 作为模板)───────
.env
.env.local
.env.*.local
!.env.example
# ─── 运行时生成物 ────────────────────────────────────────
art-agent/backend/uploads/
art-agent/backend/generated/
# ─── 工具二进制 ──────────────────────────────────────────
*.exe
# ─── IDE / 编辑器 ────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
*~
# ─── OS 文件 ─────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ─── 日志 / 调试 ─────────────────────────────────────────
*.log
npm-debug.log*

View File

@@ -1,3 +0,0 @@
{
"AndreaNovelHelper.workspaceDisabled": true
}

View File

@@ -1,6 +1,6 @@
# Art Agent MVP # EPEEKit
AI 美术资源生成助手 — 通过对话生成游戏美术资源。 AI 美术资源生成工具集 — 通过对话生成游戏美术资源。
## 快速开始 ## 快速开始
@@ -8,7 +8,7 @@ AI 美术资源生成助手 — 通过对话生成游戏美术资源。
你需要两个 API Key 你需要两个 API Key
- **OpenAI API Key**https://platform.openai.com/api-keys - **OpenAI API Key**(或兼容 APIhttps://platform.openai.com/api-keys
- **Replicate API Token**https://replicate.com/account/api-tokens - **Replicate API Token**https://replicate.com/account/api-tokens
### 2. 启动后端 ### 2. 启动后端
@@ -26,10 +26,10 @@ pip install -r requirements.txt
# 配置环境变量 # 配置环境变量
copy .env.example .env copy .env.example .env
# 编辑 .env填入你的 API Key # 编辑 .env填入你的 API Key 和模型配置
# 启动 # 启动--host 0.0.0.0 允许局域网/穿透访问)
uvicorn app.main:app --reload --port 8000 uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
``` ```
### 3. 启动前端 ### 3. 启动前端
@@ -53,6 +53,43 @@ npm run dev
- 生成的图片可以点击"保存"按钮下载 - 生成的图片可以点击"保存"按钮下载
- 持续对话进行迭代修改 - 持续对话进行迭代修改
## 访问地址
| 场景 | 前端页面 | 后端 API | 后端文档 |
|------|---------|---------|---------|
| **本机** | http://localhost:3000 | http://localhost:8000 | http://localhost:8000/docs |
| **局域网**(同一 WiFi | `http://<你的IP>:3000` | `http://<你的IP>:8000` | `http://<你的IP>:8000/docs` |
| **外网**Cloudflare Tunnel | `https://xxx.trycloudflare.com`(每次启动不同) | `https://yyy.trycloudflare.com` | — |
查看本机局域网 IP
```powershell
# Windows
Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -eq 'WLAN' }
# macOS / Linux
ifconfig | grep "inet " | grep -v 127.0.0.1
```
## 配置说明
所有可配置项都集中在 `backend/.env` 文件中,分为以下几组:
| 配置组 | 变量 | 说明 |
|--------|------|------|
| **LLM 对话** | `OPENAI_API_KEY` | API 密钥 |
| | `OPENAI_BASE_URL` | API 基地址,支持任何 OpenAI 兼容 API |
| | `LLM_MODEL` | 模型名称,如 `gpt-4o-mini``deepseek-chat` 等 |
| | `LLM_MAX_ITERATIONS` | Agent 单轮最大工具调用次数 |
| **图像生成** | `REPLICATE_API_TOKEN` | Replicate API Token |
| | `IMAGE_MODEL` | 图像生成模型,如 `black-forest-labs/flux-schnell` |
| | `IMAGE_ASPECT_RATIO` | 默认宽高比,如 `1:1` |
| | `IMAGE_OUTPUT_FORMAT` | 输出格式,如 `png` |
| **网络** | `HTTP_PROXY` / `HTTPS_PROXY` | 代理配置 |
| **服务** | `PORT` | 后端服务端口 |
切换 LLM 供应商只需修改 `OPENAI_BASE_URL` + `OPENAI_API_KEY` + `LLM_MODEL` 三个值。
## 项目结构 ## 项目结构
``` ```
@@ -60,6 +97,7 @@ art-agent/
backend/ # Python FastAPI 后端 backend/ # Python FastAPI 后端
app/ app/
main.py # 入口 main.py # 入口
config.py # 集中配置(读取 .env
api/chat.py # 对话 API api/chat.py # 对话 API
agent/ # Agent Loop 核心 agent/ # Agent Loop 核心
services/ # AI 服务封装 services/ # AI 服务封装
@@ -71,8 +109,69 @@ art-agent/
docs/ # 文档 docs/ # 文档
``` ```
## 远程访问(外网穿透)
通过 Cloudflare Quick Tunnel 免费穿透内网,手机或朋友在任何网络下都能访问,无需买服务器。
### 安装 cloudflared
```powershell
# Windows任选一种
winget install Cloudflare.cloudflared
# 或手动下载
# https://github.com/cloudflare/cloudflared/releases/latest
# 下载 cloudflared-windows-amd64.exe放到项目目录下即可
```
```bash
# macOS
brew install cloudflared
# Linux
# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
```
### 一键启动穿透
确保前后端服务已经在运行,然后在另一个终端执行:
```powershell
cd art-agent
.\start-tunnel.ps1
```
脚本会自动:
1. 为后端和前端各创建一条穿透隧道
2. 更新 `frontend/.env.local` 中的后端 API 地址为穿透地址
3. 打印手机可访问的公网 HTTPS 链接
4. 关闭脚本时Ctrl+C自动恢复 `.env.local` 为 localhost
> **重要**:穿透启动后需要**重启前端** (`npm run dev`) 才能读取新的 `.env.local`。
### 穿透管理
| 操作 | 方式 |
|------|------|
| **启动** | 运行 `.\start-tunnel.ps1`,等待打印出两条 `trycloudflare.com` 地址 |
| **停止** | 在脚本窗口按 `Ctrl+C`,或在任务管理器中结束 `cloudflared.exe` 进程 |
| **查看状态** | 检查日志:`Get-Content $env:TEMP\epeekit-tunnel-backend.log` |
| **重启** | 先停止再启动地址会变Quick Tunnel 每次分配随机域名) |
### 注意事项
- Quick Tunnel **免费无需账号**,但地址每次启动都会变化
- 如果 cloudflared 连接超时可能需要配置代理Clash 等),在运行脚本前设置:
```powershell
$env:HTTP_PROXY = "http://127.0.0.1:7890"
$env:HTTPS_PROXY = "http://127.0.0.1:7890"
.\start-tunnel.ps1
```
- 电脑关机、休眠或代理断开后隧道会自动失效
- 停止穿透后,`start-tunnel.ps1` 会自动将 `.env.local` 恢复为 `localhost`;如果手动杀进程,需要自行恢复
## 技术栈 ## 技术栈
- 前端Next.js + TypeScript + Tailwind CSS - 前端Next.js + TypeScript + Tailwind CSS
- 后端Python FastAPI + SSE - 后端Python FastAPI + SSE
- AIOpenAI GPT-4o-mini(对话+ Replicate Flux(图像生成 - AI可配置 LLM默认 GPT-4o-mini+ 可配置图像模型(默认 Replicate Flux

View File

@@ -1,10 +0,0 @@
# OpenAI API Key
# 获取地址: https://platform.openai.com/api-keys
OPENAI_API_KEY=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
# Replicate API Token
# 获取地址: https://replicate.com/account/api-tokens
REPLICATE_API_TOKEN=r8_dVRCon51vpyThJf0Nu8IqHUUhptQOEY04QVuN
# 后端服务端口
PORT=8000

View File

@@ -0,0 +1,45 @@
# ╔═══════════════════════════════════════════════════════════╗
# ║ EPEEKit — 集中配置 ║
# ║ 切换 LLM 只需改 OPENAI_BASE_URL + OPENAI_API_KEY ║
# ║ + LLM_MODEL 三个值 ║
# ╚═══════════════════════════════════════════════════════════╝
# ─── LLM 对话配置 ─────────────────────────────────────────
# API 密钥OpenAI 或兼容 API 的 key
OPENAI_API_KEY=sk-your-key-here
# API 基地址(留空 = 直连 api.openai.com
# 常见替代值:
# OpenRouter: https://openrouter.ai/api/v1
# DeepSeek: https://api.deepseek.com/v1
# 本地 Ollama: http://localhost:11434/v1
# OPENAI_BASE_URL=
# 对话模型名称
# 常见选项: gpt-4o-mini | gpt-4o | deepseek-chat | claude-3-haiku-20240307
LLM_MODEL=gpt-4o-mini
# Agent 单轮最大工具调用次数(防止无限循环)
LLM_MAX_ITERATIONS=5
# ─── 图像生成配置 ─────────────────────────────────────────
# Replicate API Token
REPLICATE_API_TOKEN=your-replicate-token-here
# 默认图像生成模型(注册表短 ID前端未指定时的 fallback
# 可选值: flux-schnell | flux-dev | sdxl
# 也兼容完整 Replicate model ID 如 black-forest-labs/flux-schnell
IMAGE_MODEL=flux-schnell
# 默认宽高比
IMAGE_ASPECT_RATIO=1:1
# 输出格式
IMAGE_OUTPUT_FORMAT=png
# ─── 网络代理(按需配置)─────────────────────────────────
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
# ─── 服务配置 ─────────────────────────────────────────────
PORT=8000

View File

@@ -1,15 +1,19 @@
"""Agent Loop 主循环LLM 对话 -> 工具调用 -> 结果回传 -> 继续。""" """Agent Loop 主循环LLM 对话 -> 工具调用 -> 结果回传 -> 继续。"""
import json import json
import os
from typing import AsyncGenerator, Optional from typing import AsyncGenerator, Optional
from openai import AsyncOpenAI from openai import AsyncOpenAI
from app.agent.tools import TOOL_DEFINITIONS, execute_tool from app.agent.tools import TOOL_DEFINITIONS, execute_tool
from app.config import get_llm_model, get_llm_max_iterations
from app.services.image_gen import to_data_uri
def _get_client() -> AsyncOpenAI: def _get_client() -> AsyncOpenAI:
return AsyncOpenAI() base_url = os.getenv("OPENAI_BASE_URL")
return AsyncOpenAI(base_url=base_url) if base_url else AsyncOpenAI()
SYSTEM_PROMPT = """\ SYSTEM_PROMPT = """\
你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。 你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。
@@ -19,6 +23,7 @@ SYSTEM_PROMPT = """\
- 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt - 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt
- 根据用户反馈迭代修改(调整颜色、风格、构图等) - 根据用户反馈迭代修改(调整颜色、风格、构图等)
- 如果用户提供了参考图,将参考图的风格元素融入生成 prompt - 如果用户提供了参考图,将参考图的风格元素融入生成 prompt
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
## 工作流程 ## 工作流程
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等) 1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
@@ -26,11 +31,20 @@ SYSTEM_PROMPT = """\
3. 向用户展示结果并询问反馈 3. 向用户展示结果并询问反馈
4. 根据反馈调整 prompt 并重新生成 4. 根据反馈调整 prompt 并重新生成
## 标注理解
当用户发送带有「图片标注」的消息时,表示用户在之前生成的图片上做了标注。
标注格式为:`[区域 (x%, y%) 大小 w%×h%]: 修改意见`
- 区域坐标表示标注框在图片上的相对位置
- 你需要理解标注区域所指的图片内容,并将修改意见融入新的 prompt
- 如果附带了标注截图(参考图),仔细观察红色标注框和文字来理解用户意图
- 在调整 prompt 时,保持原图整体风格不变,只针对标注区域做修改
## 生成 prompt 要求 ## 生成 prompt 要求
- 必须使用英文 - 必须使用英文
- 尽量详细描述:主体内容、风格、颜色方案、光照、构图、材质等 - 尽量详细描述:主体内容、颜色方案、光照、构图、材质等
- 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button" - 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button"
- 如果用户提供了参考图,在 prompt 中描述参考图的风格特征 - 如果你能直接看到参考图(图片内容),可以在 prompt 中描述参考图的风格特征
- 如果你无法看到参考图(只收到了文字提示说有参考图),参考图会由生图工具的 IP-Adapter 自动处理风格融合。此时你的 prompt 中**绝对不要猜测或指定任何画风/艺术风格关键词**(如 pixel art、watercolor、oil painting 等),只描述画面内容(主体、构图、光照等),把风格完全交给参考图来决定
## 注意事项 ## 注意事项
- 用中文和用户交流 - 用中文和用户交流
@@ -42,6 +56,7 @@ SYSTEM_PROMPT = """\
async def run_agent_loop( async def run_agent_loop(
messages: list[dict], messages: list[dict],
ref_image_url: Optional[str] = None, ref_image_url: Optional[str] = None,
image_model: Optional[str] = None,
) -> AsyncGenerator[dict, None]: ) -> AsyncGenerator[dict, None]:
""" """
运行 Agent Loop以 SSE 事件流形式 yield 结果。 运行 Agent Loop以 SSE 事件流形式 yield 结果。
@@ -56,33 +71,45 @@ async def run_agent_loop(
# 构建消息列表 # 构建消息列表
api_messages = [{"role": "system", "content": SYSTEM_PROMPT}] api_messages = [{"role": "system", "content": SYSTEM_PROMPT}]
# 检测当前 LLM 是否支持 vision多模态图片输入
llm_model = get_llm_model().lower()
vision_capable = any(kw in llm_model for kw in ("gpt-4o", "gpt-4-vision", "claude"))
for msg in messages: for msg in messages:
if msg["role"] == "user" and ref_image_url: if msg["role"] == "user" and ref_image_url and msg is messages[-1]:
# 最后一条用户消息附加参考图GPT-4o vision if vision_capable:
if msg == messages[-1] or ( # 支持 vision 的模型:直接发送图片
msg.get("role") == "user"
and messages.index(msg) == len(messages) - 1
):
api_messages.append({ api_messages.append({
"role": "user", "role": "user",
"content": [ "content": [
{"type": "text", "text": msg["content"]}, {"type": "text", "text": msg["content"]},
{ {
"type": "image_url", "type": "image_url",
"image_url": {"url": ref_image_url}, "image_url": {"url": to_data_uri(ref_image_url)},
}, },
], ],
}) })
continue else:
# 不支持 vision 的模型:以文字提示告知有参考图,风格由 IP-Adapter 处理
hint = (
f"{msg['content']}\n\n"
"【系统提示:用户上传了一张参考图,已自动传递给图片生成工具的 IP-Adapter。"
"IP-Adapter 会从参考图中提取风格并融合到生成结果中。"
"你无法看到这张参考图,因此在生成 prompt 时:\n"
"1. 只描述画面内容(主体、构图、光照、材质等)\n"
"2. 不要猜测或添加任何画风/艺术风格关键词(如 pixel art、watercolor、cartoon 等)\n"
"3. 风格完全由参考图通过 IP-Adapter 决定】"
)
api_messages.append({"role": "user", "content": hint})
continue
api_messages.append({"role": msg["role"], "content": msg["content"]}) api_messages.append({"role": msg["role"], "content": msg["content"]})
client = _get_client() client = _get_client()
max_iterations = 5 for _ in range(get_llm_max_iterations()):
for _ in range(max_iterations):
try: try:
response = await client.chat.completions.create( response = await client.chat.completions.create(
model="gpt-4o-mini", model=get_llm_model(),
messages=api_messages, messages=api_messages,
tools=TOOL_DEFINITIONS, tools=TOOL_DEFINITIONS,
stream=True, stream=True,
@@ -163,13 +190,28 @@ async def run_agent_loop(
except json.JSONDecodeError: except json.JSONDecodeError:
arguments = {} arguments = {}
result = await execute_tool(tool_name, arguments, ref_image_url) result = await execute_tool(tool_name, arguments, ref_image_url, image_model)
used_model = result.get("model_name", "")
if result.get("errors"):
yield {
"type": "tool_error",
"data": {
"tool": tool_name,
"errors": result["errors"],
"model_name": used_model,
},
}
# 如果有图片结果,推送给前端
if result.get("images"): if result.get("images"):
yield { yield {
"type": "image_result", "type": "image_result",
"data": {"images": result["images"]}, "data": {
"images": result["images"],
"prompt_used": result.get("prompt_used", ""),
"model_name": used_model,
},
} }
# 工具结果回传给 LLM # 工具结果回传给 LLM
@@ -179,7 +221,7 @@ async def run_agent_loop(
"content": json.dumps(result, ensure_ascii=False), "content": json.dumps(result, ensure_ascii=False),
}) })
# 清除 ref_image_url,避免后续轮次重复附加 # ref_image_url 不清除:消息构建只在循环外执行一次,
ref_image_url = None # 后续工具调用仍需参考图InstantStyle 等模型必须有 style_image
yield {"type": "done", "data": {}} yield {"type": "done", "data": {}}

View File

@@ -38,20 +38,27 @@ async def execute_tool(
tool_name: str, tool_name: str,
arguments: dict, arguments: dict,
ref_image_url: str | None = None, ref_image_url: str | None = None,
image_model: str | None = None,
) -> dict: ) -> dict:
"""执行工具调用,返回结果。""" """执行工具调用,返回结果。"""
if tool_name == "generate_image": if tool_name == "generate_image":
prompt = arguments["prompt"] prompt = arguments["prompt"]
num_images = arguments.get("num_images", 1) num_images = arguments.get("num_images", 1)
image_urls = await generate_images( result = await generate_images(
prompt=prompt, prompt=prompt,
num_images=num_images, num_images=num_images,
ref_image_url=ref_image_url, ref_image_url=ref_image_url,
model_id=image_model,
) )
valid_urls = [u for u in result.urls if not u.startswith("[")]
errors = [u for u in result.urls if u.startswith("[")]
return { return {
"success": True, "success": len(valid_urls) > 0,
"images": image_urls, "images": valid_urls,
"errors": errors,
"prompt_used": prompt, "prompt_used": prompt,
"model_name": result.model_name,
"model_id": result.model_id,
} }
return {"success": False, "error": f"未知工具: {tool_name}"} return {"success": False, "error": f"未知工具: {tool_name}"}

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, File, Form, UploadFile
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse
from app.agent.loop import run_agent_loop from app.agent.loop import run_agent_loop
from app.config import get_image_models_list, get_default_image_model_id
router = APIRouter() router = APIRouter()
@@ -23,26 +24,56 @@ async def _save_upload(file: UploadFile) -> str:
return f"/uploads/{filename}" return f"/uploads/{filename}"
@router.post("/upload-ref-image")
async def upload_ref_image(
file: UploadFile = File(...),
):
"""
独立的参考图上传端点。
前端选图后立即调用,返回服务端路径,供后续发消息时引用。
"""
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
url = await _save_upload(file)
return {"url": url, "filename": file.filename}
@router.get("/models")
async def list_models():
"""返回可用的图像生成模型列表。"""
return {
"models": get_image_models_list(),
"default": get_default_image_model_id(),
}
@router.post("/chat") @router.post("/chat")
async def chat( async def chat(
messages: str = Form(...), messages: str = Form(...),
ref_image: Optional[UploadFile] = File(None), ref_image: Optional[UploadFile] = File(None),
ref_image_url: Optional[str] = Form(None),
image_model: Optional[str] = Form(None),
): ):
""" """
主对话端点。 主对话端点。
参数: 参数:
- messages: JSON 字符串,对话历史 [{role, content}] - messages: JSON 字符串,对话历史 [{role, content}]
- ref_image: 可选的参考图文件 - ref_image: 可选的参考图文件(兼容旧方式)
- ref_image_url: 可选,已通过 /upload-ref-image 上传后的服务端路径
- image_model: 可选,指定本次使用的生图模型短 ID
""" """
parsed_messages = json.loads(messages) parsed_messages = json.loads(messages)
ref_image_url: Optional[str] = None resolved_ref_url: Optional[str] = None
if ref_image and ref_image.filename: if ref_image_url:
ref_image_url = await _save_upload(ref_image) resolved_ref_url = ref_image_url
elif ref_image and ref_image.filename:
resolved_ref_url = await _save_upload(ref_image)
async def event_generator(): async def event_generator():
async for event in run_agent_loop(parsed_messages, ref_image_url): async for event in run_agent_loop(
parsed_messages, resolved_ref_url, image_model=image_model
):
yield { yield {
"event": event["type"], "event": event["type"],
"data": json.dumps(event["data"], ensure_ascii=False), "data": json.dumps(event["data"], ensure_ascii=False),

View File

@@ -0,0 +1,156 @@
"""
EPEEKit 集中配置。
所有可调参数从环境变量读取,每次调用实时读取(不缓存),确保 load_dotenv() 后生效。
"""
import os
from typing import Any
def get_llm_model() -> str:
return os.getenv("LLM_MODEL", "gpt-4o-mini")
def get_llm_max_iterations() -> int:
return int(os.getenv("LLM_MAX_ITERATIONS", "5"))
# ─── 图像模型注册表 ─────────────────────────────────────
#
# 每个模型的配置说明:
# id — 前端/API 使用的短 ID
# name — 显示名称
# provider — 生成服务提供者(对应 image_gen.py 中的 Provider
# model_id — Replicate 上的完整模型 ID
# description — 前端下拉列表中的说明文字
# supports_ref_image — 是否原生支持参考图输入IP-Adapter 等)
# ref_image_param — 传给 Replicate 的参考图参数名(模型间可能不同)
# num_images_param — 批量生成参数名Flux 用 num_outputsKolors 用 number_of_images
# default_params — 默认推理参数
IMAGE_MODELS: dict[str, dict[str, Any]] = {
"flux-schnell": {
"id": "flux-schnell",
"name": "Flux Schnell",
"provider": "replicate",
"model_id": "black-forest-labs/flux-schnell",
"description": "快速生成,适合快速迭代",
"supports_ref_image": False,
"num_images_param": "num_outputs",
"default_params": {
"aspect_ratio": "1:1",
"output_format": "png",
},
},
"flux-dev": {
"id": "flux-dev",
"name": "Flux Dev",
"provider": "replicate",
"model_id": "black-forest-labs/flux-dev",
"description": "高质量生成,细节更好",
"supports_ref_image": False,
"num_images_param": "num_outputs",
"default_params": {
"aspect_ratio": "1:1",
"output_format": "png",
},
},
"sdxl": {
"id": "sdxl",
"name": "Stable Diffusion XL",
"provider": "replicate",
"model_id": "stability-ai/sdxl",
"description": "经典 SDXL支持 negative prompt",
"supports_ref_image": False,
"num_images_param": "num_outputs",
"default_params": {
"width": 1024,
"height": 1024,
"num_inference_steps": 50,
"guidance_scale": 7.5,
},
},
"instant-style": {
"id": "instant-style",
"name": "InstantStyle",
"provider": "replicate",
"model_id": "jyoung105/instant-style:c6f01e12f31cb99f9ee774a78992a71294f630a6f433d9aecfdc33b816fc4baa",
"description": "强风格迁移,画风还原度高(较慢)",
"supports_ref_image": True,
"ref_image_param": "style_image",
"num_images_param": "num_outputs",
"default_params": {
"width": 1024,
"height": 1024,
"num_inference_steps": 30,
"guidance_scale": 5,
"style_strength": 1.0,
"block_mode": "style-only",
"adapter_mode": "original",
},
},
"kolors-ipadapter": {
"id": "kolors-ipadapter",
"name": "Kolors IP-Adapter",
"provider": "replicate",
"model_id": "fofr/kolors-with-ipadapter:5a1a92b2c0f81813225d48ed8e411813da41aa84e7582fb705d1af46eea36eed",
"description": "风格参考生成,上传参考图效果最佳",
"supports_ref_image": True,
"ref_image_param": "image",
"num_images_param": "number_of_images",
"default_params": {
"width": 1024,
"height": 1024,
"steps": 25,
"cfg": 4,
"ip_adapter_weight": 0.8,
"ip_adapter_weight_type": "style transfer precise",
"output_format": "png",
},
},
}
def get_default_image_model_id() -> str:
"""返回 .env 中配置的默认模型短 ID若不在注册表中则回退到 flux-schnell。"""
env_model = os.getenv("IMAGE_MODEL", "flux-schnell")
for mid, cfg in IMAGE_MODELS.items():
if cfg["model_id"] == env_model or mid == env_model:
return mid
return "flux-schnell"
def get_image_model_config(model_id: str | None = None) -> dict[str, Any]:
"""根据短 ID 获取模型配置,未指定或不存在则使用默认模型。"""
if model_id and model_id in IMAGE_MODELS:
return IMAGE_MODELS[model_id]
return IMAGE_MODELS[get_default_image_model_id()]
def get_ref_image_model_id() -> str | None:
"""返回有参考图时推荐使用的模型 ID第一个 supports_ref_image=True 的模型)。"""
for mid, cfg in IMAGE_MODELS.items():
if cfg.get("supports_ref_image"):
return mid
return None
def get_image_models_list() -> list[dict]:
"""返回前端下拉列表所需的模型摘要信息。"""
return [
{
"id": cfg["id"],
"name": cfg["name"],
"description": cfg["description"],
"supports_ref_image": cfg.get("supports_ref_image", False),
}
for cfg in IMAGE_MODELS.values()
]
def get_image_aspect_ratio() -> str:
return os.getenv("IMAGE_ASPECT_RATIO", "1:1")
def get_image_output_format() -> str:
return os.getenv("IMAGE_OUTPUT_FORMAT", "png")

View File

@@ -2,15 +2,15 @@ import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv(override=True)
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.api.chat import router as chat_router from app.api.chat import router as chat_router
load_dotenv() app = FastAPI(title="EPEEKit API")
app = FastAPI(title="Art Agent MVP")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -1,76 +1,220 @@
"""Replicate 图像生成服务封装""" """图像生成服务 — Provider 抽象层"""
import base64
import mimetypes
import os
import uuid import uuid
from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Any
import httpx import httpx
import replicate from replicate import Client as ReplicateClient
GENERATED_DIR = Path(__file__).parent.parent.parent / "generated" from app.config import get_image_model_config, get_image_aspect_ratio, get_image_output_format
BACKEND_ROOT = Path(__file__).parent.parent.parent
GENERATED_DIR = BACKEND_ROOT / "generated"
def _make_replicate_client() -> ReplicateClient:
"""
创建 Replicate 客户端。
Replicate SDK 内部创建 httpx transport 时不读取代理环境变量,
需要我们手动把代理配置注入到 transport 中。
"""
timeout = httpx.Timeout(5.0, read=300.0, write=30.0, connect=30.0, pool=10.0)
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
transport_kwargs: dict[str, Any] = {}
if proxy_url:
transport_kwargs["proxy"] = proxy_url
our_transport = httpx.AsyncHTTPTransport(**transport_kwargs)
client = ReplicateClient(
timeout=timeout,
transport=our_transport,
)
return client
_replicate_client = _make_replicate_client()
# ─── 公共工具 ──────────────────────────────────────────
def to_data_uri(image_path: str) -> str:
"""将本地路径或已有 URL 转为 Replicate 可接受的格式data URI 或原始 URL"""
if image_path.startswith("data:") or image_path.startswith("http"):
return image_path
local = BACKEND_ROOT / image_path.lstrip("/")
if local.exists():
mime = mimetypes.guess_type(str(local))[0] or "image/png"
b64 = base64.b64encode(local.read_bytes()).decode()
return f"data:{mime};base64,{b64}"
return image_path
async def _download_image(url: str) -> str: async def _download_image(url: str) -> str:
"""下载远程图片到本地 generated/ 目录,返回本地 URL 路径。""" """下载远程图片到本地 generated/ 目录,返回本地 URL 路径。"""
filename = f"{uuid.uuid4().hex}.png" filename = f"{uuid.uuid4().hex}.png"
filepath = GENERATED_DIR / filename filepath = GENERATED_DIR / filename
async with httpx.AsyncClient() as client: proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(60.0)) as client:
resp = await client.get(url, follow_redirects=True) resp = await client.get(url, follow_redirects=True)
resp.raise_for_status() resp.raise_for_status()
filepath.write_bytes(resp.content) filepath.write_bytes(resp.content)
return f"/generated/{filename}" return f"/generated/{filename}"
# ─── Provider 抽象基类 ─────────────────────────────────
class ImageProvider(ABC):
"""所有图像生成 provider 的基类。"""
@abstractmethod
async def generate(
self,
model_config: dict[str, Any],
prompt: str,
num_images: int = 1,
ref_image_url: str | None = None,
) -> list[str]:
"""生成图片并返回本地 URL 列表。"""
...
class ReplicateProvider(ImageProvider):
"""通过 Replicate API 调用模型。"""
async def generate(
self,
model_config: dict[str, Any],
prompt: str,
num_images: int = 1,
ref_image_url: str | None = None,
) -> list[str]:
replicate_model_id = model_config["model_id"]
default_params: dict[str, Any] = model_config.get("default_params", {})
local_urls: list[str] = []
try:
input_params = self._build_input(
model_config, default_params, prompt, num_images, ref_image_url
)
output = await _replicate_client.async_run(
replicate_model_id, input=input_params, wait=False
)
items = output if isinstance(output, list) else [output]
for item in items:
url = str(item)
if url.startswith("https://") or url.startswith("http://") or url.startswith("data:"):
local_urls.append(await _download_image(url))
else:
local_urls.append(f"[生成失败: 模型返回非图片内容: {url[:200]}]")
except Exception as e:
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
local_urls.append(f"[生成失败: {detail}]")
return local_urls
@staticmethod
def _build_input(
model_config: dict[str, Any],
default_params: dict[str, Any],
prompt: str,
num_images: int = 1,
ref_image_url: str | None = None,
) -> dict[str, Any]:
"""
根据模型配置构建 Replicate input 参数。
通过 model_config 中的标志字段自动适配不同模型:
- supports_ref_image / ref_image_param: 参考图注入
- num_images_param: 各模型的批量生成参数名(如 number_of_images / num_outputs
"""
model_id = model_config["model_id"]
supports_ref = model_config.get("supports_ref_image", False)
ref_param_name = model_config.get("ref_image_param", "image")
num_images_param = model_config.get("num_images_param", "num_outputs")
# ── 支持参考图的模型IP-Adapter 系列)──
if supports_ref:
params: dict[str, Any] = {"prompt": prompt}
for k, v in default_params.items():
params[k] = v
params[num_images_param] = num_images
if ref_image_url:
params[ref_param_name] = to_data_uri(ref_image_url)
return params
# ── Flux 系列(纯文生图)──
is_flux = "flux" in model_id.lower()
params = {"prompt": prompt, num_images_param: num_images}
if is_flux:
params["aspect_ratio"] = default_params.get(
"aspect_ratio", get_image_aspect_ratio()
)
params["output_format"] = default_params.get(
"output_format", get_image_output_format()
)
else:
# SDXL 类模型
params["width"] = default_params.get("width", 1024)
params["height"] = default_params.get("height", 1024)
if "num_inference_steps" in default_params:
params["num_inference_steps"] = default_params["num_inference_steps"]
if "guidance_scale" in default_params:
params["guidance_scale"] = default_params["guidance_scale"]
return params
# ─── Provider 注册 ─────────────────────────────────────
_PROVIDERS: dict[str, ImageProvider] = {
"replicate": ReplicateProvider(),
}
# ─── 公开 API ──────────────────────────────────────────
class GenerateResult:
"""图片生成结果,包含生成的 URL 列表和实际使用的模型信息。"""
def __init__(self, urls: list[str], model_name: str, model_id: str):
self.urls = urls
self.model_name = model_name
self.model_id = model_id
async def generate_images( async def generate_images(
prompt: str, prompt: str,
num_images: int = 1, num_images: int = 1,
ref_image_url: str | None = None, ref_image_url: str | None = None,
) -> list[str]: model_id: str | None = None,
) -> GenerateResult:
""" """
调用 Replicate 生成图片 统一入口:根据 model_id 查注册表,分发到对应 provider
返回本地可访问的图片 URL 列表 model_id 为空时使用 .env 中配置的默认模型
""" """
local_urls = [] config = get_image_model_config(model_id)
provider_name = config.get("provider", "replicate")
provider = _PROVIDERS.get(provider_name)
for _ in range(num_images): model_name = config.get("name", "未知模型")
try: resolved_id = config.get("id", model_id or "unknown")
if ref_image_url and ref_image_url.startswith("/"):
# 本地路径转为 file URI 不适用于 Replicate
# 需要用户上传的图先通过后端 URL 访问
# MVP 阶段:参考图作为 prompt 的文字补充,不直接传给模型
# 仅使用 flux-schnell 文生图
output = await replicate.async_run(
"black-forest-labs/flux-schnell",
input={
"prompt": prompt,
"num_outputs": 1,
"aspect_ratio": "1:1",
"output_format": "png",
},
)
else:
output = await replicate.async_run(
"black-forest-labs/flux-schnell",
input={
"prompt": prompt,
"num_outputs": 1,
"aspect_ratio": "1:1",
"output_format": "png",
},
)
# output 是 FileOutput 列表或单个 URL if not provider:
if isinstance(output, list): return GenerateResult([f"[未知 provider: {provider_name}]"], model_name, resolved_id)
for item in output:
url = str(item)
local_url = await _download_image(url)
local_urls.append(local_url)
else:
url = str(output)
local_url = await _download_image(url)
local_urls.append(local_url)
except Exception as e: urls = await provider.generate(config, prompt, num_images, ref_image_url)
local_urls.append(f"[生成失败: {e}]") return GenerateResult(urls, model_name, resolved_id)
return local_urls

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -1,293 +0,0 @@
from __future__ import annotations
import os
from io import BytesIO
from typing import IO
from . import ExifTags, Image, ImageFile
try:
from . import _avif
SUPPORTED = True
except ImportError:
SUPPORTED = False
# Decoder options as module globals, until there is a way to pass parameters
# to Image.open (see https://github.com/python-pillow/Pillow/issues/569)
DECODE_CODEC_CHOICE = "auto"
DEFAULT_MAX_THREADS = 0
def get_codec_version(codec_name: str) -> str | None:
versions = _avif.codec_versions()
for version in versions.split(", "):
if version.split(" [")[0] == codec_name:
return version.split(":")[-1].split(" ")[0]
return None
def _accept(prefix: bytes) -> bool | str:
if prefix[4:8] != b"ftyp":
return False
major_brand = prefix[8:12]
if major_brand in (
# coding brands
b"avif",
b"avis",
# We accept files with AVIF container brands; we can't yet know if
# the ftyp box has the correct compatible brands, but if it doesn't
# then the plugin will raise a SyntaxError which Pillow will catch
# before moving on to the next plugin that accepts the file.
#
# Also, because this file might not actually be an AVIF file, we
# don't raise an error if AVIF support isn't properly compiled.
b"mif1",
b"msf1",
):
if not SUPPORTED:
return (
"image file could not be identified because AVIF support not installed"
)
return True
return False
def _get_default_max_threads() -> int:
if DEFAULT_MAX_THREADS:
return DEFAULT_MAX_THREADS
if hasattr(os, "sched_getaffinity"):
return len(os.sched_getaffinity(0))
else:
return os.cpu_count() or 1
class AvifImageFile(ImageFile.ImageFile):
format = "AVIF"
format_description = "AVIF image"
__frame = -1
def _open(self) -> None:
if not SUPPORTED:
msg = "image file could not be opened because AVIF support not installed"
raise SyntaxError(msg)
if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available(
DECODE_CODEC_CHOICE
):
msg = "Invalid opening codec"
raise ValueError(msg)
assert self.fp is not None
self._decoder = _avif.AvifDecoder(
self.fp.read(),
DECODE_CODEC_CHOICE,
_get_default_max_threads(),
)
# Get info from decoder
self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = (
self._decoder.get_info()
)
self.is_animated = self.n_frames > 1
if icc:
self.info["icc_profile"] = icc
if xmp:
self.info["xmp"] = xmp
if exif_orientation != 1 or exif:
exif_data = Image.Exif()
if exif:
exif_data.load(exif)
original_orientation = exif_data.get(ExifTags.Base.Orientation, 1)
else:
original_orientation = 1
if exif_orientation != original_orientation:
exif_data[ExifTags.Base.Orientation] = exif_orientation
exif = exif_data.tobytes()
if exif:
self.info["exif"] = exif
self.seek(0)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
# Set tile
self.__frame = frame
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
def load(self) -> Image.core.PixelAccess | None:
if self.tile:
# We need to load the image data for this frame
data, timescale, pts_in_timescales, duration_in_timescales = (
self._decoder.get_frame(self.__frame)
)
self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale))
self.info["duration"] = round(1000 * (duration_in_timescales / timescale))
if self.fp and self._exclusive_fp:
self.fp.close()
self.fp = BytesIO(data)
return super().load()
def load_seek(self, pos: int) -> None:
pass
def tell(self) -> int:
return self.__frame
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_save(im, fp, filename, save_all=True)
def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
) -> None:
info = im.encoderinfo.copy()
if save_all:
append_images = list(info.get("append_images", []))
else:
append_images = []
total = 0
for ims in [im] + append_images:
total += getattr(ims, "n_frames", 1)
quality = info.get("quality", 75)
if not isinstance(quality, int) or quality < 0 or quality > 100:
msg = "Invalid quality setting"
raise ValueError(msg)
duration = info.get("duration", 0)
subsampling = info.get("subsampling", "4:2:0")
speed = info.get("speed", 6)
max_threads = info.get("max_threads", _get_default_max_threads())
codec = info.get("codec", "auto")
if codec != "auto" and not _avif.encoder_codec_available(codec):
msg = "Invalid saving codec"
raise ValueError(msg)
range_ = info.get("range", "full")
tile_rows_log2 = info.get("tile_rows", 0)
tile_cols_log2 = info.get("tile_cols", 0)
alpha_premultiplied = bool(info.get("alpha_premultiplied", False))
autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0))
icc_profile = info.get("icc_profile", im.info.get("icc_profile"))
exif_orientation = 1
if exif := info.get("exif"):
if isinstance(exif, Image.Exif):
exif_data = exif
else:
exif_data = Image.Exif()
exif_data.load(exif)
if ExifTags.Base.Orientation in exif_data:
exif_orientation = exif_data.pop(ExifTags.Base.Orientation)
exif = exif_data.tobytes() if exif_data else b""
elif isinstance(exif, Image.Exif):
exif = exif_data.tobytes()
xmp = info.get("xmp")
if isinstance(xmp, str):
xmp = xmp.encode("utf-8")
advanced = info.get("advanced")
if advanced is not None:
if isinstance(advanced, dict):
advanced = advanced.items()
try:
advanced = tuple(advanced)
except TypeError:
invalid = True
else:
invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced)
if invalid:
msg = (
"advanced codec options must be a dict of key-value string "
"pairs or a series of key-value two-tuples"
)
raise ValueError(msg)
# Setup the AVIF encoder
enc = _avif.AvifEncoder(
im.size,
subsampling,
quality,
speed,
max_threads,
codec,
range_,
tile_rows_log2,
tile_cols_log2,
alpha_premultiplied,
autotiling,
icc_profile or b"",
exif or b"",
exif_orientation,
xmp or b"",
advanced,
)
# Add each frame
frame_idx = 0
frame_duration = 0
cur_idx = im.tell()
is_single_frame = total == 1
try:
for ims in [im] + append_images:
# Get number of frames in this image
nfr = getattr(ims, "n_frames", 1)
for idx in range(nfr):
ims.seek(idx)
# Make sure image mode is supported
frame = ims
rawmode = ims.mode
if ims.mode not in {"RGB", "RGBA"}:
rawmode = "RGBA" if ims.has_transparency_data else "RGB"
frame = ims.convert(rawmode)
# Update frame duration
if isinstance(duration, (list, tuple)):
frame_duration = duration[frame_idx]
else:
frame_duration = duration
# Append the frame to the animation encoder
enc.add(
frame.tobytes("raw", rawmode),
frame_duration,
frame.size,
rawmode,
is_single_frame,
)
# Update frame index
frame_idx += 1
if not save_all:
break
finally:
im.seek(cur_idx)
# Get the final output from the encoder
data = enc.finish()
if data is None:
msg = "cannot write file as AVIF (encoder returned None)"
raise OSError(msg)
fp.write(data)
Image.register_open(AvifImageFile.format, AvifImageFile, _accept)
if SUPPORTED:
Image.register_save(AvifImageFile.format, _save)
Image.register_save_all(AvifImageFile.format, _save_all)
Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"])
Image.register_mime(AvifImageFile.format, "image/avif")

View File

@@ -1,123 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# bitmap distribution font (bdf) file parser
#
# history:
# 1996-05-16 fl created (as bdf2pil)
# 1997-08-25 fl converted to FontFile driver
# 2001-05-25 fl removed bogus __init__ call
# 2002-11-20 fl robustification (from Kevin Cazabon, Dmitry Vasiliev)
# 2003-04-22 fl more robustification (from Graham Dumpleton)
#
# Copyright (c) 1997-2003 by Secret Labs AB.
# Copyright (c) 1997-2003 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
"""
Parse X Bitmap Distribution Format (BDF)
"""
from __future__ import annotations
from typing import BinaryIO
from . import FontFile, Image
def bdf_char(
f: BinaryIO,
) -> (
tuple[
str,
int,
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]],
Image.Image,
]
| None
):
# skip to STARTCHAR
while True:
s = f.readline()
if not s:
return None
if s.startswith(b"STARTCHAR"):
break
id = s[9:].strip().decode("ascii")
# load symbol properties
props = {}
while True:
s = f.readline()
if not s or s.startswith(b"BITMAP"):
break
i = s.find(b" ")
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
# load bitmap
bitmap = bytearray()
while True:
s = f.readline()
if not s or s.startswith(b"ENDCHAR"):
break
bitmap += s[:-1]
# The word BBX
# followed by the width in x (BBw), height in y (BBh),
# and x and y displacement (BBxoff0, BByoff0)
# of the lower left corner from the origin of the character.
width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split())
# The word DWIDTH
# followed by the width in x and y of the character in device pixels.
dwx, dwy = (int(p) for p in props["DWIDTH"].split())
bbox = (
(dwx, dwy),
(x_disp, -y_disp - height, width + x_disp, -y_disp),
(0, 0, width, height),
)
try:
im = Image.frombytes("1", (width, height), bitmap, "hex", "1")
except ValueError:
# deal with zero-width characters
im = Image.new("1", (width, height))
return id, int(props["ENCODING"]), bbox, im
class BdfFontFile(FontFile.FontFile):
"""Font file plugin for the X11 BDF format."""
def __init__(self, fp: BinaryIO) -> None:
super().__init__()
s = fp.readline()
if not s.startswith(b"STARTFONT 2.1"):
msg = "not a valid BDF file"
raise SyntaxError(msg)
props = {}
comments = []
while True:
s = fp.readline()
if not s or s.startswith(b"ENDPROPERTIES"):
break
i = s.find(b" ")
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
if s[:i] in [b"COMMENT", b"COPYRIGHT"]:
if s.find(b"LogicalFontDescription") < 0:
comments.append(s[i + 1 : -1].decode("ascii"))
while True:
c = bdf_char(fp)
if not c:
break
id, ch, (xy, dst, src), im = c
if 0 <= ch < len(self.glyph):
self.glyph[ch] = xy, dst, src, im

View File

@@ -1,498 +0,0 @@
"""
Blizzard Mipmap Format (.blp)
Jerome Leclanche <jerome@leclan.ch>
The contents of this file are hereby released in the public domain (CC0)
Full text of the CC0 license:
https://creativecommons.org/publicdomain/zero/1.0/
BLP1 files, used mostly in Warcraft III, are not fully supported.
All types of BLP2 files used in World of Warcraft are supported.
The BLP file structure consists of a header, up to 16 mipmaps of the
texture
Texture sizes must be powers of two, though the two dimensions do
not have to be equal; 512x256 is valid, but 512x200 is not.
The first mipmap (mipmap #0) is the full size image; each subsequent
mipmap halves both dimensions. The final mipmap should be 1x1.
BLP files come in many different flavours:
* JPEG-compressed (type == 0) - only supported for BLP1.
* RAW images (type == 1, encoding == 1). Each mipmap is stored as an
array of 8-bit values, one per pixel, left to right, top to bottom.
Each value is an index to the palette.
* DXT-compressed (type == 1, encoding == 2):
- DXT1 compression is used if alpha_encoding == 0.
- An additional alpha bit is used if alpha_depth == 1.
- DXT3 compression is used if alpha_encoding == 1.
- DXT5 compression is used if alpha_encoding == 7.
"""
from __future__ import annotations
import abc
import os
import struct
from enum import IntEnum
from io import BytesIO
from typing import IO
from . import Image, ImageFile
class Format(IntEnum):
JPEG = 0
class Encoding(IntEnum):
UNCOMPRESSED = 1
DXT = 2
UNCOMPRESSED_RAW_BGRA = 3
class AlphaEncoding(IntEnum):
DXT1 = 0
DXT3 = 1
DXT5 = 7
def unpack_565(i: int) -> tuple[int, int, int]:
return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3
def decode_dxt1(
data: bytes, alpha: bool = False
) -> tuple[bytearray, bytearray, bytearray, bytearray]:
"""
input: one "row" of data (i.e. will produce 4*width pixels)
"""
blocks = len(data) // 8 # number of blocks in row
ret = (bytearray(), bytearray(), bytearray(), bytearray())
for block_index in range(blocks):
# Decode next 8-byte block.
idx = block_index * 8
color0, color1, bits = struct.unpack_from("<HHI", data, idx)
r0, g0, b0 = unpack_565(color0)
r1, g1, b1 = unpack_565(color1)
# Decode this block into 4x4 pixels
# Accumulate the results onto our 4 row accumulators
for j in range(4):
for i in range(4):
# get next control op and generate a pixel
control = bits & 3
bits = bits >> 2
a = 0xFF
if control == 0:
r, g, b = r0, g0, b0
elif control == 1:
r, g, b = r1, g1, b1
elif control == 2:
if color0 > color1:
r = (2 * r0 + r1) // 3
g = (2 * g0 + g1) // 3
b = (2 * b0 + b1) // 3
else:
r = (r0 + r1) // 2
g = (g0 + g1) // 2
b = (b0 + b1) // 2
elif control == 3:
if color0 > color1:
r = (2 * r1 + r0) // 3
g = (2 * g1 + g0) // 3
b = (2 * b1 + b0) // 3
else:
r, g, b, a = 0, 0, 0, 0
if alpha:
ret[j].extend([r, g, b, a])
else:
ret[j].extend([r, g, b])
return ret
def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
"""
input: one "row" of data (i.e. will produce 4*width pixels)
"""
blocks = len(data) // 16 # number of blocks in row
ret = (bytearray(), bytearray(), bytearray(), bytearray())
for block_index in range(blocks):
idx = block_index * 16
block = data[idx : idx + 16]
# Decode next 16-byte block.
bits = struct.unpack_from("<8B", block)
color0, color1 = struct.unpack_from("<HH", block, 8)
(code,) = struct.unpack_from("<I", block, 12)
r0, g0, b0 = unpack_565(color0)
r1, g1, b1 = unpack_565(color1)
for j in range(4):
high = False # Do we want the higher bits?
for i in range(4):
alphacode_index = (4 * j + i) // 2
a = bits[alphacode_index]
if high:
high = False
a >>= 4
else:
high = True
a &= 0xF
a *= 17 # We get a value between 0 and 15
color_code = (code >> 2 * (4 * j + i)) & 0x03
if color_code == 0:
r, g, b = r0, g0, b0
elif color_code == 1:
r, g, b = r1, g1, b1
elif color_code == 2:
r = (2 * r0 + r1) // 3
g = (2 * g0 + g1) // 3
b = (2 * b0 + b1) // 3
elif color_code == 3:
r = (2 * r1 + r0) // 3
g = (2 * g1 + g0) // 3
b = (2 * b1 + b0) // 3
ret[j].extend([r, g, b, a])
return ret
def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
"""
input: one "row" of data (i.e. will produce 4 * width pixels)
"""
blocks = len(data) // 16 # number of blocks in row
ret = (bytearray(), bytearray(), bytearray(), bytearray())
for block_index in range(blocks):
idx = block_index * 16
block = data[idx : idx + 16]
# Decode next 16-byte block.
a0, a1 = struct.unpack_from("<BB", block)
bits = struct.unpack_from("<6B", block, 2)
alphacode1 = bits[2] | (bits[3] << 8) | (bits[4] << 16) | (bits[5] << 24)
alphacode2 = bits[0] | (bits[1] << 8)
color0, color1 = struct.unpack_from("<HH", block, 8)
(code,) = struct.unpack_from("<I", block, 12)
r0, g0, b0 = unpack_565(color0)
r1, g1, b1 = unpack_565(color1)
for j in range(4):
for i in range(4):
# get next control op and generate a pixel
alphacode_index = 3 * (4 * j + i)
if alphacode_index <= 12:
alphacode = (alphacode2 >> alphacode_index) & 0x07
elif alphacode_index == 15:
alphacode = (alphacode2 >> 15) | ((alphacode1 << 1) & 0x06)
else: # alphacode_index >= 18 and alphacode_index <= 45
alphacode = (alphacode1 >> (alphacode_index - 16)) & 0x07
if alphacode == 0:
a = a0
elif alphacode == 1:
a = a1
elif a0 > a1:
a = ((8 - alphacode) * a0 + (alphacode - 1) * a1) // 7
elif alphacode == 6:
a = 0
elif alphacode == 7:
a = 255
else:
a = ((6 - alphacode) * a0 + (alphacode - 1) * a1) // 5
color_code = (code >> 2 * (4 * j + i)) & 0x03
if color_code == 0:
r, g, b = r0, g0, b0
elif color_code == 1:
r, g, b = r1, g1, b1
elif color_code == 2:
r = (2 * r0 + r1) // 3
g = (2 * g0 + g1) // 3
b = (2 * b0 + b1) // 3
elif color_code == 3:
r = (2 * r1 + r0) // 3
g = (2 * g1 + g0) // 3
b = (2 * b1 + b0) // 3
ret[j].extend([r, g, b, a])
return ret
class BLPFormatError(NotImplementedError):
pass
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"BLP1", b"BLP2"))
class BlpImageFile(ImageFile.ImageFile):
"""
Blizzard Mipmap Format
"""
format = "BLP"
format_description = "Blizzard Mipmap Format"
def _open(self) -> None:
assert self.fp is not None
self.magic = self.fp.read(4)
if not _accept(self.magic):
msg = f"Bad BLP magic {repr(self.magic)}"
raise BLPFormatError(msg)
compression = struct.unpack("<i", self.fp.read(4))[0]
if self.magic == b"BLP1":
alpha = struct.unpack("<I", self.fp.read(4))[0] != 0
else:
encoding = struct.unpack("<b", self.fp.read(1))[0]
alpha = struct.unpack("<b", self.fp.read(1))[0] != 0
alpha_encoding = struct.unpack("<b", self.fp.read(1))[0]
self.fp.seek(1, os.SEEK_CUR) # mips
self._size = struct.unpack("<II", self.fp.read(8))
args: tuple[int, int, bool] | tuple[int, int, bool, int]
if self.magic == b"BLP1":
encoding = struct.unpack("<i", self.fp.read(4))[0]
self.fp.seek(4, os.SEEK_CUR) # subtype
args = (compression, encoding, alpha)
offset = 28
else:
args = (compression, encoding, alpha, alpha_encoding)
offset = 20
decoder = self.magic.decode()
self._mode = "RGBA" if alpha else "RGB"
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
try:
self._read_header()
self._load()
except struct.error as e:
msg = "Truncated BLP file"
raise OSError(msg) from e
return -1, 0
@abc.abstractmethod
def _load(self) -> None:
pass
def _read_header(self) -> None:
self._offsets = struct.unpack("<16I", self._safe_read(16 * 4))
self._lengths = struct.unpack("<16I", self._safe_read(16 * 4))
def _safe_read(self, length: int) -> bytes:
assert self.fd is not None
return ImageFile._safe_read(self.fd, length)
def _read_palette(self) -> list[tuple[int, int, int, int]]:
ret = []
for i in range(256):
try:
b, g, r, a = struct.unpack("<4B", self._safe_read(4))
except struct.error:
break
ret.append((b, g, r, a))
return ret
def _read_bgra(
self, palette: list[tuple[int, int, int, int]], alpha: bool
) -> bytearray:
data = bytearray()
_data = BytesIO(self._safe_read(self._lengths[0]))
while True:
try:
(offset,) = struct.unpack("<B", _data.read(1))
except struct.error:
break
b, g, r, a = palette[offset]
d: tuple[int, ...] = (r, g, b)
if alpha:
d += (a,)
data.extend(d)
return data
class BLP1Decoder(_BLPBaseDecoder):
def _load(self) -> None:
self._compression, self._encoding, alpha = self.args
if self._compression == Format.JPEG:
self._decode_jpeg_stream()
elif self._compression == 1:
if self._encoding in (4, 5):
palette = self._read_palette()
data = self._read_bgra(palette, alpha)
self.set_as_raw(data)
else:
msg = f"Unsupported BLP encoding {repr(self._encoding)}"
raise BLPFormatError(msg)
else:
msg = f"Unsupported BLP compression {repr(self._encoding)}"
raise BLPFormatError(msg)
def _decode_jpeg_stream(self) -> None:
from .JpegImagePlugin import JpegImageFile
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
jpeg_header = self._safe_read(jpeg_header_size)
assert self.fd is not None
self._safe_read(self._offsets[0] - self.fd.tell()) # What IS this?
data = self._safe_read(self._lengths[0])
data = jpeg_header + data
image = JpegImageFile(BytesIO(data))
Image._decompression_bomb_check(image.size)
if image.mode == "CMYK":
args = image.tile[0].args
assert isinstance(args, tuple)
image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))]
self.set_as_raw(image.convert("RGB").tobytes(), "BGR")
class BLP2Decoder(_BLPBaseDecoder):
def _load(self) -> None:
self._compression, self._encoding, alpha, self._alpha_encoding = self.args
palette = self._read_palette()
assert self.fd is not None
self.fd.seek(self._offsets[0])
if self._compression == 1:
# Uncompressed or DirectX compression
if self._encoding == Encoding.UNCOMPRESSED:
data = self._read_bgra(palette, alpha)
elif self._encoding == Encoding.DXT:
data = bytearray()
if self._alpha_encoding == AlphaEncoding.DXT1:
linesize = (self.state.xsize + 3) // 4 * 8
for yb in range((self.state.ysize + 3) // 4):
for d in decode_dxt1(self._safe_read(linesize), alpha):
data += d
elif self._alpha_encoding == AlphaEncoding.DXT3:
linesize = (self.state.xsize + 3) // 4 * 16
for yb in range((self.state.ysize + 3) // 4):
for d in decode_dxt3(self._safe_read(linesize)):
data += d
elif self._alpha_encoding == AlphaEncoding.DXT5:
linesize = (self.state.xsize + 3) // 4 * 16
for yb in range((self.state.ysize + 3) // 4):
for d in decode_dxt5(self._safe_read(linesize)):
data += d
else:
msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}"
raise BLPFormatError(msg)
else:
msg = f"Unknown BLP encoding {repr(self._encoding)}"
raise BLPFormatError(msg)
else:
msg = f"Unknown BLP compression {repr(self._compression)}"
raise BLPFormatError(msg)
self.set_as_raw(data)
class BLPEncoder(ImageFile.PyEncoder):
_pushes_fd = True
def _write_palette(self) -> bytes:
data = b""
assert self.im is not None
palette = self.im.getpalette("RGBA", "RGBA")
for i in range(len(palette) // 4):
r, g, b, a = palette[i * 4 : (i + 1) * 4]
data += struct.pack("<4B", b, g, r, a)
while len(data) < 256 * 4:
data += b"\x00" * 4
return data
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
palette_data = self._write_palette()
offset = 20 + 16 * 4 * 2 + len(palette_data)
data = struct.pack("<16I", offset, *((0,) * 15))
assert self.im is not None
w, h = self.im.size
data += struct.pack("<16I", w * h, *((0,) * 15))
data += palette_data
for y in range(h):
for x in range(w):
data += struct.pack("<B", self.im.getpixel((x, y)))
return len(data), 0, data
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode != "P":
msg = "Unsupported BLP image mode"
raise ValueError(msg)
magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2"
fp.write(magic)
assert im.palette is not None
fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression
alpha_depth = 1 if im.palette.mode == "RGBA" else 0
if magic == b"BLP1":
fp.write(struct.pack("<L", alpha_depth))
else:
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
fp.write(struct.pack("<b", alpha_depth))
fp.write(struct.pack("<b", 0)) # alpha encoding
fp.write(struct.pack("<b", 0)) # mips
fp.write(struct.pack("<II", *im.size))
if magic == b"BLP1":
fp.write(struct.pack("<i", 5))
fp.write(struct.pack("<i", 0))
ImageFile._save(im, fp, [ImageFile._Tile("BLP", (0, 0) + im.size, 0, im.mode)])
Image.register_open(BlpImageFile.format, BlpImageFile, _accept)
Image.register_extension(BlpImageFile.format, ".blp")
Image.register_decoder("BLP1", BLP1Decoder)
Image.register_decoder("BLP2", BLP2Decoder)
Image.register_save(BlpImageFile.format, _save)
Image.register_encoder("BLP", BLPEncoder)

View File

@@ -1,514 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# BMP file handler
#
# Windows (and OS/2) native bitmap storage format.
#
# history:
# 1995-09-01 fl Created
# 1996-04-30 fl Added save
# 1997-08-27 fl Fixed save of 1-bit images
# 1998-03-06 fl Load P images as L where possible
# 1998-07-03 fl Load P images as 1 where possible
# 1998-12-29 fl Handle small palettes
# 2002-12-30 fl Fixed load of 1-bit palette images
# 2003-04-21 fl Fixed load of 1-bit monochrome images
# 2003-04-23 fl Added limited support for BI_BITFIELDS compression
#
# Copyright (c) 1997-2003 by Secret Labs AB
# Copyright (c) 1995-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import IO, Any
from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16
from ._binary import i32le as i32
from ._binary import o8
from ._binary import o16le as o16
from ._binary import o32le as o32
#
# --------------------------------------------------------------------
# Read BMP file
BIT2MODE = {
# bits => mode, rawmode
1: ("P", "P;1"),
4: ("P", "P;4"),
8: ("P", "P"),
16: ("RGB", "BGR;15"),
24: ("RGB", "BGR"),
32: ("RGB", "BGRX"),
}
USE_RAW_ALPHA = False
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"BM")
def _dib_accept(prefix: bytes) -> bool:
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
# =============================================================================
# Image plugin for the Windows BMP format.
# =============================================================================
class BmpImageFile(ImageFile.ImageFile):
"""Image plugin for the Windows Bitmap format (BMP)"""
# ------------------------------------------------------------- Description
format_description = "Windows Bitmap"
format = "BMP"
# -------------------------------------------------- BMP Compression values
COMPRESSIONS = {"RAW": 0, "RLE8": 1, "RLE4": 2, "BITFIELDS": 3, "JPEG": 4, "PNG": 5}
for k, v in COMPRESSIONS.items():
vars()[k] = v
def _bitmap(self, header: int = 0, offset: int = 0) -> None:
"""Read relevant info about the BMP"""
assert self.fp is not None
read, seek = self.fp.read, self.fp.seek
if header:
seek(header)
# read bmp header size @offset 14 (this is part of the header size)
file_info: dict[str, bool | int | tuple[int, ...]] = {
"header_size": i32(read(4)),
"direction": -1,
}
# -------------------- If requested, read header at a specific position
# read the rest of the bmp header, without its size
assert isinstance(file_info["header_size"], int)
header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4)
# ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1
# ----- This format has different offsets because of width/height types
# 12: BITMAPCOREHEADER/OS21XBITMAPHEADER
if file_info["header_size"] == 12:
file_info["width"] = i16(header_data, 0)
file_info["height"] = i16(header_data, 2)
file_info["planes"] = i16(header_data, 4)
file_info["bits"] = i16(header_data, 6)
file_info["compression"] = self.COMPRESSIONS["RAW"]
file_info["palette_padding"] = 3
# --------------------------------------------- Windows Bitmap v3 to v5
# 40: BITMAPINFOHEADER
# 52: BITMAPV2HEADER
# 56: BITMAPV3HEADER
# 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER
# 108: BITMAPV4HEADER
# 124: BITMAPV5HEADER
elif file_info["header_size"] in (40, 52, 56, 64, 108, 124):
file_info["y_flip"] = header_data[7] == 0xFF
file_info["direction"] = 1 if file_info["y_flip"] else -1
file_info["width"] = i32(header_data, 0)
file_info["height"] = (
i32(header_data, 4)
if not file_info["y_flip"]
else 2**32 - i32(header_data, 4)
)
file_info["planes"] = i16(header_data, 8)
file_info["bits"] = i16(header_data, 10)
file_info["compression"] = i32(header_data, 12)
# byte size of pixel data
file_info["data_size"] = i32(header_data, 16)
file_info["pixels_per_meter"] = (
i32(header_data, 20),
i32(header_data, 24),
)
file_info["colors"] = i32(header_data, 28)
file_info["palette_padding"] = 4
assert isinstance(file_info["pixels_per_meter"], tuple)
self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"])
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
masks = ["r_mask", "g_mask", "b_mask"]
if len(header_data) >= 48:
if len(header_data) >= 52:
masks.append("a_mask")
else:
file_info["a_mask"] = 0x0
for idx, mask in enumerate(masks):
file_info[mask] = i32(header_data, 36 + idx * 4)
else:
# 40 byte headers only have the three components in the
# bitfields masks, ref:
# https://msdn.microsoft.com/en-us/library/windows/desktop/dd183376(v=vs.85).aspx
# See also
# https://github.com/python-pillow/Pillow/issues/1293
# There is a 4th component in the RGBQuad, in the alpha
# location, but it is listed as a reserved component,
# and it is not generally an alpha channel
file_info["a_mask"] = 0x0
for mask in masks:
file_info[mask] = i32(read(4))
assert isinstance(file_info["r_mask"], int)
assert isinstance(file_info["g_mask"], int)
assert isinstance(file_info["b_mask"], int)
assert isinstance(file_info["a_mask"], int)
file_info["rgb_mask"] = (
file_info["r_mask"],
file_info["g_mask"],
file_info["b_mask"],
)
file_info["rgba_mask"] = (
file_info["r_mask"],
file_info["g_mask"],
file_info["b_mask"],
file_info["a_mask"],
)
else:
msg = f"Unsupported BMP header type ({file_info['header_size']})"
raise OSError(msg)
# ------------------ Special case : header is reported 40, which
# ---------------------- is shorter than real size for bpp >= 16
assert isinstance(file_info["width"], int)
assert isinstance(file_info["height"], int)
self._size = file_info["width"], file_info["height"]
# ------- If color count was not found in the header, compute from bits
assert isinstance(file_info["bits"], int)
if not file_info.get("colors", 0):
file_info["colors"] = 1 << file_info["bits"]
assert isinstance(file_info["palette_padding"], int)
assert isinstance(file_info["colors"], int)
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
offset += file_info["palette_padding"] * file_info["colors"]
# ---------------------- Check bit depth for unusual unsupported values
self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", ""))
if not self.mode:
msg = f"Unsupported BMP pixel depth ({file_info['bits']})"
raise OSError(msg)
# ---------------- Process BMP with Bitfields compression (not palette)
decoder_name = "raw"
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
SUPPORTED: dict[int, list[tuple[int, ...]]] = {
32: [
(0xFF0000, 0xFF00, 0xFF, 0x0),
(0xFF000000, 0xFF0000, 0xFF00, 0x0),
(0xFF000000, 0xFF00, 0xFF, 0x0),
(0xFF000000, 0xFF0000, 0xFF00, 0xFF),
(0xFF, 0xFF00, 0xFF0000, 0xFF000000),
(0xFF0000, 0xFF00, 0xFF, 0xFF000000),
(0xFF000000, 0xFF00, 0xFF, 0xFF0000),
(0x0, 0x0, 0x0, 0x0),
],
24: [(0xFF0000, 0xFF00, 0xFF)],
16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)],
}
MASK_MODES = {
(32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX",
(32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR",
(32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR",
(32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR",
(32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA",
(32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA",
(32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR",
(32, (0x0, 0x0, 0x0, 0x0)): "BGRA",
(24, (0xFF0000, 0xFF00, 0xFF)): "BGR",
(16, (0xF800, 0x7E0, 0x1F)): "BGR;16",
(16, (0x7C00, 0x3E0, 0x1F)): "BGR;15",
}
if file_info["bits"] in SUPPORTED:
if (
file_info["bits"] == 32
and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]]
):
assert isinstance(file_info["rgba_mask"], tuple)
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])]
self._mode = "RGBA" if "A" in raw_mode else self.mode
elif (
file_info["bits"] in (24, 16)
and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]]
):
assert isinstance(file_info["rgb_mask"], tuple)
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])]
else:
msg = "Unsupported BMP bitfields layout"
raise OSError(msg)
else:
msg = "Unsupported BMP bitfields layout"
raise OSError(msg)
elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
if file_info["bits"] == 32 and (
header == 22 or USE_RAW_ALPHA # 32-bit .cur offset
):
raw_mode, self._mode = "BGRA", "RGBA"
elif file_info["compression"] in (
self.COMPRESSIONS["RLE8"],
self.COMPRESSIONS["RLE4"],
):
decoder_name = "bmp_rle"
else:
msg = f"Unsupported BMP compression ({file_info['compression']})"
raise OSError(msg)
# --------------- Once the header is processed, process the palette/LUT
if self.mode == "P": # Paletted for 1, 4 and 8 bit images
# ---------------------------------------------------- 1-bit images
if not (0 < file_info["colors"] <= 65536):
msg = f"Unsupported BMP Palette size ({file_info['colors']})"
raise OSError(msg)
else:
padding = file_info["palette_padding"]
palette = read(padding * file_info["colors"])
grayscale = True
indices = (
(0, 255)
if file_info["colors"] == 2
else list(range(file_info["colors"]))
)
# ----------------- Check if grayscale and ignore palette if so
for ind, val in enumerate(indices):
rgb = palette[ind * padding : ind * padding + 3]
if rgb != o8(val) * 3:
grayscale = False
# ------- If all colors are gray, white or black, ditch palette
if grayscale:
self._mode = "1" if file_info["colors"] == 2 else "L"
raw_mode = self.mode
else:
self._mode = "P"
self.palette = ImagePalette.raw(
"BGRX" if padding == 4 else "BGR", palette
)
# ---------------------------- Finally set the tile data for the plugin
self.info["compression"] = file_info["compression"]
args: list[Any] = [raw_mode]
if decoder_name == "bmp_rle":
args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"])
else:
assert isinstance(file_info["width"], int)
args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3))
args.append(file_info["direction"])
self.tile = [
ImageFile._Tile(
decoder_name,
(0, 0, file_info["width"], file_info["height"]),
offset or self.fp.tell(),
tuple(args),
)
]
def _open(self) -> None:
"""Open file, check magic number and read header"""
# read 14 bytes: magic number, filesize, reserved, header final offset
assert self.fp is not None
head_data = self.fp.read(14)
# choke if the file does not have the required magic bytes
if not _accept(head_data):
msg = "Not a BMP file"
raise SyntaxError(msg)
# read the start position of the BMP image data (u32)
offset = i32(head_data, 10)
# load bitmap information (offset=raster info)
self._bitmap(offset=offset)
class BmpRleDecoder(ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
rle4 = self.args[1]
data = bytearray()
x = 0
dest_length = self.state.xsize * self.state.ysize
while len(data) < dest_length:
pixels = self.fd.read(1)
byte = self.fd.read(1)
if not pixels or not byte:
break
num_pixels = pixels[0]
if num_pixels:
# encoded mode
if x + num_pixels > self.state.xsize:
# Too much data for row
num_pixels = max(0, self.state.xsize - x)
if rle4:
first_pixel = o8(byte[0] >> 4)
second_pixel = o8(byte[0] & 0x0F)
for index in range(num_pixels):
if index % 2 == 0:
data += first_pixel
else:
data += second_pixel
else:
data += byte * num_pixels
x += num_pixels
else:
if byte[0] == 0:
# end of line
while len(data) % self.state.xsize != 0:
data += b"\x00"
x = 0
elif byte[0] == 1:
# end of bitmap
break
elif byte[0] == 2:
# delta
bytes_read = self.fd.read(2)
if len(bytes_read) < 2:
break
right, up = bytes_read
data += b"\x00" * (right + up * self.state.xsize)
x = len(data) % self.state.xsize
else:
# absolute mode
if rle4:
# 2 pixels per byte
byte_count = byte[0] // 2
bytes_read = self.fd.read(byte_count)
for byte_read in bytes_read:
data += o8(byte_read >> 4)
data += o8(byte_read & 0x0F)
else:
byte_count = byte[0]
bytes_read = self.fd.read(byte_count)
data += bytes_read
if len(bytes_read) < byte_count:
break
x += byte[0]
# align to 16-bit word boundary
if self.fd.tell() % 2 != 0:
self.fd.seek(1, os.SEEK_CUR)
rawmode = "L" if self.mode == "L" else "P"
self.set_as_raw(bytes(data), rawmode, (0, self.args[-1]))
return -1, 0
# =============================================================================
# Image plugin for the DIB format (BMP alias)
# =============================================================================
class DibImageFile(BmpImageFile):
format = "DIB"
format_description = "Windows Bitmap"
def _open(self) -> None:
self._bitmap()
#
# --------------------------------------------------------------------
# Write BMP file
SAVE = {
"1": ("1", 1, 2),
"L": ("L", 8, 256),
"P": ("P", 8, 256),
"RGB": ("BGR", 24, 0),
"RGBA": ("BGRA", 32, 0),
}
def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_save(im, fp, filename, False)
def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True
) -> None:
try:
rawmode, bits, colors = SAVE[im.mode]
except KeyError as e:
msg = f"cannot write mode {im.mode} as BMP"
raise OSError(msg) from e
info = im.encoderinfo
dpi = info.get("dpi", (96, 96))
# 1 meter == 39.3701 inches
ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi)
stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3)
header = 40 # or 64 for OS/2 version 2
image = stride * im.size[1]
if im.mode == "1":
palette = b"".join(o8(i) * 3 + b"\x00" for i in (0, 255))
elif im.mode == "L":
palette = b"".join(o8(i) * 3 + b"\x00" for i in range(256))
elif im.mode == "P":
palette = im.im.getpalette("RGB", "BGRX")
colors = len(palette) // 4
else:
palette = None
# bitmap header
if bitmap_header:
offset = 14 + header + colors * 4
file_size = offset + image
if file_size > 2**32 - 1:
msg = "File size is too large for the BMP format"
raise ValueError(msg)
fp.write(
b"BM" # file type (magic)
+ o32(file_size) # file size
+ o32(0) # reserved
+ o32(offset) # image data offset
)
# bitmap info header
fp.write(
o32(header) # info header size
+ o32(im.size[0]) # width
+ o32(im.size[1]) # height
+ o16(1) # planes
+ o16(bits) # depth
+ o32(0) # compression (0=uncompressed)
+ o32(image) # size of bitmap
+ o32(ppm[0]) # resolution
+ o32(ppm[1]) # resolution
+ o32(colors) # colors used
+ o32(colors) # colors important
)
fp.write(b"\0" * (header - 40)) # padding (for OS/2 format)
if palette:
fp.write(palette)
ImageFile._save(
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]
)
#
# --------------------------------------------------------------------
# Registry
Image.register_open(BmpImageFile.format, BmpImageFile, _accept)
Image.register_save(BmpImageFile.format, _save)
Image.register_extension(BmpImageFile.format, ".bmp")
Image.register_mime(BmpImageFile.format, "image/bmp")
Image.register_decoder("bmp_rle", BmpRleDecoder)
Image.register_open(DibImageFile.format, DibImageFile, _dib_accept)
Image.register_save(DibImageFile.format, _dib_save)
Image.register_extension(DibImageFile.format, ".dib")
Image.register_mime(DibImageFile.format, "image/bmp")

View File

@@ -1,72 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# BUFR stub adapter
#
# Copyright (c) 1996-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import IO
from . import Image, ImageFile
_handler = None
def register_handler(handler: ImageFile.StubHandler | None) -> None:
"""
Install application-specific BUFR image handler.
:param handler: Handler object.
"""
global _handler
_handler = handler
# --------------------------------------------------------------------
# Image adapter
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"BUFR", b"ZCZC"))
class BufrStubImageFile(ImageFile.StubImageFile):
format = "BUFR"
format_description = "BUFR"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "Not a BUFR file"
raise SyntaxError(msg)
self.fp.seek(-4, os.SEEK_CUR)
# make something up
self._mode = "F"
self._size = 1, 1
def _load(self) -> ImageFile.StubHandler | None:
return _handler
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if _handler is None or not hasattr(_handler, "save"):
msg = "BUFR save handler not installed"
raise OSError(msg)
_handler.save(im, fp, filename)
# --------------------------------------------------------------------
# Registry
Image.register_open(BufrStubImageFile.format, BufrStubImageFile, _accept)
Image.register_save(BufrStubImageFile.format, _save)
Image.register_extension(BufrStubImageFile.format, ".bufr")

View File

@@ -1,173 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# a class to read from a container file
#
# History:
# 1995-06-18 fl Created
# 1995-09-07 fl Added readline(), readlines()
#
# Copyright (c) 1997-2001 by Secret Labs AB
# Copyright (c) 1995 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
from collections.abc import Iterable
from typing import IO, AnyStr, NoReturn
class ContainerIO(IO[AnyStr]):
"""
A file object that provides read access to a part of an existing
file (for example a TAR file).
"""
def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None:
"""
Create file object.
:param file: Existing file.
:param offset: Start of region, in bytes.
:param length: Size of region, in bytes.
"""
self.fh: IO[AnyStr] = file
self.pos = 0
self.offset = offset
self.length = length
self.fh.seek(offset)
##
# Always false.
def isatty(self) -> bool:
return False
def seekable(self) -> bool:
return True
def seek(self, offset: int, mode: int = io.SEEK_SET) -> int:
"""
Move file pointer.
:param offset: Offset in bytes.
:param mode: Starting position. Use 0 for beginning of region, 1
for current offset, and 2 for end of region. You cannot move
the pointer outside the defined region.
:returns: Offset from start of region, in bytes.
"""
if mode == 1:
self.pos = self.pos + offset
elif mode == 2:
self.pos = self.length + offset
else:
self.pos = offset
# clamp
self.pos = max(0, min(self.pos, self.length))
self.fh.seek(self.offset + self.pos)
return self.pos
def tell(self) -> int:
"""
Get current file pointer.
:returns: Offset from start of region, in bytes.
"""
return self.pos
def readable(self) -> bool:
return True
def read(self, n: int = -1) -> AnyStr:
"""
Read data.
:param n: Number of bytes to read. If omitted, zero or negative,
read until end of region.
:returns: An 8-bit string.
"""
if n > 0:
n = min(n, self.length - self.pos)
else:
n = self.length - self.pos
if n <= 0: # EOF
return b"" if "b" in self.fh.mode else "" # type: ignore[return-value]
self.pos = self.pos + n
return self.fh.read(n)
def readline(self, n: int = -1) -> AnyStr:
"""
Read a line of text.
:param n: Number of bytes to read. If omitted, zero or negative,
read until end of line.
:returns: An 8-bit string.
"""
s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment]
newline_character = b"\n" if "b" in self.fh.mode else "\n"
while True:
c = self.read(1)
if not c:
break
s = s + c
if c == newline_character or len(s) == n:
break
return s
def readlines(self, n: int | None = -1) -> list[AnyStr]:
"""
Read multiple lines of text.
:param n: Number of lines to read. If omitted, zero, negative or None,
read until end of region.
:returns: A list of 8-bit strings.
"""
lines = []
while True:
s = self.readline()
if not s:
break
lines.append(s)
if len(lines) == n:
break
return lines
def writable(self) -> bool:
return False
def write(self, b: AnyStr) -> NoReturn:
raise NotImplementedError()
def writelines(self, lines: Iterable[AnyStr]) -> NoReturn:
raise NotImplementedError()
def truncate(self, size: int | None = None) -> int:
raise NotImplementedError()
def __enter__(self) -> ContainerIO[AnyStr]:
return self
def __exit__(self, *args: object) -> None:
self.close()
def __iter__(self) -> ContainerIO[AnyStr]:
return self
def __next__(self) -> AnyStr:
line = self.readline()
if not line:
msg = "end of region"
raise StopIteration(msg)
return line
def fileno(self) -> int:
return self.fh.fileno()
def flush(self) -> None:
self.fh.flush()
def close(self) -> None:
self.fh.close()

View File

@@ -1,75 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# Windows Cursor support for PIL
#
# notes:
# uses BmpImagePlugin.py to read the bitmap data.
#
# history:
# 96-05-27 fl Created
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1996.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import BmpImagePlugin, Image
from ._binary import i16le as i16
from ._binary import i32le as i32
#
# --------------------------------------------------------------------
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\0\0\2\0")
##
# Image plugin for Windows Cursor files.
class CurImageFile(BmpImagePlugin.BmpImageFile):
format = "CUR"
format_description = "Windows Cursor"
def _open(self) -> None:
assert self.fp is not None
offset = self.fp.tell()
# check magic
s = self.fp.read(6)
if not _accept(s):
msg = "not a CUR file"
raise SyntaxError(msg)
# pick the largest cursor in the file
m = b""
for i in range(i16(s, 4)):
s = self.fp.read(16)
if not m:
m = s
elif s[0] > m[0] and s[1] > m[1]:
m = s
if not m:
msg = "No cursors were found"
raise TypeError(msg)
# load as bitmap
self._bitmap(i32(m, 12) + offset)
# patch up the bitmap height
self._size = self.size[0], self.size[1] // 2
self.tile = [self.tile[0]._replace(extents=(0, 0) + self.size)]
#
# --------------------------------------------------------------------
Image.register_open(CurImageFile.format, CurImageFile, _accept)
Image.register_extension(CurImageFile.format, ".cur")

View File

@@ -1,84 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# DCX file handling
#
# DCX is a container file format defined by Intel, commonly used
# for fax applications. Each DCX file consists of a directory
# (a list of file offsets) followed by a set of (usually 1-bit)
# PCX files.
#
# History:
# 1995-09-09 fl Created
# 1996-03-20 fl Properly derived from PcxImageFile.
# 1998-07-15 fl Renamed offset attribute to avoid name clash
# 2002-07-30 fl Fixed file handling
#
# Copyright (c) 1997-98 by Secret Labs AB.
# Copyright (c) 1995-96 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image
from ._binary import i32le as i32
from ._util import DeferredError
from .PcxImagePlugin import PcxImageFile
MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then?
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 4 and i32(prefix) == MAGIC
##
# Image plugin for the Intel DCX format.
class DcxImageFile(PcxImageFile):
format = "DCX"
format_description = "Intel DCX"
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
# Header
assert self.fp is not None
s = self.fp.read(4)
if not _accept(s):
msg = "not a DCX file"
raise SyntaxError(msg)
# Component directory
self._offset = []
for i in range(1024):
offset = i32(self.fp.read(4))
if not offset:
break
self._offset.append(offset)
self._fp = self.fp
self.frame = -1
self.n_frames = len(self._offset)
self.is_animated = self.n_frames > 1
self.seek(0)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self.frame = frame
self.fp = self._fp
self.fp.seek(self._offset[frame])
PcxImageFile._open(self)
def tell(self) -> int:
return self.frame
Image.register_open(DcxImageFile.format, DcxImageFile, _accept)
Image.register_extension(DcxImageFile.format, ".dcx")

View File

@@ -1,625 +0,0 @@
"""
A Pillow plugin for .dds files (S3TC-compressed aka DXTC)
Jerome Leclanche <jerome@leclan.ch>
Documentation:
https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt
The contents of this file are hereby released in the public domain (CC0)
Full text of the CC0 license:
https://creativecommons.org/publicdomain/zero/1.0/
"""
from __future__ import annotations
import struct
import sys
from enum import IntEnum, IntFlag
from typing import IO
from . import Image, ImageFile, ImagePalette
from ._binary import i32le as i32
from ._binary import o8
from ._binary import o32le as o32
# Magic ("DDS ")
DDS_MAGIC = 0x20534444
# DDS flags
class DDSD(IntFlag):
CAPS = 0x1
HEIGHT = 0x2
WIDTH = 0x4
PITCH = 0x8
PIXELFORMAT = 0x1000
MIPMAPCOUNT = 0x20000
LINEARSIZE = 0x80000
DEPTH = 0x800000
# DDS caps
class DDSCAPS(IntFlag):
COMPLEX = 0x8
TEXTURE = 0x1000
MIPMAP = 0x400000
class DDSCAPS2(IntFlag):
CUBEMAP = 0x200
CUBEMAP_POSITIVEX = 0x400
CUBEMAP_NEGATIVEX = 0x800
CUBEMAP_POSITIVEY = 0x1000
CUBEMAP_NEGATIVEY = 0x2000
CUBEMAP_POSITIVEZ = 0x4000
CUBEMAP_NEGATIVEZ = 0x8000
VOLUME = 0x200000
# Pixel Format
class DDPF(IntFlag):
ALPHAPIXELS = 0x1
ALPHA = 0x2
FOURCC = 0x4
PALETTEINDEXED8 = 0x20
RGB = 0x40
LUMINANCE = 0x20000
# dxgiformat.h
class DXGI_FORMAT(IntEnum):
UNKNOWN = 0
R32G32B32A32_TYPELESS = 1
R32G32B32A32_FLOAT = 2
R32G32B32A32_UINT = 3
R32G32B32A32_SINT = 4
R32G32B32_TYPELESS = 5
R32G32B32_FLOAT = 6
R32G32B32_UINT = 7
R32G32B32_SINT = 8
R16G16B16A16_TYPELESS = 9
R16G16B16A16_FLOAT = 10
R16G16B16A16_UNORM = 11
R16G16B16A16_UINT = 12
R16G16B16A16_SNORM = 13
R16G16B16A16_SINT = 14
R32G32_TYPELESS = 15
R32G32_FLOAT = 16
R32G32_UINT = 17
R32G32_SINT = 18
R32G8X24_TYPELESS = 19
D32_FLOAT_S8X24_UINT = 20
R32_FLOAT_X8X24_TYPELESS = 21
X32_TYPELESS_G8X24_UINT = 22
R10G10B10A2_TYPELESS = 23
R10G10B10A2_UNORM = 24
R10G10B10A2_UINT = 25
R11G11B10_FLOAT = 26
R8G8B8A8_TYPELESS = 27
R8G8B8A8_UNORM = 28
R8G8B8A8_UNORM_SRGB = 29
R8G8B8A8_UINT = 30
R8G8B8A8_SNORM = 31
R8G8B8A8_SINT = 32
R16G16_TYPELESS = 33
R16G16_FLOAT = 34
R16G16_UNORM = 35
R16G16_UINT = 36
R16G16_SNORM = 37
R16G16_SINT = 38
R32_TYPELESS = 39
D32_FLOAT = 40
R32_FLOAT = 41
R32_UINT = 42
R32_SINT = 43
R24G8_TYPELESS = 44
D24_UNORM_S8_UINT = 45
R24_UNORM_X8_TYPELESS = 46
X24_TYPELESS_G8_UINT = 47
R8G8_TYPELESS = 48
R8G8_UNORM = 49
R8G8_UINT = 50
R8G8_SNORM = 51
R8G8_SINT = 52
R16_TYPELESS = 53
R16_FLOAT = 54
D16_UNORM = 55
R16_UNORM = 56
R16_UINT = 57
R16_SNORM = 58
R16_SINT = 59
R8_TYPELESS = 60
R8_UNORM = 61
R8_UINT = 62
R8_SNORM = 63
R8_SINT = 64
A8_UNORM = 65
R1_UNORM = 66
R9G9B9E5_SHAREDEXP = 67
R8G8_B8G8_UNORM = 68
G8R8_G8B8_UNORM = 69
BC1_TYPELESS = 70
BC1_UNORM = 71
BC1_UNORM_SRGB = 72
BC2_TYPELESS = 73
BC2_UNORM = 74
BC2_UNORM_SRGB = 75
BC3_TYPELESS = 76
BC3_UNORM = 77
BC3_UNORM_SRGB = 78
BC4_TYPELESS = 79
BC4_UNORM = 80
BC4_SNORM = 81
BC5_TYPELESS = 82
BC5_UNORM = 83
BC5_SNORM = 84
B5G6R5_UNORM = 85
B5G5R5A1_UNORM = 86
B8G8R8A8_UNORM = 87
B8G8R8X8_UNORM = 88
R10G10B10_XR_BIAS_A2_UNORM = 89
B8G8R8A8_TYPELESS = 90
B8G8R8A8_UNORM_SRGB = 91
B8G8R8X8_TYPELESS = 92
B8G8R8X8_UNORM_SRGB = 93
BC6H_TYPELESS = 94
BC6H_UF16 = 95
BC6H_SF16 = 96
BC7_TYPELESS = 97
BC7_UNORM = 98
BC7_UNORM_SRGB = 99
AYUV = 100
Y410 = 101
Y416 = 102
NV12 = 103
P010 = 104
P016 = 105
OPAQUE_420 = 106
YUY2 = 107
Y210 = 108
Y216 = 109
NV11 = 110
AI44 = 111
IA44 = 112
P8 = 113
A8P8 = 114
B4G4R4A4_UNORM = 115
P208 = 130
V208 = 131
V408 = 132
SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189
SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190
class D3DFMT(IntEnum):
UNKNOWN = 0
R8G8B8 = 20
A8R8G8B8 = 21
X8R8G8B8 = 22
R5G6B5 = 23
X1R5G5B5 = 24
A1R5G5B5 = 25
A4R4G4B4 = 26
R3G3B2 = 27
A8 = 28
A8R3G3B2 = 29
X4R4G4B4 = 30
A2B10G10R10 = 31
A8B8G8R8 = 32
X8B8G8R8 = 33
G16R16 = 34
A2R10G10B10 = 35
A16B16G16R16 = 36
A8P8 = 40
P8 = 41
L8 = 50
A8L8 = 51
A4L4 = 52
V8U8 = 60
L6V5U5 = 61
X8L8V8U8 = 62
Q8W8V8U8 = 63
V16U16 = 64
A2W10V10U10 = 67
D16_LOCKABLE = 70
D32 = 71
D15S1 = 73
D24S8 = 75
D24X8 = 77
D24X4S4 = 79
D16 = 80
D32F_LOCKABLE = 82
D24FS8 = 83
D32_LOCKABLE = 84
S8_LOCKABLE = 85
L16 = 81
VERTEXDATA = 100
INDEX16 = 101
INDEX32 = 102
Q16W16V16U16 = 110
R16F = 111
G16R16F = 112
A16B16G16R16F = 113
R32F = 114
G32R32F = 115
A32B32G32R32F = 116
CxV8U8 = 117
A1 = 118
A2B10G10R10_XR_BIAS = 119
BINARYBUFFER = 199
UYVY = i32(b"UYVY")
R8G8_B8G8 = i32(b"RGBG")
YUY2 = i32(b"YUY2")
G8R8_G8B8 = i32(b"GRGB")
DXT1 = i32(b"DXT1")
DXT2 = i32(b"DXT2")
DXT3 = i32(b"DXT3")
DXT4 = i32(b"DXT4")
DXT5 = i32(b"DXT5")
DX10 = i32(b"DX10")
BC4S = i32(b"BC4S")
BC4U = i32(b"BC4U")
BC5S = i32(b"BC5S")
BC5U = i32(b"BC5U")
ATI1 = i32(b"ATI1")
ATI2 = i32(b"ATI2")
MULTI2_ARGB8 = i32(b"MET1")
# Backward compatibility layer
module = sys.modules[__name__]
for item in DDSD:
assert item.name is not None
setattr(module, f"DDSD_{item.name}", item.value)
for item1 in DDSCAPS:
assert item1.name is not None
setattr(module, f"DDSCAPS_{item1.name}", item1.value)
for item2 in DDSCAPS2:
assert item2.name is not None
setattr(module, f"DDSCAPS2_{item2.name}", item2.value)
for item3 in DDPF:
assert item3.name is not None
setattr(module, f"DDPF_{item3.name}", item3.value)
DDS_FOURCC = DDPF.FOURCC
DDS_RGB = DDPF.RGB
DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS
DDS_LUMINANCE = DDPF.LUMINANCE
DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS
DDS_ALPHA = DDPF.ALPHA
DDS_PAL8 = DDPF.PALETTEINDEXED8
DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT
DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH
DDS_HEADER_FLAGS_PITCH = DDSD.PITCH
DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE
DDS_HEIGHT = DDSD.HEIGHT
DDS_WIDTH = DDSD.WIDTH
DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE
DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP
DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX
DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX
DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX
DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY
DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY
DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ
DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ
DXT1_FOURCC = D3DFMT.DXT1
DXT3_FOURCC = D3DFMT.DXT3
DXT5_FOURCC = D3DFMT.DXT5
DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS
DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM
DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB
DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS
DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM
DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM
DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16
DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16
DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS
DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM
DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB
class DdsImageFile(ImageFile.ImageFile):
format = "DDS"
format_description = "DirectDraw Surface"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "not a DDS file"
raise SyntaxError(msg)
(header_size,) = struct.unpack("<I", self.fp.read(4))
if header_size != 124:
msg = f"Unsupported header size {repr(header_size)}"
raise OSError(msg)
header = self.fp.read(header_size - 4)
if len(header) != 120:
msg = f"Incomplete header: {len(header)} bytes"
raise OSError(msg)
flags, height, width = struct.unpack("<3I", header[:12])
self._size = (width, height)
extents = (0, 0) + self.size
pitch, depth, mipmaps = struct.unpack("<3I", header[12:24])
struct.unpack("<11I", header[24:68]) # reserved
# pixel format
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header[68:84])
n = 0
rawmode = None
if pfflags & DDPF.RGB:
# Texture contains uncompressed RGB data
if pfflags & DDPF.ALPHAPIXELS:
self._mode = "RGBA"
mask_count = 4
else:
self._mode = "RGB"
mask_count = 3
masks = struct.unpack(f"<{mask_count}I", header[84 : 84 + mask_count * 4])
self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))]
return
elif pfflags & DDPF.LUMINANCE:
if bitcount == 8:
self._mode = "L"
elif bitcount == 16 and pfflags & DDPF.ALPHAPIXELS:
self._mode = "LA"
else:
msg = f"Unsupported bitcount {bitcount} for {pfflags}"
raise OSError(msg)
elif pfflags & DDPF.PALETTEINDEXED8:
self._mode = "P"
self.palette = ImagePalette.raw("RGBA", self.fp.read(1024))
self.palette.mode = "RGBA"
elif pfflags & DDPF.FOURCC:
offset = header_size + 4
if fourcc == D3DFMT.DXT1:
self._mode = "RGBA"
self.pixel_format = "DXT1"
n = 1
elif fourcc == D3DFMT.DXT3:
self._mode = "RGBA"
self.pixel_format = "DXT3"
n = 2
elif fourcc == D3DFMT.DXT5:
self._mode = "RGBA"
self.pixel_format = "DXT5"
n = 3
elif fourcc in (D3DFMT.BC4U, D3DFMT.ATI1):
self._mode = "L"
self.pixel_format = "BC4"
n = 4
elif fourcc == D3DFMT.BC5S:
self._mode = "RGB"
self.pixel_format = "BC5S"
n = 5
elif fourcc in (D3DFMT.BC5U, D3DFMT.ATI2):
self._mode = "RGB"
self.pixel_format = "BC5"
n = 5
elif fourcc == D3DFMT.DX10:
offset += 20
# ignoring flags which pertain to volume textures and cubemaps
(dxgi_format,) = struct.unpack("<I", self.fp.read(4))
self.fp.read(16)
if dxgi_format in (
DXGI_FORMAT.BC1_UNORM,
DXGI_FORMAT.BC1_TYPELESS,
):
self._mode = "RGBA"
self.pixel_format = "BC1"
n = 1
elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM):
self._mode = "RGBA"
self.pixel_format = "BC2"
n = 2
elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM):
self._mode = "RGBA"
self.pixel_format = "BC3"
n = 3
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
self._mode = "L"
self.pixel_format = "BC4"
n = 4
elif dxgi_format in (DXGI_FORMAT.BC5_TYPELESS, DXGI_FORMAT.BC5_UNORM):
self._mode = "RGB"
self.pixel_format = "BC5"
n = 5
elif dxgi_format == DXGI_FORMAT.BC5_SNORM:
self._mode = "RGB"
self.pixel_format = "BC5S"
n = 5
elif dxgi_format == DXGI_FORMAT.BC6H_UF16:
self._mode = "RGB"
self.pixel_format = "BC6H"
n = 6
elif dxgi_format == DXGI_FORMAT.BC6H_SF16:
self._mode = "RGB"
self.pixel_format = "BC6HS"
n = 6
elif dxgi_format in (
DXGI_FORMAT.BC7_TYPELESS,
DXGI_FORMAT.BC7_UNORM,
DXGI_FORMAT.BC7_UNORM_SRGB,
):
self._mode = "RGBA"
self.pixel_format = "BC7"
n = 7
if dxgi_format == DXGI_FORMAT.BC7_UNORM_SRGB:
self.info["gamma"] = 1 / 2.2
elif dxgi_format in (
DXGI_FORMAT.R8G8B8A8_TYPELESS,
DXGI_FORMAT.R8G8B8A8_UNORM,
DXGI_FORMAT.R8G8B8A8_UNORM_SRGB,
):
self._mode = "RGBA"
if dxgi_format == DXGI_FORMAT.R8G8B8A8_UNORM_SRGB:
self.info["gamma"] = 1 / 2.2
else:
msg = f"Unimplemented DXGI format {dxgi_format}"
raise NotImplementedError(msg)
else:
msg = f"Unimplemented pixel format {repr(fourcc)}"
raise NotImplementedError(msg)
else:
msg = f"Unknown pixel format flags {pfflags}"
raise NotImplementedError(msg)
if n:
self.tile = [
ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format))
]
else:
self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)]
def load_seek(self, pos: int) -> None:
pass
class DdsRgbDecoder(ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
bitcount, masks = self.args
# Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
# Calculate how many zeros each mask is padded with
mask_offsets = []
# And the maximum value of each channel without the padding
mask_totals = []
for mask in masks:
offset = 0
if mask != 0:
while mask >> (offset + 1) << (offset + 1) == mask:
offset += 1
mask_offsets.append(offset)
mask_totals.append(mask >> offset)
data = bytearray()
bytecount = bitcount // 8
dest_length = self.state.xsize * self.state.ysize * len(masks)
while len(data) < dest_length:
value = int.from_bytes(self.fd.read(bytecount), "little")
for i, mask in enumerate(masks):
masked_value = value & mask
# Remove the zero padding, and scale it to 8 bits
data += o8(
int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
if mask_totals[i]
else 0
)
self.set_as_raw(data)
return -1, 0
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode not in ("RGB", "RGBA", "L", "LA"):
msg = f"cannot write mode {im.mode} as DDS"
raise OSError(msg)
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
bitcount = len(im.getbands()) * 8
pixel_format = im.encoderinfo.get("pixel_format")
args: tuple[int] | str
if pixel_format:
codec_name = "bcn"
flags |= DDSD.LINEARSIZE
pitch = (im.width + 3) * 4
rgba_mask = [0, 0, 0, 0]
pixel_flags = DDPF.FOURCC
if pixel_format == "DXT1":
fourcc = D3DFMT.DXT1
args = (1,)
elif pixel_format == "DXT3":
fourcc = D3DFMT.DXT3
args = (2,)
elif pixel_format == "DXT5":
fourcc = D3DFMT.DXT5
args = (3,)
else:
fourcc = D3DFMT.DX10
if pixel_format == "BC2":
args = (2,)
dxgi_format = DXGI_FORMAT.BC2_TYPELESS
elif pixel_format == "BC3":
args = (3,)
dxgi_format = DXGI_FORMAT.BC3_TYPELESS
elif pixel_format == "BC5":
args = (5,)
dxgi_format = DXGI_FORMAT.BC5_TYPELESS
if im.mode != "RGB":
msg = "only RGB mode can be written as BC5"
raise OSError(msg)
else:
msg = f"cannot write pixel format {pixel_format}"
raise OSError(msg)
else:
codec_name = "raw"
flags |= DDSD.PITCH
pitch = (im.width * bitcount + 7) // 8
alpha = im.mode[-1] == "A"
if im.mode[0] == "L":
pixel_flags = DDPF.LUMINANCE
args = im.mode
if alpha:
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
else:
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
else:
pixel_flags = DDPF.RGB
args = im.mode[::-1]
rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
if alpha:
r, g, b, a = im.split()
im = Image.merge("RGBA", (a, r, g, b))
if alpha:
pixel_flags |= DDPF.ALPHAPIXELS
rgba_mask.append(0xFF000000 if alpha else 0)
fourcc = D3DFMT.UNKNOWN
fp.write(
o32(DDS_MAGIC)
+ struct.pack(
"<7I",
124, # header size
flags, # flags
im.height,
im.width,
pitch,
0, # depth
0, # mipmaps
)
+ struct.pack("11I", *((0,) * 11)) # reserved
# pfsize, pfflags, fourcc, bitcount
+ struct.pack("<4I", 32, pixel_flags, fourcc, bitcount)
+ struct.pack("<4I", *rgba_mask) # dwRGBABitMask
+ struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0)
)
if fourcc == D3DFMT.DX10:
fp.write(
# dxgi_format, 2D resource, misc, array size, straight alpha
struct.pack("<5I", dxgi_format, 3, 0, 0, 1)
)
ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)])
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"DDS ")
Image.register_open(DdsImageFile.format, DdsImageFile, _accept)
Image.register_decoder("dds_rgb", DdsRgbDecoder)
Image.register_save(DdsImageFile.format, _save)
Image.register_extension(DdsImageFile.format, ".dds")

View File

@@ -1,481 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# EPS file handling
#
# History:
# 1995-09-01 fl Created (0.1)
# 1996-05-18 fl Don't choke on "atend" fields, Ghostscript interface (0.2)
# 1996-08-22 fl Don't choke on floating point BoundingBox values
# 1996-08-23 fl Handle files from Macintosh (0.3)
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4)
# 2003-09-07 fl Check gs.close status (from Federico Di Gregorio) (0.5)
# 2014-05-07 e Handling of EPS with binary preview and fixed resolution
# resizing
#
# Copyright (c) 1997-2003 by Secret Labs AB.
# Copyright (c) 1995-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
import os
import re
import subprocess
import sys
import tempfile
from typing import IO
from . import Image, ImageFile
from ._binary import i32le as i32
# --------------------------------------------------------------------
split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")
gs_binary: str | bool | None = None
gs_windows_binary = None
def has_ghostscript() -> bool:
global gs_binary, gs_windows_binary
if gs_binary is None:
if sys.platform.startswith("win"):
if gs_windows_binary is None:
import shutil
for binary in ("gswin32c", "gswin64c", "gs"):
if shutil.which(binary) is not None:
gs_windows_binary = binary
break
else:
gs_windows_binary = False
gs_binary = gs_windows_binary
else:
try:
subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL)
gs_binary = "gs"
except OSError:
gs_binary = False
return gs_binary is not False
def Ghostscript(
tile: list[ImageFile._Tile],
size: tuple[int, int],
fp: IO[bytes],
scale: int = 1,
transparency: bool = False,
) -> Image.core.ImagingCore:
"""Render an image using Ghostscript"""
global gs_binary
if not has_ghostscript():
msg = "Unable to locate Ghostscript on paths"
raise OSError(msg)
assert isinstance(gs_binary, str)
# Unpack decoder tile
args = tile[0].args
assert isinstance(args, tuple)
length, bbox = args
# Hack to support hi-res rendering
scale = int(scale) or 1
width = size[0] * scale
height = size[1] * scale
# resolution is dependent on bbox and size
res_x = 72.0 * width / (bbox[2] - bbox[0])
res_y = 72.0 * height / (bbox[3] - bbox[1])
out_fd, outfile = tempfile.mkstemp()
os.close(out_fd)
infile_temp = None
if hasattr(fp, "name") and os.path.exists(fp.name):
infile = fp.name
else:
in_fd, infile_temp = tempfile.mkstemp()
os.close(in_fd)
infile = infile_temp
# Ignore length and offset!
# Ghostscript can read it
# Copy whole file to read in Ghostscript
with open(infile_temp, "wb") as f:
# fetch length of fp
fp.seek(0, io.SEEK_END)
fsize = fp.tell()
# ensure start position
# go back
fp.seek(0)
lengthfile = fsize
while lengthfile > 0:
s = fp.read(min(lengthfile, 100 * 1024))
if not s:
break
lengthfile -= len(s)
f.write(s)
if transparency:
# "RGBA"
device = "pngalpha"
else:
# "pnmraw" automatically chooses between
# PBM ("1"), PGM ("L"), and PPM ("RGB").
device = "pnmraw"
# Build Ghostscript command
command = [
gs_binary,
"-q", # quiet mode
f"-g{width:d}x{height:d}", # set output geometry (pixels)
f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch)
"-dBATCH", # exit after processing
"-dNOPAUSE", # don't pause between pages
"-dSAFER", # safe mode
f"-sDEVICE={device}",
f"-sOutputFile={outfile}", # output file
# adjust for image origin
"-c",
f"{-bbox[0]} {-bbox[1]} translate",
"-f",
infile, # input file
# showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272)
"-c",
"showpage",
]
# push data through Ghostscript
try:
startupinfo = None
if sys.platform.startswith("win"):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
subprocess.check_call(command, startupinfo=startupinfo)
with Image.open(outfile) as out_im:
out_im.load()
return out_im.im.copy()
finally:
try:
os.unlink(outfile)
if infile_temp:
os.unlink(infile_temp)
except OSError:
pass
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"%!PS") or (
len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5
)
##
# Image plugin for Encapsulated PostScript. This plugin supports only
# a few variants of this format.
class EpsImageFile(ImageFile.ImageFile):
"""EPS File Parser for the Python Imaging Library"""
format = "EPS"
format_description = "Encapsulated Postscript"
mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
def _open(self) -> None:
assert self.fp is not None
length, offset = self._find_offset(self.fp)
# go to offset - start of "%!PS"
self.fp.seek(offset)
self._mode = "RGB"
# When reading header comments, the first comment is used.
# When reading trailer comments, the last comment is used.
bounding_box: list[int] | None = None
imagedata_size: tuple[int, int] | None = None
byte_arr = bytearray(255)
bytes_mv = memoryview(byte_arr)
bytes_read = 0
reading_header_comments = True
reading_trailer_comments = False
trailer_reached = False
def check_required_header_comments() -> None:
"""
The EPS specification requires that some headers exist.
This should be checked when the header comments formally end,
when image data starts, or when the file ends, whichever comes first.
"""
if "PS-Adobe" not in self.info:
msg = 'EPS header missing "%!PS-Adobe" comment'
raise SyntaxError(msg)
if "BoundingBox" not in self.info:
msg = 'EPS header missing "%%BoundingBox" comment'
raise SyntaxError(msg)
def read_comment(s: str) -> bool:
nonlocal bounding_box, reading_trailer_comments
try:
m = split.match(s)
except re.error as e:
msg = "not an EPS file"
raise SyntaxError(msg) from e
if not m:
return False
k, v = m.group(1, 2)
self.info[k] = v
if k == "BoundingBox":
if v == "(atend)":
reading_trailer_comments = True
elif not bounding_box or (trailer_reached and reading_trailer_comments):
try:
# Note: The DSC spec says that BoundingBox
# fields should be integers, but some drivers
# put floating point values there anyway.
bounding_box = [int(float(i)) for i in v.split()]
except Exception:
pass
return True
while True:
byte = self.fp.read(1)
if byte == b"":
# if we didn't read a byte we must be at the end of the file
if bytes_read == 0:
if reading_header_comments:
check_required_header_comments()
break
elif byte in b"\r\n":
# if we read a line ending character, ignore it and parse what
# we have already read. if we haven't read any other characters,
# continue reading
if bytes_read == 0:
continue
else:
# ASCII/hexadecimal lines in an EPS file must not exceed
# 255 characters, not including line ending characters
if bytes_read >= 255:
# only enforce this for lines starting with a "%",
# otherwise assume it's binary data
if byte_arr[0] == ord("%"):
msg = "not an EPS file"
raise SyntaxError(msg)
else:
if reading_header_comments:
check_required_header_comments()
reading_header_comments = False
# reset bytes_read so we can keep reading
# data until the end of the line
bytes_read = 0
byte_arr[bytes_read] = byte[0]
bytes_read += 1
continue
if reading_header_comments:
# Load EPS header
# if this line doesn't start with a "%",
# or does start with "%%EndComments",
# then we've reached the end of the header/comments
if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments":
check_required_header_comments()
reading_header_comments = False
continue
s = str(bytes_mv[:bytes_read], "latin-1")
if not read_comment(s):
m = field.match(s)
if m:
k = m.group(1)
if k.startswith("PS-Adobe"):
self.info["PS-Adobe"] = k[9:]
else:
self.info[k] = ""
elif s[0] == "%":
# handle non-DSC PostScript comments that some
# tools mistakenly put in the Comments section
pass
else:
msg = "bad EPS header"
raise OSError(msg)
elif bytes_mv[:11] == b"%ImageData:":
# Check for an "ImageData" descriptor
# https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096
# If we've already read an "ImageData" descriptor,
# don't read another one.
if imagedata_size:
bytes_read = 0
continue
# Values:
# columns
# rows
# bit depth (1 or 8)
# mode (1: L, 2: LAB, 3: RGB, 4: CMYK)
# number of padding channels
# block size (number of bytes per row per channel)
# binary/ascii (1: binary, 2: ascii)
# data start identifier (the image data follows after a single line
# consisting only of this quoted value)
image_data_values = byte_arr[11:bytes_read].split(None, 7)
columns, rows, bit_depth, mode_id = (
int(value) for value in image_data_values[:4]
)
if bit_depth == 1:
self._mode = "1"
elif bit_depth == 8:
try:
self._mode = self.mode_map[mode_id]
except ValueError:
break
else:
break
# Parse the columns and rows after checking the bit depth and mode
# in case the bit depth and/or mode are invalid.
imagedata_size = columns, rows
elif bytes_mv[:5] == b"%%EOF":
break
elif trailer_reached and reading_trailer_comments:
# Load EPS trailer
s = str(bytes_mv[:bytes_read], "latin-1")
read_comment(s)
elif bytes_mv[:9] == b"%%Trailer":
trailer_reached = True
elif bytes_mv[:14] == b"%%BeginBinary:":
bytecount = int(byte_arr[14:bytes_read])
self.fp.seek(bytecount, os.SEEK_CUR)
bytes_read = 0
# A "BoundingBox" is always required,
# even if an "ImageData" descriptor size exists.
if not bounding_box:
msg = "cannot determine EPS bounding box"
raise OSError(msg)
# An "ImageData" size takes precedence over the "BoundingBox".
self._size = imagedata_size or (
bounding_box[2] - bounding_box[0],
bounding_box[3] - bounding_box[1],
)
self.tile = [
ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box))
]
def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
s = fp.read(4)
if s == b"%!PS":
# for HEAD without binary preview
fp.seek(0, io.SEEK_END)
length = fp.tell()
offset = 0
elif i32(s) == 0xC6D3D0C5:
# FIX for: Some EPS file not handled correctly / issue #302
# EPS can contain binary data
# or start directly with latin coding
# more info see:
# https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf
s = fp.read(8)
offset = i32(s)
length = i32(s, 4)
else:
msg = "not an EPS file"
raise SyntaxError(msg)
return length, offset
def load(
self, scale: int = 1, transparency: bool = False
) -> Image.core.PixelAccess | None:
# Load EPS via Ghostscript
if self.tile:
assert self.fp is not None
self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
self._mode = self.im.mode
self._size = self.im.size
self.tile = []
return Image.Image.load(self)
def load_seek(self, pos: int) -> None:
# we can't incrementally load, so force ImageFile.parser to
# use our custom load method by defining this method.
pass
# --------------------------------------------------------------------
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
"""EPS Writer for the Python Imaging Library."""
# make sure image data is available
im.load()
# determine PostScript image mode
if im.mode == "L":
operator = (8, 1, b"image")
elif im.mode == "RGB":
operator = (8, 3, b"false 3 colorimage")
elif im.mode == "CMYK":
operator = (8, 4, b"false 4 colorimage")
else:
msg = "image mode is not supported"
raise ValueError(msg)
if eps:
# write EPS header
fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
# fp.write("%%CreationDate: %s"...)
fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
fp.write(b"%%Pages: 1\n")
fp.write(b"%%EndComments\n")
fp.write(b"%%Page: 1 1\n")
fp.write(b"%%ImageData: %d %d " % im.size)
fp.write(b'%d %d 0 1 1 "%s"\n' % operator)
# image header
fp.write(b"gsave\n")
fp.write(b"10 dict begin\n")
fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
fp.write(b"%d %d scale\n" % im.size)
fp.write(b"%d %d 8\n" % im.size) # <= bits
fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
fp.write(b"{ currentfile buf readhexstring pop } bind\n")
fp.write(operator[2] + b"\n")
if hasattr(fp, "flush"):
fp.flush()
ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)])
fp.write(b"\n%%%%EndBinary\n")
fp.write(b"grestore end\n")
if hasattr(fp, "flush"):
fp.flush()
# --------------------------------------------------------------------
Image.register_open(EpsImageFile.format, EpsImageFile, _accept)
Image.register_save(EpsImageFile.format, _save)
Image.register_extensions(EpsImageFile.format, [".ps", ".eps"])
Image.register_mime(EpsImageFile.format, "application/postscript")

View File

@@ -1,384 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# EXIF tags
#
# Copyright (c) 2003 by Secret Labs AB
#
# See the README file for information on usage and redistribution.
#
"""
This module provides constants and clear-text names for various
well-known EXIF tags.
"""
from __future__ import annotations
from enum import IntEnum
class Base(IntEnum):
# possibly incomplete
InteropIndex = 0x0001
ProcessingSoftware = 0x000B
NewSubfileType = 0x00FE
SubfileType = 0x00FF
ImageWidth = 0x0100
ImageLength = 0x0101
BitsPerSample = 0x0102
Compression = 0x0103
PhotometricInterpretation = 0x0106
Thresholding = 0x0107
CellWidth = 0x0108
CellLength = 0x0109
FillOrder = 0x010A
DocumentName = 0x010D
ImageDescription = 0x010E
Make = 0x010F
Model = 0x0110
StripOffsets = 0x0111
Orientation = 0x0112
SamplesPerPixel = 0x0115
RowsPerStrip = 0x0116
StripByteCounts = 0x0117
MinSampleValue = 0x0118
MaxSampleValue = 0x0119
XResolution = 0x011A
YResolution = 0x011B
PlanarConfiguration = 0x011C
PageName = 0x011D
FreeOffsets = 0x0120
FreeByteCounts = 0x0121
GrayResponseUnit = 0x0122
GrayResponseCurve = 0x0123
T4Options = 0x0124
T6Options = 0x0125
ResolutionUnit = 0x0128
PageNumber = 0x0129
TransferFunction = 0x012D
Software = 0x0131
DateTime = 0x0132
Artist = 0x013B
HostComputer = 0x013C
Predictor = 0x013D
WhitePoint = 0x013E
PrimaryChromaticities = 0x013F
ColorMap = 0x0140
HalftoneHints = 0x0141
TileWidth = 0x0142
TileLength = 0x0143
TileOffsets = 0x0144
TileByteCounts = 0x0145
SubIFDs = 0x014A
InkSet = 0x014C
InkNames = 0x014D
NumberOfInks = 0x014E
DotRange = 0x0150
TargetPrinter = 0x0151
ExtraSamples = 0x0152
SampleFormat = 0x0153
SMinSampleValue = 0x0154
SMaxSampleValue = 0x0155
TransferRange = 0x0156
ClipPath = 0x0157
XClipPathUnits = 0x0158
YClipPathUnits = 0x0159
Indexed = 0x015A
JPEGTables = 0x015B
OPIProxy = 0x015F
JPEGProc = 0x0200
JpegIFOffset = 0x0201
JpegIFByteCount = 0x0202
JpegRestartInterval = 0x0203
JpegLosslessPredictors = 0x0205
JpegPointTransforms = 0x0206
JpegQTables = 0x0207
JpegDCTables = 0x0208
JpegACTables = 0x0209
YCbCrCoefficients = 0x0211
YCbCrSubSampling = 0x0212
YCbCrPositioning = 0x0213
ReferenceBlackWhite = 0x0214
XMLPacket = 0x02BC
RelatedImageFileFormat = 0x1000
RelatedImageWidth = 0x1001
RelatedImageLength = 0x1002
Rating = 0x4746
RatingPercent = 0x4749
ImageID = 0x800D
CFARepeatPatternDim = 0x828D
BatteryLevel = 0x828F
Copyright = 0x8298
ExposureTime = 0x829A
FNumber = 0x829D
IPTCNAA = 0x83BB
ImageResources = 0x8649
ExifOffset = 0x8769
InterColorProfile = 0x8773
ExposureProgram = 0x8822
SpectralSensitivity = 0x8824
GPSInfo = 0x8825
ISOSpeedRatings = 0x8827
OECF = 0x8828
Interlace = 0x8829
TimeZoneOffset = 0x882A
SelfTimerMode = 0x882B
SensitivityType = 0x8830
StandardOutputSensitivity = 0x8831
RecommendedExposureIndex = 0x8832
ISOSpeed = 0x8833
ISOSpeedLatitudeyyy = 0x8834
ISOSpeedLatitudezzz = 0x8835
ExifVersion = 0x9000
DateTimeOriginal = 0x9003
DateTimeDigitized = 0x9004
OffsetTime = 0x9010
OffsetTimeOriginal = 0x9011
OffsetTimeDigitized = 0x9012
ComponentsConfiguration = 0x9101
CompressedBitsPerPixel = 0x9102
ShutterSpeedValue = 0x9201
ApertureValue = 0x9202
BrightnessValue = 0x9203
ExposureBiasValue = 0x9204
MaxApertureValue = 0x9205
SubjectDistance = 0x9206
MeteringMode = 0x9207
LightSource = 0x9208
Flash = 0x9209
FocalLength = 0x920A
Noise = 0x920D
ImageNumber = 0x9211
SecurityClassification = 0x9212
ImageHistory = 0x9213
TIFFEPStandardID = 0x9216
MakerNote = 0x927C
UserComment = 0x9286
SubsecTime = 0x9290
SubsecTimeOriginal = 0x9291
SubsecTimeDigitized = 0x9292
AmbientTemperature = 0x9400
Humidity = 0x9401
Pressure = 0x9402
WaterDepth = 0x9403
Acceleration = 0x9404
CameraElevationAngle = 0x9405
XPTitle = 0x9C9B
XPComment = 0x9C9C
XPAuthor = 0x9C9D
XPKeywords = 0x9C9E
XPSubject = 0x9C9F
FlashPixVersion = 0xA000
ColorSpace = 0xA001
ExifImageWidth = 0xA002
ExifImageHeight = 0xA003
RelatedSoundFile = 0xA004
ExifInteroperabilityOffset = 0xA005
FlashEnergy = 0xA20B
SpatialFrequencyResponse = 0xA20C
FocalPlaneXResolution = 0xA20E
FocalPlaneYResolution = 0xA20F
FocalPlaneResolutionUnit = 0xA210
SubjectLocation = 0xA214
ExposureIndex = 0xA215
SensingMethod = 0xA217
FileSource = 0xA300
SceneType = 0xA301
CFAPattern = 0xA302
CustomRendered = 0xA401
ExposureMode = 0xA402
WhiteBalance = 0xA403
DigitalZoomRatio = 0xA404
FocalLengthIn35mmFilm = 0xA405
SceneCaptureType = 0xA406
GainControl = 0xA407
Contrast = 0xA408
Saturation = 0xA409
Sharpness = 0xA40A
DeviceSettingDescription = 0xA40B
SubjectDistanceRange = 0xA40C
ImageUniqueID = 0xA420
CameraOwnerName = 0xA430
BodySerialNumber = 0xA431
LensSpecification = 0xA432
LensMake = 0xA433
LensModel = 0xA434
LensSerialNumber = 0xA435
CompositeImage = 0xA460
CompositeImageCount = 0xA461
CompositeImageExposureTimes = 0xA462
Gamma = 0xA500
PrintImageMatching = 0xC4A5
DNGVersion = 0xC612
DNGBackwardVersion = 0xC613
UniqueCameraModel = 0xC614
LocalizedCameraModel = 0xC615
CFAPlaneColor = 0xC616
CFALayout = 0xC617
LinearizationTable = 0xC618
BlackLevelRepeatDim = 0xC619
BlackLevel = 0xC61A
BlackLevelDeltaH = 0xC61B
BlackLevelDeltaV = 0xC61C
WhiteLevel = 0xC61D
DefaultScale = 0xC61E
DefaultCropOrigin = 0xC61F
DefaultCropSize = 0xC620
ColorMatrix1 = 0xC621
ColorMatrix2 = 0xC622
CameraCalibration1 = 0xC623
CameraCalibration2 = 0xC624
ReductionMatrix1 = 0xC625
ReductionMatrix2 = 0xC626
AnalogBalance = 0xC627
AsShotNeutral = 0xC628
AsShotWhiteXY = 0xC629
BaselineExposure = 0xC62A
BaselineNoise = 0xC62B
BaselineSharpness = 0xC62C
BayerGreenSplit = 0xC62D
LinearResponseLimit = 0xC62E
CameraSerialNumber = 0xC62F
LensInfo = 0xC630
ChromaBlurRadius = 0xC631
AntiAliasStrength = 0xC632
ShadowScale = 0xC633
DNGPrivateData = 0xC634
MakerNoteSafety = 0xC635
CalibrationIlluminant1 = 0xC65A
CalibrationIlluminant2 = 0xC65B
BestQualityScale = 0xC65C
RawDataUniqueID = 0xC65D
OriginalRawFileName = 0xC68B
OriginalRawFileData = 0xC68C
ActiveArea = 0xC68D
MaskedAreas = 0xC68E
AsShotICCProfile = 0xC68F
AsShotPreProfileMatrix = 0xC690
CurrentICCProfile = 0xC691
CurrentPreProfileMatrix = 0xC692
ColorimetricReference = 0xC6BF
CameraCalibrationSignature = 0xC6F3
ProfileCalibrationSignature = 0xC6F4
AsShotProfileName = 0xC6F6
NoiseReductionApplied = 0xC6F7
ProfileName = 0xC6F8
ProfileHueSatMapDims = 0xC6F9
ProfileHueSatMapData1 = 0xC6FA
ProfileHueSatMapData2 = 0xC6FB
ProfileToneCurve = 0xC6FC
ProfileEmbedPolicy = 0xC6FD
ProfileCopyright = 0xC6FE
ForwardMatrix1 = 0xC714
ForwardMatrix2 = 0xC715
PreviewApplicationName = 0xC716
PreviewApplicationVersion = 0xC717
PreviewSettingsName = 0xC718
PreviewSettingsDigest = 0xC719
PreviewColorSpace = 0xC71A
PreviewDateTime = 0xC71B
RawImageDigest = 0xC71C
OriginalRawFileDigest = 0xC71D
SubTileBlockSize = 0xC71E
RowInterleaveFactor = 0xC71F
ProfileLookTableDims = 0xC725
ProfileLookTableData = 0xC726
OpcodeList1 = 0xC740
OpcodeList2 = 0xC741
OpcodeList3 = 0xC74E
NoiseProfile = 0xC761
FrameRate = 0xC764
"""Maps EXIF tags to tag names."""
TAGS = {
**{i.value: i.name for i in Base},
0x920C: "SpatialFrequencyResponse",
0x9214: "SubjectLocation",
0x9215: "ExposureIndex",
0x828E: "CFAPattern",
0x920B: "FlashEnergy",
0x9216: "TIFF/EPStandardID",
}
class GPS(IntEnum):
GPSVersionID = 0x00
GPSLatitudeRef = 0x01
GPSLatitude = 0x02
GPSLongitudeRef = 0x03
GPSLongitude = 0x04
GPSAltitudeRef = 0x05
GPSAltitude = 0x06
GPSTimeStamp = 0x07
GPSSatellites = 0x08
GPSStatus = 0x09
GPSMeasureMode = 0x0A
GPSDOP = 0x0B
GPSSpeedRef = 0x0C
GPSSpeed = 0x0D
GPSTrackRef = 0x0E
GPSTrack = 0x0F
GPSImgDirectionRef = 0x10
GPSImgDirection = 0x11
GPSMapDatum = 0x12
GPSDestLatitudeRef = 0x13
GPSDestLatitude = 0x14
GPSDestLongitudeRef = 0x15
GPSDestLongitude = 0x16
GPSDestBearingRef = 0x17
GPSDestBearing = 0x18
GPSDestDistanceRef = 0x19
GPSDestDistance = 0x1A
GPSProcessingMethod = 0x1B
GPSAreaInformation = 0x1C
GPSDateStamp = 0x1D
GPSDifferential = 0x1E
GPSHPositioningError = 0x1F
"""Maps EXIF GPS tags to tag names."""
GPSTAGS = {i.value: i.name for i in GPS}
class Interop(IntEnum):
InteropIndex = 0x0001
InteropVersion = 0x0002
RelatedImageFileFormat = 0x1000
RelatedImageWidth = 0x1001
RelatedImageHeight = 0x1002
class IFD(IntEnum):
Exif = 0x8769
GPSInfo = 0x8825
MakerNote = 0x927C
Makernote = 0x927C # Deprecated
Interop = 0xA005
IFD1 = -1
class LightSource(IntEnum):
Unknown = 0x00
Daylight = 0x01
Fluorescent = 0x02
Tungsten = 0x03
Flash = 0x04
Fine = 0x09
Cloudy = 0x0A
Shade = 0x0B
DaylightFluorescent = 0x0C
DayWhiteFluorescent = 0x0D
CoolWhiteFluorescent = 0x0E
WhiteFluorescent = 0x0F
StandardLightA = 0x11
StandardLightB = 0x12
StandardLightC = 0x13
D55 = 0x14
D65 = 0x15
D75 = 0x16
D50 = 0x17
ISO = 0x18
Other = 0xFF

View File

@@ -1,153 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# FITS file handling
#
# Copyright (c) 1998-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import gzip
import math
from . import Image, ImageFile
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"SIMPLE")
class FitsImageFile(ImageFile.ImageFile):
format = "FITS"
format_description = "FITS"
def _open(self) -> None:
assert self.fp is not None
headers: dict[bytes, bytes] = {}
header_in_progress = False
decoder_name = ""
while True:
header = self.fp.read(80)
if not header:
msg = "Truncated FITS file"
raise OSError(msg)
keyword = header[:8].strip()
if keyword in (b"SIMPLE", b"XTENSION"):
header_in_progress = True
elif headers and not header_in_progress:
# This is now a data unit
break
elif keyword == b"END":
# Seek to the end of the header unit
self.fp.seek(math.ceil(self.fp.tell() / 2880) * 2880)
if not decoder_name:
decoder_name, offset, args = self._parse_headers(headers)
header_in_progress = False
continue
if decoder_name:
# Keep going to read past the headers
continue
value = header[8:].split(b"/")[0].strip()
if value.startswith(b"="):
value = value[1:].strip()
if not headers and (not _accept(keyword) or value != b"T"):
msg = "Not a FITS file"
raise SyntaxError(msg)
headers[keyword] = value
if not decoder_name:
msg = "No image data"
raise ValueError(msg)
offset += self.fp.tell() - 80
self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)]
def _get_size(
self, headers: dict[bytes, bytes], prefix: bytes
) -> tuple[int, int] | None:
naxis = int(headers[prefix + b"NAXIS"])
if naxis == 0:
return None
if naxis == 1:
return 1, int(headers[prefix + b"NAXIS1"])
else:
return int(headers[prefix + b"NAXIS1"]), int(headers[prefix + b"NAXIS2"])
def _parse_headers(
self, headers: dict[bytes, bytes]
) -> tuple[str, int, tuple[str | int, ...]]:
prefix = b""
decoder_name = "raw"
offset = 0
if (
headers.get(b"XTENSION") == b"'BINTABLE'"
and headers.get(b"ZIMAGE") == b"T"
and headers[b"ZCMPTYPE"] == b"'GZIP_1 '"
):
no_prefix_size = self._get_size(headers, prefix) or (0, 0)
number_of_bits = int(headers[b"BITPIX"])
offset = no_prefix_size[0] * no_prefix_size[1] * (number_of_bits // 8)
prefix = b"Z"
decoder_name = "fits_gzip"
size = self._get_size(headers, prefix)
if not size:
return "", 0, ()
self._size = size
number_of_bits = int(headers[prefix + b"BITPIX"])
if number_of_bits == 8:
self._mode = "L"
elif number_of_bits == 16:
self._mode = "I;16"
elif number_of_bits == 32:
self._mode = "I"
elif number_of_bits in (-32, -64):
self._mode = "F"
args: tuple[str | int, ...]
if decoder_name == "raw":
args = (self.mode, 0, -1)
else:
args = (number_of_bits,)
return decoder_name, offset, args
class FitsGzipDecoder(ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
with gzip.open(self.fd) as fp:
value = fp.read(self.state.xsize * self.state.ysize * 4)
rows = []
offset = 0
number_of_bits = min(self.args[0] // 8, 4)
for y in range(self.state.ysize):
row = bytearray()
for x in range(self.state.xsize):
row += value[offset + (4 - number_of_bits) : offset + 4]
offset += 4
rows.append(row)
self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row]))
return -1, 0
# --------------------------------------------------------------------
# Registry
Image.register_open(FitsImageFile.format, FitsImageFile, _accept)
Image.register_decoder("fits_gzip", FitsGzipDecoder)
Image.register_extensions(FitsImageFile.format, [".fit", ".fits"])

View File

@@ -1,184 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# FLI/FLC file handling.
#
# History:
# 95-09-01 fl Created
# 97-01-03 fl Fixed parser, setup decoder tile
# 98-07-15 fl Renamed offset attribute to avoid name clash
#
# Copyright (c) Secret Labs AB 1997-98.
# Copyright (c) Fredrik Lundh 1995-97.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16
from ._binary import i32le as i32
from ._binary import o8
from ._util import DeferredError
#
# decoder
def _accept(prefix: bytes) -> bool:
return (
len(prefix) >= 16
and i16(prefix, 4) in [0xAF11, 0xAF12]
and i16(prefix, 14) in [0, 3] # flags
)
##
# Image plugin for the FLI/FLC animation format. Use the <b>seek</b>
# method to load individual frames.
class FliImageFile(ImageFile.ImageFile):
format = "FLI"
format_description = "Autodesk FLI/FLC Animation"
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
# HEAD
assert self.fp is not None
s = self.fp.read(128)
if not (
_accept(s)
and s[20:22] == b"\x00" * 2
and s[42:80] == b"\x00" * 38
and s[88:] == b"\x00" * 40
):
msg = "not an FLI/FLC file"
raise SyntaxError(msg)
# frames
self.n_frames = i16(s, 6)
self.is_animated = self.n_frames > 1
# image characteristics
self._mode = "P"
self._size = i16(s, 8), i16(s, 10)
# animation speed
duration = i32(s, 16)
magic = i16(s, 4)
if magic == 0xAF11:
duration = (duration * 1000) // 70
self.info["duration"] = duration
# look for palette
palette = [(a, a, a) for a in range(256)]
s = self.fp.read(16)
self.__offset = 128
if i16(s, 4) == 0xF100:
# prefix chunk; ignore it
self.fp.seek(self.__offset + i32(s))
s = self.fp.read(16)
if i16(s, 4) == 0xF1FA:
# look for palette chunk
number_of_subchunks = i16(s, 6)
chunk_size: int | None = None
for _ in range(number_of_subchunks):
if chunk_size is not None:
self.fp.seek(chunk_size - 6, os.SEEK_CUR)
s = self.fp.read(6)
chunk_type = i16(s, 4)
if chunk_type in (4, 11):
self._palette(palette, 2 if chunk_type == 11 else 0)
break
chunk_size = i32(s)
if not chunk_size:
break
self.palette = ImagePalette.raw(
"RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette)
)
# set things up to decode first frame
self.__frame = -1
self._fp = self.fp
self.__rewind = self.fp.tell()
self.seek(0)
def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None:
# load palette
i = 0
assert self.fp is not None
for e in range(i16(self.fp.read(2))):
s = self.fp.read(2)
i = i + s[0]
n = s[1]
if n == 0:
n = 256
s = self.fp.read(n * 3)
for n in range(0, len(s), 3):
r = s[n] << shift
g = s[n + 1] << shift
b = s[n + 2] << shift
palette[i] = (r, g, b)
i += 1
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
if frame < self.__frame:
self._seek(0)
for f in range(self.__frame + 1, frame + 1):
self._seek(f)
def _seek(self, frame: int) -> None:
if isinstance(self._fp, DeferredError):
raise self._fp.ex
if frame == 0:
self.__frame = -1
self._fp.seek(self.__rewind)
self.__offset = 128
else:
# ensure that the previous frame was loaded
self.load()
if frame != self.__frame + 1:
msg = f"cannot seek to frame {frame}"
raise ValueError(msg)
self.__frame = frame
# move to next frame
self.fp = self._fp
self.fp.seek(self.__offset)
s = self.fp.read(4)
if not s:
msg = "missing frame size"
raise EOFError(msg)
framesize = i32(s)
self.decodermaxblock = framesize
self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)]
self.__offset += framesize
def tell(self) -> int:
return self.__frame
#
# registry
Image.register_open(FliImageFile.format, FliImageFile, _accept)
Image.register_extensions(FliImageFile.format, [".fli", ".flc"])

View File

@@ -1,159 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# base class for raster font file parsers
#
# history:
# 1997-06-05 fl created
# 1997-08-19 fl restrict image width
#
# Copyright (c) 1997-1998 by Secret Labs AB
# Copyright (c) 1997-1998 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import BinaryIO
from . import Image, ImageFont, _binary
WIDTH = 800
def puti16(
fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int]
) -> None:
"""Write network order (big-endian) 16-bit sequence"""
for v in values:
if v < 0:
v += 65536
fp.write(_binary.o16be(v))
class FontFile:
"""Base class for raster font file handlers."""
bitmap: Image.Image | None = None
def __init__(self) -> None:
self.info: dict[bytes, bytes | int] = {}
self.glyph: list[
tuple[
tuple[int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
Image.Image,
]
| None
] = [None] * 256
def __getitem__(self, ix: int) -> (
tuple[
tuple[int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
Image.Image,
]
| None
):
return self.glyph[ix]
def compile(self) -> None:
"""Create metrics and bitmap"""
if self.bitmap:
return
# create bitmap large enough to hold all data
h = w = maxwidth = 0
lines = 1
for glyph in self.glyph:
if glyph:
d, dst, src, im = glyph
h = max(h, src[3] - src[1])
w = w + (src[2] - src[0])
if w > WIDTH:
lines += 1
w = src[2] - src[0]
maxwidth = max(maxwidth, w)
xsize = maxwidth
ysize = lines * h
if xsize == 0 and ysize == 0:
return
self.ysize = h
# paste glyphs into bitmap
self.bitmap = Image.new("1", (xsize, ysize))
self.metrics: list[
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]]
| None
] = [None] * 256
x = y = 0
for i in range(256):
glyph = self[i]
if glyph:
d, dst, src, im = glyph
xx = src[2] - src[0]
x0, y0 = x, y
x = x + xx
if x > WIDTH:
x, y = 0, y + h
x0, y0 = x, y
x = xx
s = src[0] + x0, src[1] + y0, src[2] + x0, src[3] + y0
self.bitmap.paste(im.crop(src), s)
self.metrics[i] = d, dst, s
def _encode_metrics(self) -> bytes:
values: list[int] = []
for i in range(256):
m = self.metrics[i]
if m:
values.extend(m[0] + m[1] + m[2])
else:
values.extend((0,) * 10)
data = bytearray()
for v in values:
if v < 0:
v += 65536
data += _binary.o16be(v)
return bytes(data)
def save(self, filename: str) -> None:
"""Save font"""
self.compile()
# font data
if not self.bitmap:
msg = "No bitmap created"
raise ValueError(msg)
self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG")
# font metrics
with open(os.path.splitext(filename)[0] + ".pil", "wb") as fp:
fp.write(b"PILfont\n")
fp.write(f";;;;;;{self.ysize};\n".encode("ascii")) # HACK!!!
fp.write(b"DATA\n")
fp.write(self._encode_metrics())
def to_imagefont(self) -> ImageFont.ImageFont:
"""Convert to ImageFont"""
self.compile()
# font data
if not self.bitmap:
msg = "No bitmap created"
raise ValueError(msg)
imagefont = ImageFont.ImageFont()
imagefont._load(self.bitmap, self._encode_metrics())
return imagefont

View File

@@ -1,258 +0,0 @@
#
# THIS IS WORK IN PROGRESS
#
# The Python Imaging Library.
# $Id$
#
# FlashPix support for PIL
#
# History:
# 97-01-25 fl Created (reads uncompressed RGB images only)
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1997.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import olefile
from . import Image, ImageFile
from ._binary import i32le as i32
# we map from colour field tuples to (mode, rawmode) descriptors
MODES = {
# opacity
(0x00007FFE,): ("A", "L"),
# monochrome
(0x00010000,): ("L", "L"),
(0x00018000, 0x00017FFE): ("RGBA", "LA"),
# photo YCC
(0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"),
(0x00028000, 0x00028001, 0x00028002, 0x00027FFE): ("RGBA", "YCCA;P"),
# standard RGB (NIFRGB)
(0x00030000, 0x00030001, 0x00030002): ("RGB", "RGB"),
(0x00038000, 0x00038001, 0x00038002, 0x00037FFE): ("RGBA", "RGBA"),
}
#
# --------------------------------------------------------------------
def _accept(prefix: bytes) -> bool:
return prefix.startswith(olefile.MAGIC)
##
# Image plugin for the FlashPix images.
class FpxImageFile(ImageFile.ImageFile):
format = "FPX"
format_description = "FlashPix"
def _open(self) -> None:
#
# read the OLE directory and see if this is a likely
# to be a FlashPix file
assert self.fp is not None
try:
self.ole = olefile.OleFileIO(self.fp)
except OSError as e:
msg = "not an FPX file; invalid OLE file"
raise SyntaxError(msg) from e
root = self.ole.root
if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B":
msg = "not an FPX file; bad root CLSID"
raise SyntaxError(msg)
self._open_index(1)
def _open_index(self, index: int = 1) -> None:
#
# get the Image Contents Property Set
prop = self.ole.getproperties(
[f"Data Object Store {index:06d}", "\005Image Contents"]
)
# size (highest resolution)
assert isinstance(prop[0x1000002], int)
assert isinstance(prop[0x1000003], int)
self._size = prop[0x1000002], prop[0x1000003]
size = max(self.size)
i = 1
while size > 64:
size = size // 2
i += 1
self.maxid = i - 1
# mode. instead of using a single field for this, flashpix
# requires you to specify the mode for each channel in each
# resolution subimage, and leaves it to the decoder to make
# sure that they all match. for now, we'll cheat and assume
# that this is always the case.
id = self.maxid << 16
s = prop[0x2000002 | id]
if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4:
msg = "Invalid number of bands"
raise OSError(msg)
# note: for now, we ignore the "uncalibrated" flag
colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands))
self._mode, self.rawmode = MODES[colors]
# load JPEG tables, if any
self.jpeg = {}
for i in range(256):
id = 0x3000001 | (i << 16)
if id in prop:
self.jpeg[i] = prop[id]
self._open_subimage(1, self.maxid)
def _open_subimage(self, index: int = 1, subimage: int = 0) -> None:
#
# setup tile descriptors for a given subimage
stream = [
f"Data Object Store {index:06d}",
f"Resolution {subimage:04d}",
"Subimage 0000 Header",
]
fp = self.ole.openstream(stream)
# skip prefix
fp.read(28)
# header stream
s = fp.read(36)
size = i32(s, 4), i32(s, 8)
# tilecount = i32(s, 12)
xtile, ytile = i32(s, 16), i32(s, 20)
# channels = i32(s, 24)
offset = i32(s, 28)
length = i32(s, 32)
if size != self.size:
msg = "subimage mismatch"
raise OSError(msg)
# get tile descriptors
fp.seek(28 + offset)
s = fp.read(i32(s, 12) * length)
x = y = 0
xsize, ysize = size
self.tile = []
for i in range(0, len(s), length):
x1 = min(xsize, x + xtile)
y1 = min(ysize, y + ytile)
compression = i32(s, i + 8)
if compression == 0:
self.tile.append(
ImageFile._Tile(
"raw",
(x, y, x1, y1),
i32(s, i) + 28,
self.rawmode,
)
)
elif compression == 1:
# FIXME: the fill decoder is not implemented
self.tile.append(
ImageFile._Tile(
"fill",
(x, y, x1, y1),
i32(s, i) + 28,
(self.rawmode, s[12:16]),
)
)
elif compression == 2:
internal_color_conversion = s[14]
jpeg_tables = s[15]
rawmode = self.rawmode
if internal_color_conversion:
# The image is stored as usual (usually YCbCr).
if rawmode == "RGBA":
# For "RGBA", data is stored as YCbCrA based on
# negative RGB. The following trick works around
# this problem :
jpegmode, rawmode = "YCbCrK", "CMYK"
else:
jpegmode = None # let the decoder decide
else:
# The image is stored as defined by rawmode
jpegmode = rawmode
self.tile.append(
ImageFile._Tile(
"jpeg",
(x, y, x1, y1),
i32(s, i) + 28,
(rawmode, jpegmode),
)
)
# FIXME: jpeg tables are tile dependent; the prefix
# data must be placed in the tile descriptor itself!
if jpeg_tables:
self.tile_prefix = self.jpeg[jpeg_tables]
else:
msg = "unknown/invalid compression"
raise OSError(msg)
x += xtile
if x >= xsize:
x, y = 0, y + ytile
if y >= ysize:
break # isn't really required
assert self.fp is not None
self.stream = stream
self._fp = self.fp
self.fp = None
def load(self) -> Image.core.PixelAccess | None:
if not self.fp:
self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"])
return ImageFile.ImageFile.load(self)
def close(self) -> None:
self.ole.close()
super().close()
def __exit__(self, *args: object) -> None:
self.ole.close()
super().__exit__()
#
# --------------------------------------------------------------------
Image.register_open(FpxImageFile.format, FpxImageFile, _accept)
Image.register_extension(FpxImageFile.format, ".fpx")

View File

@@ -1,115 +0,0 @@
"""
A Pillow loader for .ftc and .ftu files (FTEX)
Jerome Leclanche <jerome@leclan.ch>
The contents of this file are hereby released in the public domain (CC0)
Full text of the CC0 license:
https://creativecommons.org/publicdomain/zero/1.0/
Independence War 2: Edge Of Chaos - Texture File Format - 16 October 2001
The textures used for 3D objects in Independence War 2: Edge Of Chaos are in a
packed custom format called FTEX. This file format uses file extensions FTC
and FTU.
* FTC files are compressed textures (using standard texture compression).
* FTU files are not compressed.
Texture File Format
The FTC and FTU texture files both use the same format. This
has the following structure:
{header}
{format_directory}
{data}
Where:
{header} = {
u32:magic,
u32:version,
u32:width,
u32:height,
u32:mipmap_count,
u32:format_count
}
* The "magic" number is "FTEX".
* "width" and "height" are the dimensions of the texture.
* "mipmap_count" is the number of mipmaps in the texture.
* "format_count" is the number of texture formats (different versions of the
same texture) in this file.
{format_directory} = format_count * { u32:format, u32:where }
The format value is 0 for DXT1 compressed textures and 1 for 24-bit RGB
uncompressed textures.
The texture data for a format starts at the position "where" in the file.
Each set of texture data in the file has the following structure:
{data} = format_count * { u32:mipmap_size, mipmap_size * { u8 } }
* "mipmap_size" is the number of bytes in that mip level. For compressed
textures this is the size of the texture data compressed with DXT1. For 24 bit
uncompressed textures, this is 3 * width * height. Following this are the image
bytes for that mipmap level.
Note: All data is stored in little-Endian (Intel) byte order.
"""
from __future__ import annotations
import struct
from enum import IntEnum
from io import BytesIO
from . import Image, ImageFile
MAGIC = b"FTEX"
class Format(IntEnum):
DXT1 = 0
UNCOMPRESSED = 1
class FtexImageFile(ImageFile.ImageFile):
format = "FTEX"
format_description = "Texture File Format (IW2:EOC)"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(4)):
msg = "not an FTEX file"
raise SyntaxError(msg)
struct.unpack("<i", self.fp.read(4)) # version
self._size = struct.unpack("<2i", self.fp.read(8))
mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8))
# Only support single-format files.
# I don't know of any multi-format file.
assert format_count == 1
format, where = struct.unpack("<2i", self.fp.read(8))
self.fp.seek(where)
(mipmap_size,) = struct.unpack("<i", self.fp.read(4))
data = self.fp.read(mipmap_size)
if format == Format.DXT1:
self._mode = "RGBA"
self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
elif format == Format.UNCOMPRESSED:
self._mode = "RGB"
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")]
else:
msg = f"Invalid texture compression format: {repr(format)}"
raise ValueError(msg)
self.fp.close()
self.fp = BytesIO(data)
def load_seek(self, pos: int) -> None:
pass
def _accept(prefix: bytes) -> bool:
return prefix.startswith(MAGIC)
Image.register_open(FtexImageFile.format, FtexImageFile, _accept)
Image.register_extensions(FtexImageFile.format, [".ftc", ".ftu"])

View File

@@ -1,103 +0,0 @@
#
# The Python Imaging Library
#
# load a GIMP brush file
#
# History:
# 96-03-14 fl Created
# 16-01-08 es Version 2
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1996.
# Copyright (c) Eric Soroos 2016.
#
# See the README file for information on usage and redistribution.
#
#
# See https://github.com/GNOME/gimp/blob/mainline/devel-docs/gbr.txt for
# format documentation.
#
# This code Interprets version 1 and 2 .gbr files.
# Version 1 files are obsolete, and should not be used for new
# brushes.
# Version 2 files are saved by GIMP v2.8 (at least)
# Version 3 files have a format specifier of 18 for 16bit floats in
# the color depth field. This is currently unsupported by Pillow.
from __future__ import annotations
from . import Image, ImageFile
from ._binary import i32be as i32
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 8 and i32(prefix, 0) >= 20 and i32(prefix, 4) in (1, 2)
##
# Image plugin for the GIMP brush format.
class GbrImageFile(ImageFile.ImageFile):
format = "GBR"
format_description = "GIMP brush file"
def _open(self) -> None:
assert self.fp is not None
header_size = i32(self.fp.read(4))
if header_size < 20:
msg = "not a GIMP brush"
raise SyntaxError(msg)
version = i32(self.fp.read(4))
if version not in (1, 2):
msg = f"Unsupported GIMP brush version: {version}"
raise SyntaxError(msg)
width = i32(self.fp.read(4))
height = i32(self.fp.read(4))
color_depth = i32(self.fp.read(4))
if width == 0 or height == 0:
msg = "not a GIMP brush"
raise SyntaxError(msg)
if color_depth not in (1, 4):
msg = f"Unsupported GIMP brush color depth: {color_depth}"
raise SyntaxError(msg)
if version == 1:
comment_length = header_size - 20
else:
comment_length = header_size - 28
magic_number = self.fp.read(4)
if magic_number != b"GIMP":
msg = "not a GIMP brush, bad magic number"
raise SyntaxError(msg)
self.info["spacing"] = i32(self.fp.read(4))
self.info["comment"] = self.fp.read(comment_length)[:-1]
if color_depth == 1:
self._mode = "L"
else:
self._mode = "RGBA"
self._size = width, height
# Image might not be small
Image._decompression_bomb_check(self.size)
# Data is an uncompressed block of w * h * bytes/pixel
self._data_size = width * height * color_depth
def load(self) -> Image.core.PixelAccess | None:
if self._im is None:
assert self.fp is not None
self.im = Image.core.new(self.mode, self.size)
self.frombytes(self.fp.read(self._data_size))
return Image.Image.load(self)
#
# registry
Image.register_open(GbrImageFile.format, GbrImageFile, _accept)
Image.register_extension(GbrImageFile.format, ".gbr")

View File

@@ -1,103 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# GD file handling
#
# History:
# 1996-04-12 fl Created
#
# Copyright (c) 1997 by Secret Labs AB.
# Copyright (c) 1996 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
"""
.. note::
This format cannot be automatically recognized, so the
class is not registered for use with :py:func:`PIL.Image.open()`. To open a
gd file, use the :py:func:`PIL.GdImageFile.open()` function instead.
.. warning::
THE GD FORMAT IS NOT DESIGNED FOR DATA INTERCHANGE. This
implementation is provided for convenience and demonstrational
purposes only.
"""
from __future__ import annotations
from typing import IO
from . import ImageFile, ImagePalette, UnidentifiedImageError
from ._binary import i16be as i16
from ._binary import i32be as i32
from ._typing import StrOrBytesPath
class GdImageFile(ImageFile.ImageFile):
"""
Image plugin for the GD uncompressed format. Note that this format
is not supported by the standard :py:func:`PIL.Image.open()` function. To use
this plugin, you have to import the :py:mod:`PIL.GdImageFile` module and
use the :py:func:`PIL.GdImageFile.open()` function.
"""
format = "GD"
format_description = "GD uncompressed images"
def _open(self) -> None:
# Header
assert self.fp is not None
s = self.fp.read(1037)
if i16(s) not in [65534, 65535]:
msg = "Not a valid GD 2.x .gd file"
raise SyntaxError(msg)
self._mode = "P"
self._size = i16(s, 2), i16(s, 4)
true_color = s[6]
true_color_offset = 2 if true_color else 0
# transparency index
tindex = i32(s, 7 + true_color_offset)
if tindex < 256:
self.info["transparency"] = tindex
self.palette = ImagePalette.raw(
"RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4]
)
self.tile = [
ImageFile._Tile(
"raw",
(0, 0) + self.size,
7 + true_color_offset + 6 + 256 * 4,
"L",
)
]
def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile:
"""
Load texture from a GD image file.
:param fp: GD file name, or an opened file handle.
:param mode: Optional mode. In this version, if the mode argument
is given, it must be "r".
:returns: An image instance.
:raises OSError: If the image could not be read.
"""
if mode != "r":
msg = "bad mode"
raise ValueError(msg)
try:
return GdImageFile(fp)
except SyntaxError as e:
msg = "cannot identify this image file"
raise UnidentifiedImageError(msg) from e

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +0,0 @@
#
# Python Imaging Library
# $Id$
#
# stuff to read (and render) GIMP gradient files
#
# History:
# 97-08-23 fl Created
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1997.
#
# See the README file for information on usage and redistribution.
#
"""
Stuff to translate curve segments to palette values (derived from
the corresponding code in GIMP, written by Federico Mena Quintero.
See the GIMP distribution for more information.)
"""
from __future__ import annotations
from math import log, pi, sin, sqrt
from ._binary import o8
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import IO
EPSILON = 1e-10
"""""" # Enable auto-doc for data member
def linear(middle: float, pos: float) -> float:
if pos <= middle:
if middle < EPSILON:
return 0.0
else:
return 0.5 * pos / middle
else:
pos = pos - middle
middle = 1.0 - middle
if middle < EPSILON:
return 1.0
else:
return 0.5 + 0.5 * pos / middle
def curved(middle: float, pos: float) -> float:
return pos ** (log(0.5) / log(max(middle, EPSILON)))
def sine(middle: float, pos: float) -> float:
return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0
def sphere_increasing(middle: float, pos: float) -> float:
return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2)
def sphere_decreasing(middle: float, pos: float) -> float:
return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2)
SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing]
"""""" # Enable auto-doc for data member
class GradientFile:
gradient: (
list[
tuple[
float,
float,
float,
list[float],
list[float],
Callable[[float, float], float],
]
]
| None
) = None
def getpalette(self, entries: int = 256) -> tuple[bytes, str]:
assert self.gradient is not None
palette = []
ix = 0
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
for i in range(entries):
x = i / (entries - 1)
while x1 < x:
ix += 1
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
w = x1 - x0
if w < EPSILON:
scale = segment(0.5, 0.5)
else:
scale = segment((xm - x0) / w, (x - x0) / w)
# expand to RGBA
r = o8(int(255 * ((rgb1[0] - rgb0[0]) * scale + rgb0[0]) + 0.5))
g = o8(int(255 * ((rgb1[1] - rgb0[1]) * scale + rgb0[1]) + 0.5))
b = o8(int(255 * ((rgb1[2] - rgb0[2]) * scale + rgb0[2]) + 0.5))
a = o8(int(255 * ((rgb1[3] - rgb0[3]) * scale + rgb0[3]) + 0.5))
# add to palette
palette.append(r + g + b + a)
return b"".join(palette), "RGBA"
class GimpGradientFile(GradientFile):
"""File handler for GIMP's gradient format."""
def __init__(self, fp: IO[bytes]) -> None:
if not fp.readline().startswith(b"GIMP Gradient"):
msg = "not a GIMP gradient file"
raise SyntaxError(msg)
line = fp.readline()
# GIMP 1.2 gradient files don't contain a name, but GIMP 1.3 files do
if line.startswith(b"Name: "):
line = fp.readline().strip()
count = int(line)
self.gradient = []
for i in range(count):
s = fp.readline().split()
w = [float(x) for x in s[:11]]
x0, x1 = w[0], w[2]
xm = w[1]
rgb0 = w[3:7]
rgb1 = w[7:11]
segment = SEGMENTS[int(s[11])]
cspace = int(s[12])
if cspace != 0:
msg = "cannot handle HSV colour space"
raise OSError(msg)
self.gradient.append((x0, x1, xm, rgb0, rgb1, segment))

View File

@@ -1,75 +0,0 @@
#
# Python Imaging Library
# $Id$
#
# stuff to read GIMP palette files
#
# History:
# 1997-08-23 fl Created
# 2004-09-07 fl Support GIMP 2.0 palette files.
#
# Copyright (c) Secret Labs AB 1997-2004. All rights reserved.
# Copyright (c) Fredrik Lundh 1997-2004.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import re
from io import BytesIO
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import IO
class GimpPaletteFile:
"""File handler for GIMP's palette format."""
rawmode = "RGB"
def _read(self, fp: IO[bytes], limit: bool = True) -> None:
if not fp.readline().startswith(b"GIMP Palette"):
msg = "not a GIMP palette file"
raise SyntaxError(msg)
palette: list[int] = []
i = 0
while True:
if limit and i == 256 + 3:
break
i += 1
s = fp.readline()
if not s:
break
# skip fields and comment lines
if re.match(rb"\w+:|#", s):
continue
if limit and len(s) > 100:
msg = "bad palette file"
raise SyntaxError(msg)
v = s.split(maxsplit=3)
if len(v) < 3:
msg = "bad palette entry"
raise ValueError(msg)
palette += (int(v[i]) for i in range(3))
if limit and len(palette) == 768:
break
self.palette = bytes(palette)
def __init__(self, fp: IO[bytes]) -> None:
self._read(fp)
@classmethod
def frombytes(cls, data: bytes) -> GimpPaletteFile:
self = cls.__new__(cls)
self._read(BytesIO(data), False)
return self
def getpalette(self) -> tuple[bytes, str]:
return self.palette, self.rawmode

View File

@@ -1,72 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# GRIB stub adapter
#
# Copyright (c) 1996-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import IO
from . import Image, ImageFile
_handler = None
def register_handler(handler: ImageFile.StubHandler | None) -> None:
"""
Install application-specific GRIB image handler.
:param handler: Handler object.
"""
global _handler
_handler = handler
# --------------------------------------------------------------------
# Image adapter
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 8 and prefix.startswith(b"GRIB") and prefix[7] == 1
class GribStubImageFile(ImageFile.StubImageFile):
format = "GRIB"
format_description = "GRIB"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(8)):
msg = "Not a GRIB file"
raise SyntaxError(msg)
self.fp.seek(-8, os.SEEK_CUR)
# make something up
self._mode = "F"
self._size = 1, 1
def _load(self) -> ImageFile.StubHandler | None:
return _handler
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if _handler is None or not hasattr(_handler, "save"):
msg = "GRIB save handler not installed"
raise OSError(msg)
_handler.save(im, fp, filename)
# --------------------------------------------------------------------
# Registry
Image.register_open(GribStubImageFile.format, GribStubImageFile, _accept)
Image.register_save(GribStubImageFile.format, _save)
Image.register_extension(GribStubImageFile.format, ".grib")

View File

@@ -1,72 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# HDF5 stub adapter
#
# Copyright (c) 2000-2003 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import IO
from . import Image, ImageFile
_handler = None
def register_handler(handler: ImageFile.StubHandler | None) -> None:
"""
Install application-specific HDF5 image handler.
:param handler: Handler object.
"""
global _handler
_handler = handler
# --------------------------------------------------------------------
# Image adapter
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\x89HDF\r\n\x1a\n")
class HDF5StubImageFile(ImageFile.StubImageFile):
format = "HDF5"
format_description = "HDF5"
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(8)):
msg = "Not an HDF file"
raise SyntaxError(msg)
self.fp.seek(-8, os.SEEK_CUR)
# make something up
self._mode = "F"
self._size = 1, 1
def _load(self) -> ImageFile.StubHandler | None:
return _handler
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if _handler is None or not hasattr(_handler, "save"):
msg = "HDF5 save handler not installed"
raise OSError(msg)
_handler.save(im, fp, filename)
# --------------------------------------------------------------------
# Registry
Image.register_open(HDF5StubImageFile.format, HDF5StubImageFile, _accept)
Image.register_save(HDF5StubImageFile.format, _save)
Image.register_extensions(HDF5StubImageFile.format, [".h5", ".hdf"])

View File

@@ -1,401 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# macOS icns file decoder, based on icns.py by Bob Ippolito.
#
# history:
# 2004-10-09 fl Turned into a PIL plugin; removed 2.3 dependencies.
# 2020-04-04 Allow saving on all operating systems.
#
# Copyright (c) 2004 by Bob Ippolito.
# Copyright (c) 2004 by Secret Labs.
# Copyright (c) 2004 by Fredrik Lundh.
# Copyright (c) 2014 by Alastair Houghton.
# Copyright (c) 2020 by Pan Jing.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
import os
import struct
import sys
from typing import IO
from . import Image, ImageFile, PngImagePlugin, features
enable_jpeg2k = features.check_codec("jpg_2000")
if enable_jpeg2k:
from . import Jpeg2KImagePlugin
MAGIC = b"icns"
HEADERSIZE = 8
def nextheader(fobj: IO[bytes]) -> tuple[bytes, int]:
return struct.unpack(">4sI", fobj.read(HEADERSIZE))
def read_32t(
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
) -> dict[str, Image.Image]:
# The 128x128 icon seems to have an extra header for some reason.
start, length = start_length
fobj.seek(start)
sig = fobj.read(4)
if sig != b"\x00\x00\x00\x00":
msg = "Unknown signature, expecting 0x00000000"
raise SyntaxError(msg)
return read_32(fobj, (start + 4, length - 4), size)
def read_32(
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
) -> dict[str, Image.Image]:
"""
Read a 32bit RGB icon resource. Seems to be either uncompressed or
an RLE packbits-like scheme.
"""
start, length = start_length
fobj.seek(start)
pixel_size = (size[0] * size[2], size[1] * size[2])
sizesq = pixel_size[0] * pixel_size[1]
if length == sizesq * 3:
# uncompressed ("RGBRGBGB")
indata = fobj.read(length)
im = Image.frombuffer("RGB", pixel_size, indata, "raw", "RGB", 0, 1)
else:
# decode image
im = Image.new("RGB", pixel_size, None)
for band_ix in range(3):
data = []
bytesleft = sizesq
while bytesleft > 0:
byte = fobj.read(1)
if not byte:
break
byte_int = byte[0]
if byte_int & 0x80:
blocksize = byte_int - 125
byte = fobj.read(1)
data.extend([byte] * blocksize)
else:
blocksize = byte_int + 1
data.append(fobj.read(blocksize))
bytesleft -= blocksize
if bytesleft <= 0:
break
if bytesleft != 0:
msg = f"Error reading channel [{repr(bytesleft)} left]"
raise SyntaxError(msg)
band = Image.frombuffer("L", pixel_size, b"".join(data), "raw", "L", 0, 1)
im.im.putband(band.im, band_ix)
return {"RGB": im}
def read_mk(
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
) -> dict[str, Image.Image]:
# Alpha masks seem to be uncompressed
start = start_length[0]
fobj.seek(start)
pixel_size = (size[0] * size[2], size[1] * size[2])
sizesq = pixel_size[0] * pixel_size[1]
band = Image.frombuffer("L", pixel_size, fobj.read(sizesq), "raw", "L", 0, 1)
return {"A": band}
def read_png_or_jpeg2000(
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
) -> dict[str, Image.Image]:
start, length = start_length
fobj.seek(start)
sig = fobj.read(12)
im: Image.Image
if sig.startswith(b"\x89PNG\x0d\x0a\x1a\x0a"):
fobj.seek(start)
im = PngImagePlugin.PngImageFile(fobj)
Image._decompression_bomb_check(im.size)
return {"RGBA": im}
elif (
sig.startswith((b"\xff\x4f\xff\x51", b"\x0d\x0a\x87\x0a"))
or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"
):
if not enable_jpeg2k:
msg = (
"Unsupported icon subimage format (rebuild PIL "
"with JPEG 2000 support to fix this)"
)
raise ValueError(msg)
# j2k, jpc or j2c
fobj.seek(start)
jp2kstream = fobj.read(length)
f = io.BytesIO(jp2kstream)
im = Jpeg2KImagePlugin.Jpeg2KImageFile(f)
Image._decompression_bomb_check(im.size)
if im.mode != "RGBA":
im = im.convert("RGBA")
return {"RGBA": im}
else:
msg = "Unsupported icon subimage format"
raise ValueError(msg)
class IcnsFile:
SIZES = {
(512, 512, 2): [(b"ic10", read_png_or_jpeg2000)],
(512, 512, 1): [(b"ic09", read_png_or_jpeg2000)],
(256, 256, 2): [(b"ic14", read_png_or_jpeg2000)],
(256, 256, 1): [(b"ic08", read_png_or_jpeg2000)],
(128, 128, 2): [(b"ic13", read_png_or_jpeg2000)],
(128, 128, 1): [
(b"ic07", read_png_or_jpeg2000),
(b"it32", read_32t),
(b"t8mk", read_mk),
],
(64, 64, 1): [(b"icp6", read_png_or_jpeg2000)],
(32, 32, 2): [(b"ic12", read_png_or_jpeg2000)],
(48, 48, 1): [(b"ih32", read_32), (b"h8mk", read_mk)],
(32, 32, 1): [
(b"icp5", read_png_or_jpeg2000),
(b"il32", read_32),
(b"l8mk", read_mk),
],
(16, 16, 2): [(b"ic11", read_png_or_jpeg2000)],
(16, 16, 1): [
(b"icp4", read_png_or_jpeg2000),
(b"is32", read_32),
(b"s8mk", read_mk),
],
}
def __init__(self, fobj: IO[bytes]) -> None:
"""
fobj is a file-like object as an icns resource
"""
# signature : (start, length)
self.dct = {}
self.fobj = fobj
sig, filesize = nextheader(fobj)
if not _accept(sig):
msg = "not an icns file"
raise SyntaxError(msg)
i = HEADERSIZE
while i < filesize:
sig, blocksize = nextheader(fobj)
if blocksize <= 0:
msg = "invalid block header"
raise SyntaxError(msg)
i += HEADERSIZE
blocksize -= HEADERSIZE
self.dct[sig] = (i, blocksize)
fobj.seek(blocksize, io.SEEK_CUR)
i += blocksize
def itersizes(self) -> list[tuple[int, int, int]]:
sizes = []
for size, fmts in self.SIZES.items():
for fmt, reader in fmts:
if fmt in self.dct:
sizes.append(size)
break
return sizes
def bestsize(self) -> tuple[int, int, int]:
sizes = self.itersizes()
if not sizes:
msg = "No 32bit icon resources found"
raise SyntaxError(msg)
return max(sizes)
def dataforsize(self, size: tuple[int, int, int]) -> dict[str, Image.Image]:
"""
Get an icon resource as {channel: array}. Note that
the arrays are bottom-up like windows bitmaps and will likely
need to be flipped or transposed in some way.
"""
dct = {}
for code, reader in self.SIZES[size]:
desc = self.dct.get(code)
if desc is not None:
dct.update(reader(self.fobj, desc, size))
return dct
def getimage(
self, size: tuple[int, int] | tuple[int, int, int] | None = None
) -> Image.Image:
if size is None:
size = self.bestsize()
elif len(size) == 2:
size = (size[0], size[1], 1)
channels = self.dataforsize(size)
im = channels.get("RGBA")
if im:
return im
im = channels["RGB"].copy()
try:
im.putalpha(channels["A"])
except KeyError:
pass
return im
##
# Image plugin for Mac OS icons.
class IcnsImageFile(ImageFile.ImageFile):
"""
PIL image support for Mac OS .icns files.
Chooses the best resolution, but will possibly load
a different size image if you mutate the size attribute
before calling 'load'.
The info dictionary has a key 'sizes' that is a list
of sizes that the icns file has.
"""
format = "ICNS"
format_description = "Mac OS icns resource"
def _open(self) -> None:
assert self.fp is not None
self.icns = IcnsFile(self.fp)
self._mode = "RGBA"
self.info["sizes"] = self.icns.itersizes()
self.best_size = self.icns.bestsize()
self.size = (
self.best_size[0] * self.best_size[2],
self.best_size[1] * self.best_size[2],
)
@property
def size(self) -> tuple[int, int]:
return self._size
@size.setter
def size(self, value: tuple[int, int]) -> None:
# Check that a matching size exists,
# or that there is a scale that would create a size that matches
for size in self.info["sizes"]:
simple_size = size[0] * size[2], size[1] * size[2]
scale = simple_size[0] // value[0]
if simple_size[1] / value[1] == scale:
self._size = value
return
msg = "This is not one of the allowed sizes of this image"
raise ValueError(msg)
def load(self, scale: int | None = None) -> Image.core.PixelAccess | None:
if scale is not None:
width, height = self.size[:2]
self.size = width * scale, height * scale
self.best_size = width, height, scale
px = Image.Image.load(self)
if self._im is not None and self.im.size == self.size:
# Already loaded
return px
self.load_prepare()
# This is likely NOT the best way to do it, but whatever.
im = self.icns.getimage(self.best_size)
# If this is a PNG or JPEG 2000, it won't be loaded yet
px = im.load()
self.im = im.im
self._mode = im.mode
self.size = im.size
return px
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
"""
Saves the image as a series of PNG files,
that are then combined into a .icns file.
"""
if hasattr(fp, "flush"):
fp.flush()
sizes = {
b"ic07": 128,
b"ic08": 256,
b"ic09": 512,
b"ic10": 1024,
b"ic11": 32,
b"ic12": 64,
b"ic13": 256,
b"ic14": 512,
}
provided_images = {im.width: im for im in im.encoderinfo.get("append_images", [])}
size_streams = {}
for size in set(sizes.values()):
image = (
provided_images[size]
if size in provided_images
else im.resize((size, size))
)
temp = io.BytesIO()
image.save(temp, "png")
size_streams[size] = temp.getvalue()
entries = []
for type, size in sizes.items():
stream = size_streams[size]
entries.append((type, HEADERSIZE + len(stream), stream))
# Header
fp.write(MAGIC)
file_length = HEADERSIZE # Header
file_length += HEADERSIZE + 8 * len(entries) # TOC
file_length += sum(entry[1] for entry in entries)
fp.write(struct.pack(">i", file_length))
# TOC
fp.write(b"TOC ")
fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE))
for entry in entries:
fp.write(entry[0])
fp.write(struct.pack(">i", entry[1]))
# Data
for entry in entries:
fp.write(entry[0])
fp.write(struct.pack(">i", entry[1]))
fp.write(entry[2])
if hasattr(fp, "flush"):
fp.flush()
def _accept(prefix: bytes) -> bool:
return prefix.startswith(MAGIC)
Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept)
Image.register_extension(IcnsImageFile.format, ".icns")
Image.register_save(IcnsImageFile.format, _save)
Image.register_mime(IcnsImageFile.format, "image/icns")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Syntax: python3 IcnsImagePlugin.py [file]")
sys.exit()
with open(sys.argv[1], "rb") as fp:
imf = IcnsImageFile(fp)
for size in imf.info["sizes"]:
width, height, scale = imf.size = size
imf.save(f"out-{width}-{height}-{scale}.png")
with Image.open(sys.argv[1]) as im:
im.save("out.png")
if sys.platform == "windows":
os.startfile("out.png")

View File

@@ -1,396 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# Windows Icon support for PIL
#
# History:
# 96-05-27 fl Created
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1996.
#
# See the README file for information on usage and redistribution.
#
# This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis
# <casadebender@gmail.com>.
# https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
#
# Copyright 2008 Bryan Davis
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Icon format references:
# * https://en.wikipedia.org/wiki/ICO_(file_format)
# * https://msdn.microsoft.com/en-us/library/ms997538.aspx
from __future__ import annotations
import warnings
from io import BytesIO
from math import ceil, log
from typing import IO, NamedTuple
from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin
from ._binary import i16le as i16
from ._binary import i32le as i32
from ._binary import o8
from ._binary import o16le as o16
from ._binary import o32le as o32
#
# --------------------------------------------------------------------
_MAGIC = b"\0\0\1\0"
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
fp.write(_MAGIC) # (2+2)
bmp = im.encoderinfo.get("bitmap_format") == "bmp"
sizes = im.encoderinfo.get(
"sizes",
[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
)
frames = []
provided_ims = [im] + im.encoderinfo.get("append_images", [])
width, height = im.size
for size in sorted(set(sizes)):
if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256:
continue
for provided_im in provided_ims:
if provided_im.size != size:
continue
frames.append(provided_im)
if bmp:
bits = BmpImagePlugin.SAVE[provided_im.mode][1]
bits_used = [bits]
for other_im in provided_ims:
if other_im.size != size:
continue
bits = BmpImagePlugin.SAVE[other_im.mode][1]
if bits not in bits_used:
# Another image has been supplied for this size
# with a different bit depth
frames.append(other_im)
bits_used.append(bits)
break
else:
# TODO: invent a more convenient method for proportional scalings
frame = provided_im.copy()
frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None)
frames.append(frame)
fp.write(o16(len(frames))) # idCount(2)
offset = fp.tell() + len(frames) * 16
for frame in frames:
width, height = frame.size
# 0 means 256
fp.write(o8(width if width < 256 else 0)) # bWidth(1)
fp.write(o8(height if height < 256 else 0)) # bHeight(1)
bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0)
fp.write(o8(colors)) # bColorCount(1)
fp.write(b"\0") # bReserved(1)
fp.write(b"\0\0") # wPlanes(2)
fp.write(o16(bits)) # wBitCount(2)
image_io = BytesIO()
if bmp:
frame.save(image_io, "dib")
if bits != 32:
and_mask = Image.new("1", size)
ImageFile._save(
and_mask,
image_io,
[ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
)
else:
frame.save(image_io, "png")
image_io.seek(0)
image_bytes = image_io.read()
if bmp:
image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
bytes_len = len(image_bytes)
fp.write(o32(bytes_len)) # dwBytesInRes(4)
fp.write(o32(offset)) # dwImageOffset(4)
current = fp.tell()
fp.seek(offset)
fp.write(image_bytes)
offset = offset + bytes_len
fp.seek(current)
def _accept(prefix: bytes) -> bool:
return prefix.startswith(_MAGIC)
class IconHeader(NamedTuple):
width: int
height: int
nb_color: int
reserved: int
planes: int
bpp: int
size: int
offset: int
dim: tuple[int, int]
square: int
color_depth: int
class IcoFile:
def __init__(self, buf: IO[bytes]) -> None:
"""
Parse image from file-like object containing ico file data
"""
# check magic
s = buf.read(6)
if not _accept(s):
msg = "not an ICO file"
raise SyntaxError(msg)
self.buf = buf
self.entry = []
# Number of items in file
self.nb_items = i16(s, 4)
# Get headers for each item
for i in range(self.nb_items):
s = buf.read(16)
# See Wikipedia
width = s[0] or 256
height = s[1] or 256
# No. of colors in image (0 if >=8bpp)
nb_color = s[2]
bpp = i16(s, 6)
icon_header = IconHeader(
width=width,
height=height,
nb_color=nb_color,
reserved=s[3],
planes=i16(s, 4),
bpp=i16(s, 6),
size=i32(s, 8),
offset=i32(s, 12),
dim=(width, height),
square=width * height,
# See Wikipedia notes about color depth.
# We need this just to differ images with equal sizes
color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256,
)
self.entry.append(icon_header)
self.entry = sorted(self.entry, key=lambda x: x.color_depth)
# ICO images are usually squares
self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
def sizes(self) -> set[tuple[int, int]]:
"""
Get a set of all available icon sizes and color depths.
"""
return {(h.width, h.height) for h in self.entry}
def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int:
for i, h in enumerate(self.entry):
if size == h.dim and (bpp is False or bpp == h.color_depth):
return i
return 0
def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image:
"""
Get an image from the icon
"""
return self.frame(self.getentryindex(size, bpp))
def frame(self, idx: int) -> Image.Image:
"""
Get an image from frame idx
"""
header = self.entry[idx]
self.buf.seek(header.offset)
data = self.buf.read(8)
self.buf.seek(header.offset)
im: Image.Image
if data[:8] == PngImagePlugin._MAGIC:
# png frame
im = PngImagePlugin.PngImageFile(self.buf)
Image._decompression_bomb_check(im.size)
else:
# XOR + AND mask bmp frame
im = BmpImagePlugin.DibImageFile(self.buf)
Image._decompression_bomb_check(im.size)
# change tile dimension to only encompass XOR image
im._size = (im.size[0], int(im.size[1] / 2))
d, e, o, a = im.tile[0]
im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a)
# figure out where AND mask image starts
if header.bpp == 32:
# 32-bit color depth icon image allows semitransparent areas
# PIL's DIB format ignores transparency bits, recover them.
# The DIB is packed in BGRX byte order where X is the alpha
# channel.
# Back up to start of bmp data
self.buf.seek(o)
# extract every 4th byte (eg. 3,7,11,15,...)
alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4]
# convert to an 8bpp grayscale image
try:
mask = Image.frombuffer(
"L", # 8bpp
im.size, # (w, h)
alpha_bytes, # source chars
"raw", # raw decoder
("L", 0, -1), # 8bpp inverted, unpadded, reversed
)
except ValueError:
if ImageFile.LOAD_TRUNCATED_IMAGES:
mask = None
else:
raise
else:
# get AND image from end of bitmap
w = im.size[0]
if (w % 32) > 0:
# bitmap row data is aligned to word boundaries
w += 32 - (im.size[0] % 32)
# the total mask data is
# padded row size * height / bits per char
total_bytes = int((w * im.size[1]) / 8)
and_mask_offset = header.offset + header.size - total_bytes
self.buf.seek(and_mask_offset)
mask_data = self.buf.read(total_bytes)
# convert raw data to image
try:
mask = Image.frombuffer(
"1", # 1 bpp
im.size, # (w, h)
mask_data, # source chars
"raw", # raw decoder
("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed
)
except ValueError:
if ImageFile.LOAD_TRUNCATED_IMAGES:
mask = None
else:
raise
# now we have two images, im is XOR image and mask is AND image
# apply mask image as alpha channel
if mask:
im = im.convert("RGBA")
im.putalpha(mask)
return im
##
# Image plugin for Windows Icon files.
class IcoImageFile(ImageFile.ImageFile):
"""
PIL read-only image support for Microsoft Windows .ico files.
By default the largest resolution image in the file will be loaded. This
can be changed by altering the 'size' attribute before calling 'load'.
The info dictionary has a key 'sizes' that is a list of the sizes available
in the icon file.
Handles classic, XP and Vista icon formats.
When saving, PNG compression is used. Support for this was only added in
Windows Vista. If you are unable to view the icon in Windows, convert the
image to "RGBA" mode before saving.
This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis
<casadebender@gmail.com>.
https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
"""
format = "ICO"
format_description = "Windows Icon"
def _open(self) -> None:
assert self.fp is not None
self.ico = IcoFile(self.fp)
self.info["sizes"] = self.ico.sizes()
self.size = self.ico.entry[0].dim
self.load()
@property
def size(self) -> tuple[int, int]:
return self._size
@size.setter
def size(self, value: tuple[int, int]) -> None:
if value not in self.info["sizes"]:
msg = "This is not one of the allowed sizes of this image"
raise ValueError(msg)
self._size = value
def load(self) -> Image.core.PixelAccess | None:
if self._im is not None and self.im.size == self.size:
# Already loaded
return Image.Image.load(self)
im = self.ico.getimage(self.size)
# if tile is PNG, it won't really be loaded yet
im.load()
self.im = im.im
self._mode = im.mode
if im.palette:
self.palette = im.palette
if im.size != self.size:
warnings.warn("Image was not the expected size")
index = self.ico.getentryindex(self.size)
sizes = list(self.info["sizes"])
sizes[index] = im.size
self.info["sizes"] = set(sizes)
self.size = im.size
return Image.Image.load(self)
def load_seek(self, pos: int) -> None:
# Flag the ImageFile.Parser so that it
# just does all the decode at the end.
pass
#
# --------------------------------------------------------------------
Image.register_open(IcoImageFile.format, IcoImageFile, _accept)
Image.register_save(IcoImageFile.format, _save)
Image.register_extension(IcoImageFile.format, ".ico")
Image.register_mime(IcoImageFile.format, "image/x-icon")

View File

@@ -1,390 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# IFUNC IM file handling for PIL
#
# history:
# 1995-09-01 fl Created.
# 1997-01-03 fl Save palette images
# 1997-01-08 fl Added sequence support
# 1997-01-23 fl Added P and RGB save support
# 1997-05-31 fl Read floating point images
# 1997-06-22 fl Save floating point images
# 1997-08-27 fl Read and save 1-bit images
# 1998-06-25 fl Added support for RGB+LUT images
# 1998-07-02 fl Added support for YCC images
# 1998-07-15 fl Renamed offset attribute to avoid name clash
# 1998-12-29 fl Added I;16 support
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.7)
# 2003-09-26 fl Added LA/PA support
#
# Copyright (c) 1997-2003 by Secret Labs AB.
# Copyright (c) 1995-2001 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
import re
from typing import IO, Any
from . import Image, ImageFile, ImagePalette
from ._util import DeferredError
# --------------------------------------------------------------------
# Standard tags
COMMENT = "Comment"
DATE = "Date"
EQUIPMENT = "Digitalization equipment"
FRAMES = "File size (no of images)"
LUT = "Lut"
NAME = "Name"
SCALE = "Scale (x,y)"
SIZE = "Image size (x*y)"
MODE = "Image type"
TAGS = {
COMMENT: 0,
DATE: 0,
EQUIPMENT: 0,
FRAMES: 0,
LUT: 0,
NAME: 0,
SCALE: 0,
SIZE: 0,
MODE: 0,
}
OPEN = {
# ifunc93/p3cfunc formats
"0 1 image": ("1", "1"),
"L 1 image": ("1", "1"),
"Greyscale image": ("L", "L"),
"Grayscale image": ("L", "L"),
"RGB image": ("RGB", "RGB;L"),
"RLB image": ("RGB", "RLB"),
"RYB image": ("RGB", "RLB"),
"B1 image": ("1", "1"),
"B2 image": ("P", "P;2"),
"B4 image": ("P", "P;4"),
"X 24 image": ("RGB", "RGB"),
"L 32 S image": ("I", "I;32"),
"L 32 F image": ("F", "F;32"),
# old p3cfunc formats
"RGB3 image": ("RGB", "RGB;T"),
"RYB3 image": ("RGB", "RYB;T"),
# extensions
"LA image": ("LA", "LA;L"),
"PA image": ("LA", "PA;L"),
"RGBA image": ("RGBA", "RGBA;L"),
"RGBX image": ("RGB", "RGBX;L"),
"CMYK image": ("CMYK", "CMYK;L"),
"YCC image": ("YCbCr", "YCbCr;L"),
}
# ifunc95 extensions
for i in ["8", "8S", "16", "16S", "32", "32F"]:
OPEN[f"L {i} image"] = ("F", f"F;{i}")
OPEN[f"L*{i} image"] = ("F", f"F;{i}")
for i in ["16", "16L", "16B"]:
OPEN[f"L {i} image"] = (f"I;{i}", f"I;{i}")
OPEN[f"L*{i} image"] = (f"I;{i}", f"I;{i}")
for i in ["32S"]:
OPEN[f"L {i} image"] = ("I", f"I;{i}")
OPEN[f"L*{i} image"] = ("I", f"I;{i}")
for j in range(2, 33):
OPEN[f"L*{j} image"] = ("F", f"F;{j}")
# --------------------------------------------------------------------
# Read IM directory
split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$")
def number(s: Any) -> float:
try:
return int(s)
except ValueError:
return float(s)
##
# Image plugin for the IFUNC IM file format.
class ImImageFile(ImageFile.ImageFile):
format = "IM"
format_description = "IFUNC Image Memory"
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
# Quick rejection: if there's not an LF among the first
# 100 bytes, this is (probably) not a text header.
assert self.fp is not None
if b"\n" not in self.fp.read(100):
msg = "not an IM file"
raise SyntaxError(msg)
self.fp.seek(0)
n = 0
# Default values
self.info[MODE] = "L"
self.info[SIZE] = (512, 512)
self.info[FRAMES] = 1
self.rawmode = "L"
while True:
s = self.fp.read(1)
# Some versions of IFUNC uses \n\r instead of \r\n...
if s == b"\r":
continue
if not s or s == b"\0" or s == b"\x1a":
break
# FIXME: this may read whole file if not a text file
s = s + self.fp.readline()
if len(s) > 100:
msg = "not an IM file"
raise SyntaxError(msg)
if s.endswith(b"\r\n"):
s = s[:-2]
elif s.endswith(b"\n"):
s = s[:-1]
try:
m = split.match(s)
except re.error as e:
msg = "not an IM file"
raise SyntaxError(msg) from e
if m:
k, v = m.group(1, 2)
# Don't know if this is the correct encoding,
# but a decent guess (I guess)
k = k.decode("latin-1", "replace")
v = v.decode("latin-1", "replace")
# Convert value as appropriate
if k in [FRAMES, SCALE, SIZE]:
v = v.replace("*", ",")
v = tuple(map(number, v.split(",")))
if len(v) == 1:
v = v[0]
elif k == MODE and v in OPEN:
v, self.rawmode = OPEN[v]
# Add to dictionary. Note that COMMENT tags are
# combined into a list of strings.
if k == COMMENT:
if k in self.info:
self.info[k].append(v)
else:
self.info[k] = [v]
else:
self.info[k] = v
if k in TAGS:
n += 1
else:
msg = f"Syntax error in IM header: {s.decode('ascii', 'replace')}"
raise SyntaxError(msg)
if not n:
msg = "Not an IM file"
raise SyntaxError(msg)
# Basic attributes
self._size = self.info[SIZE]
self._mode = self.info[MODE]
# Skip forward to start of image data
while s and not s.startswith(b"\x1a"):
s = self.fp.read(1)
if not s:
msg = "File truncated"
raise SyntaxError(msg)
if LUT in self.info:
# convert lookup table to palette or lut attribute
palette = self.fp.read(768)
greyscale = 1 # greyscale palette
linear = 1 # linear greyscale palette
for i in range(256):
if palette[i] == palette[i + 256] == palette[i + 512]:
if palette[i] != i:
linear = 0
else:
greyscale = 0
if self.mode in ["L", "LA", "P", "PA"]:
if greyscale:
if not linear:
self.lut = list(palette[:256])
else:
if self.mode in ["L", "P"]:
self._mode = self.rawmode = "P"
elif self.mode in ["LA", "PA"]:
self._mode = "PA"
self.rawmode = "PA;L"
self.palette = ImagePalette.raw("RGB;L", palette)
elif self.mode == "RGB":
if not greyscale or not linear:
self.lut = list(palette)
self.frame = 0
self.__offset = offs = self.fp.tell()
self._fp = self.fp # FIXME: hack
if self.rawmode.startswith("F;"):
# ifunc95 formats
try:
# use bit decoder (if necessary)
bits = int(self.rawmode[2:])
if bits not in [8, 16, 32]:
self.tile = [
ImageFile._Tile(
"bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1)
)
]
return
except ValueError:
pass
if self.rawmode in ["RGB;T", "RYB;T"]:
# Old LabEye/3PC files. Would be very surprised if anyone
# ever stumbled upon such a file ;-)
size = self.size[0] * self.size[1]
self.tile = [
ImageFile._Tile("raw", (0, 0) + self.size, offs, ("G", 0, -1)),
ImageFile._Tile("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)),
ImageFile._Tile(
"raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1)
),
]
else:
# LabEye/IFUNC files
self.tile = [
ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))
]
@property
def n_frames(self) -> int:
return self.info[FRAMES]
@property
def is_animated(self) -> bool:
return self.info[FRAMES] > 1
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self.frame = frame
if self.mode == "1":
bits = 1
else:
bits = 8 * len(self.mode)
size = ((self.size[0] * bits + 7) // 8) * self.size[1]
offs = self.__offset + frame * size
self.fp = self._fp
self.tile = [
ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))
]
def tell(self) -> int:
return self.frame
#
# --------------------------------------------------------------------
# Save IM files
SAVE = {
# mode: (im type, raw mode)
"1": ("0 1", "1"),
"L": ("Greyscale", "L"),
"LA": ("LA", "LA;L"),
"P": ("Greyscale", "P"),
"PA": ("LA", "PA;L"),
"I": ("L 32S", "I;32S"),
"I;16": ("L 16", "I;16"),
"I;16L": ("L 16L", "I;16L"),
"I;16B": ("L 16B", "I;16B"),
"F": ("L 32F", "F;32F"),
"RGB": ("RGB", "RGB;L"),
"RGBA": ("RGBA", "RGBA;L"),
"RGBX": ("RGBX", "RGBX;L"),
"CMYK": ("CMYK", "CMYK;L"),
"YCbCr": ("YCC", "YCbCr;L"),
}
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
try:
image_type, rawmode = SAVE[im.mode]
except KeyError as e:
msg = f"Cannot save {im.mode} images as IM"
raise ValueError(msg) from e
frames = im.encoderinfo.get("frames", 1)
fp.write(f"Image type: {image_type} image\r\n".encode("ascii"))
if filename:
# Each line must be 100 characters or less,
# or: SyntaxError("not an IM file")
# 8 characters are used for "Name: " and "\r\n"
# Keep just the filename, ditch the potentially overlong path
if isinstance(filename, bytes):
filename = filename.decode("ascii")
name, ext = os.path.splitext(os.path.basename(filename))
name = "".join([name[: 92 - len(ext)], ext])
fp.write(f"Name: {name}\r\n".encode("ascii"))
fp.write(f"Image size (x*y): {im.size[0]}*{im.size[1]}\r\n".encode("ascii"))
fp.write(f"File size (no of images): {frames}\r\n".encode("ascii"))
if im.mode in ["P", "PA"]:
fp.write(b"Lut: 1\r\n")
fp.write(b"\000" * (511 - fp.tell()) + b"\032")
if im.mode in ["P", "PA"]:
im_palette = im.im.getpalette("RGB", "RGB;L")
colors = len(im_palette) // 3
palette = b""
for i in range(3):
palette += im_palette[colors * i : colors * (i + 1)]
palette += b"\x00" * (256 - colors)
fp.write(palette) # 768 bytes
ImageFile._save(
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))]
)
#
# --------------------------------------------------------------------
# Registry
Image.register_open(ImImageFile.format, ImImageFile)
Image.register_save(ImImageFile.format, _save)
Image.register_extension(ImImageFile.format, ".im")

File diff suppressed because it is too large Load Diff

View File

@@ -1,311 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# standard channel operations
#
# History:
# 1996-03-24 fl Created
# 1996-08-13 fl Added logical operations (for "1" images)
# 2000-10-12 fl Added offset method (from Image.py)
#
# Copyright (c) 1997-2000 by Secret Labs AB
# Copyright (c) 1996-2000 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image
def constant(image: Image.Image, value: int) -> Image.Image:
"""Fill a channel with a given gray level.
:rtype: :py:class:`~PIL.Image.Image`
"""
return Image.new("L", image.size, value)
def duplicate(image: Image.Image) -> Image.Image:
"""Copy a channel. Alias for :py:meth:`PIL.Image.Image.copy`.
:rtype: :py:class:`~PIL.Image.Image`
"""
return image.copy()
def invert(image: Image.Image) -> Image.Image:
"""
Invert an image (channel). ::
out = MAX - image
:rtype: :py:class:`~PIL.Image.Image`
"""
image.load()
return image._new(image.im.chop_invert())
def lighter(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""
Compares the two images, pixel by pixel, and returns a new image containing
the lighter values. ::
out = max(image1, image2)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_lighter(image2.im))
def darker(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""
Compares the two images, pixel by pixel, and returns a new image containing
the darker values. ::
out = min(image1, image2)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_darker(image2.im))
def difference(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""
Returns the absolute value of the pixel-by-pixel difference between the two
images. ::
out = abs(image1 - image2)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_difference(image2.im))
def multiply(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""
Superimposes two images on top of each other.
If you multiply an image with a solid black image, the result is black. If
you multiply with a solid white image, the image is unaffected. ::
out = image1 * image2 / MAX
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_multiply(image2.im))
def screen(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""
Superimposes two inverted images on top of each other. ::
out = MAX - ((MAX - image1) * (MAX - image2) / MAX)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_screen(image2.im))
def soft_light(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""
Superimposes two images on top of each other using the Soft Light algorithm
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_soft_light(image2.im))
def hard_light(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""
Superimposes two images on top of each other using the Hard Light algorithm
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_hard_light(image2.im))
def overlay(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""
Superimposes two images on top of each other using the Overlay algorithm
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_overlay(image2.im))
def add(
image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0
) -> Image.Image:
"""
Adds two images, dividing the result by scale and adding the
offset. If omitted, scale defaults to 1.0, and offset to 0.0. ::
out = ((image1 + image2) / scale + offset)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_add(image2.im, scale, offset))
def subtract(
image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0
) -> Image.Image:
"""
Subtracts two images, dividing the result by scale and adding the offset.
If omitted, scale defaults to 1.0, and offset to 0.0. ::
out = ((image1 - image2) / scale + offset)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_subtract(image2.im, scale, offset))
def add_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""Add two images, without clipping the result. ::
out = ((image1 + image2) % MAX)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_add_modulo(image2.im))
def subtract_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""Subtract two images, without clipping the result. ::
out = ((image1 - image2) % MAX)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_subtract_modulo(image2.im))
def logical_and(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""Logical AND between two images.
Both of the images must have mode "1". If you would like to perform a
logical AND on an image with a mode other than "1", try
:py:meth:`~PIL.ImageChops.multiply` instead, using a black-and-white mask
as the second image. ::
out = ((image1 and image2) % MAX)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_and(image2.im))
def logical_or(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""Logical OR between two images.
Both of the images must have mode "1". ::
out = ((image1 or image2) % MAX)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_or(image2.im))
def logical_xor(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""Logical XOR between two images.
Both of the images must have mode "1". ::
out = ((bool(image1) != bool(image2)) % MAX)
:rtype: :py:class:`~PIL.Image.Image`
"""
image1.load()
image2.load()
return image1._new(image1.im.chop_xor(image2.im))
def blend(image1: Image.Image, image2: Image.Image, alpha: float) -> Image.Image:
"""Blend images using constant transparency weight. Alias for
:py:func:`PIL.Image.blend`.
:rtype: :py:class:`~PIL.Image.Image`
"""
return Image.blend(image1, image2, alpha)
def composite(
image1: Image.Image, image2: Image.Image, mask: Image.Image
) -> Image.Image:
"""Create composite using transparency mask. Alias for
:py:func:`PIL.Image.composite`.
:rtype: :py:class:`~PIL.Image.Image`
"""
return Image.composite(image1, image2, mask)
def offset(image: Image.Image, xoffset: int, yoffset: int | None = None) -> Image.Image:
"""Returns a copy of the image where data has been offset by the given
distances. Data wraps around the edges. If ``yoffset`` is omitted, it
is assumed to be equal to ``xoffset``.
:param image: Input image.
:param xoffset: The horizontal distance.
:param yoffset: The vertical distance. If omitted, both
distances are set to the same value.
:rtype: :py:class:`~PIL.Image.Image`
"""
if yoffset is None:
yoffset = xoffset
image.load()
return image._new(image.im.offset(xoffset, yoffset))

File diff suppressed because it is too large Load Diff

View File

@@ -1,320 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# map CSS3-style colour description strings to RGB
#
# History:
# 2002-10-24 fl Added support for CSS-style color strings
# 2002-12-15 fl Added RGBA support
# 2004-03-27 fl Fixed remaining int() problems for Python 1.5.2
# 2004-07-19 fl Fixed gray/grey spelling issues
# 2009-03-05 fl Fixed rounding error in grayscale calculation
#
# Copyright (c) 2002-2004 by Secret Labs AB
# Copyright (c) 2002-2004 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import re
from functools import lru_cache
from . import Image
@lru_cache
def getrgb(color: str) -> tuple[int, int, int] | tuple[int, int, int, int]:
"""
Convert a color string to an RGB or RGBA tuple. If the string cannot be
parsed, this function raises a :py:exc:`ValueError` exception.
.. versionadded:: 1.1.4
:param color: A color string
:return: ``(red, green, blue[, alpha])``
"""
if len(color) > 100:
msg = "color specifier is too long"
raise ValueError(msg)
color = color.lower()
rgb = colormap.get(color, None)
if rgb:
if isinstance(rgb, tuple):
return rgb
rgb_tuple = getrgb(rgb)
assert len(rgb_tuple) == 3
colormap[color] = rgb_tuple
return rgb_tuple
# check for known string formats
if re.match("#[a-f0-9]{3}$", color):
return int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16)
if re.match("#[a-f0-9]{4}$", color):
return (
int(color[1] * 2, 16),
int(color[2] * 2, 16),
int(color[3] * 2, 16),
int(color[4] * 2, 16),
)
if re.match("#[a-f0-9]{6}$", color):
return int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)
if re.match("#[a-f0-9]{8}$", color):
return (
int(color[1:3], 16),
int(color[3:5], 16),
int(color[5:7], 16),
int(color[7:9], 16),
)
m = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color)
if m:
return int(m.group(1)), int(m.group(2)), int(m.group(3))
m = re.match(r"rgb\(\s*(\d+)%\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)$", color)
if m:
return (
int((int(m.group(1)) * 255) / 100.0 + 0.5),
int((int(m.group(2)) * 255) / 100.0 + 0.5),
int((int(m.group(3)) * 255) / 100.0 + 0.5),
)
m = re.match(
r"hsl\(\s*(\d+\.?\d*)\s*,\s*(\d+\.?\d*)%\s*,\s*(\d+\.?\d*)%\s*\)$", color
)
if m:
from colorsys import hls_to_rgb
rgb_floats = hls_to_rgb(
float(m.group(1)) / 360.0,
float(m.group(3)) / 100.0,
float(m.group(2)) / 100.0,
)
return (
int(rgb_floats[0] * 255 + 0.5),
int(rgb_floats[1] * 255 + 0.5),
int(rgb_floats[2] * 255 + 0.5),
)
m = re.match(
r"hs[bv]\(\s*(\d+\.?\d*)\s*,\s*(\d+\.?\d*)%\s*,\s*(\d+\.?\d*)%\s*\)$", color
)
if m:
from colorsys import hsv_to_rgb
rgb_floats = hsv_to_rgb(
float(m.group(1)) / 360.0,
float(m.group(2)) / 100.0,
float(m.group(3)) / 100.0,
)
return (
int(rgb_floats[0] * 255 + 0.5),
int(rgb_floats[1] * 255 + 0.5),
int(rgb_floats[2] * 255 + 0.5),
)
m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color)
if m:
return int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
msg = f"unknown color specifier: {repr(color)}"
raise ValueError(msg)
@lru_cache
def getcolor(color: str, mode: str) -> int | tuple[int, ...]:
"""
Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if
``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is
not color or a palette image, converts the RGB value to a grayscale value.
If the string cannot be parsed, this function raises a :py:exc:`ValueError`
exception.
.. versionadded:: 1.1.4
:param color: A color string
:param mode: Convert result to this mode
:return: ``graylevel, (graylevel, alpha) or (red, green, blue[, alpha])``
"""
# same as getrgb, but converts the result to the given mode
rgb, alpha = getrgb(color), 255
if len(rgb) == 4:
alpha = rgb[3]
rgb = rgb[:3]
if mode == "HSV":
from colorsys import rgb_to_hsv
r, g, b = rgb
h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255)
return int(h * 255), int(s * 255), int(v * 255)
elif Image.getmodebase(mode) == "L":
r, g, b = rgb
# ITU-R Recommendation 601-2 for nonlinear RGB
# scaled to 24 bits to match the convert's implementation.
graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16
if mode[-1] == "A":
return graylevel, alpha
return graylevel
elif mode[-1] == "A":
return rgb + (alpha,)
return rgb
colormap: dict[str, str | tuple[int, int, int]] = {
# X11 colour table from https://drafts.csswg.org/css-color-4/, with
# gray/grey spelling issues fixed. This is a superset of HTML 4.0
# colour names used in CSS 1.
"aliceblue": "#f0f8ff",
"antiquewhite": "#faebd7",
"aqua": "#00ffff",
"aquamarine": "#7fffd4",
"azure": "#f0ffff",
"beige": "#f5f5dc",
"bisque": "#ffe4c4",
"black": "#000000",
"blanchedalmond": "#ffebcd",
"blue": "#0000ff",
"blueviolet": "#8a2be2",
"brown": "#a52a2a",
"burlywood": "#deb887",
"cadetblue": "#5f9ea0",
"chartreuse": "#7fff00",
"chocolate": "#d2691e",
"coral": "#ff7f50",
"cornflowerblue": "#6495ed",
"cornsilk": "#fff8dc",
"crimson": "#dc143c",
"cyan": "#00ffff",
"darkblue": "#00008b",
"darkcyan": "#008b8b",
"darkgoldenrod": "#b8860b",
"darkgray": "#a9a9a9",
"darkgrey": "#a9a9a9",
"darkgreen": "#006400",
"darkkhaki": "#bdb76b",
"darkmagenta": "#8b008b",
"darkolivegreen": "#556b2f",
"darkorange": "#ff8c00",
"darkorchid": "#9932cc",
"darkred": "#8b0000",
"darksalmon": "#e9967a",
"darkseagreen": "#8fbc8f",
"darkslateblue": "#483d8b",
"darkslategray": "#2f4f4f",
"darkslategrey": "#2f4f4f",
"darkturquoise": "#00ced1",
"darkviolet": "#9400d3",
"deeppink": "#ff1493",
"deepskyblue": "#00bfff",
"dimgray": "#696969",
"dimgrey": "#696969",
"dodgerblue": "#1e90ff",
"firebrick": "#b22222",
"floralwhite": "#fffaf0",
"forestgreen": "#228b22",
"fuchsia": "#ff00ff",
"gainsboro": "#dcdcdc",
"ghostwhite": "#f8f8ff",
"gold": "#ffd700",
"goldenrod": "#daa520",
"gray": "#808080",
"grey": "#808080",
"green": "#008000",
"greenyellow": "#adff2f",
"honeydew": "#f0fff0",
"hotpink": "#ff69b4",
"indianred": "#cd5c5c",
"indigo": "#4b0082",
"ivory": "#fffff0",
"khaki": "#f0e68c",
"lavender": "#e6e6fa",
"lavenderblush": "#fff0f5",
"lawngreen": "#7cfc00",
"lemonchiffon": "#fffacd",
"lightblue": "#add8e6",
"lightcoral": "#f08080",
"lightcyan": "#e0ffff",
"lightgoldenrodyellow": "#fafad2",
"lightgreen": "#90ee90",
"lightgray": "#d3d3d3",
"lightgrey": "#d3d3d3",
"lightpink": "#ffb6c1",
"lightsalmon": "#ffa07a",
"lightseagreen": "#20b2aa",
"lightskyblue": "#87cefa",
"lightslategray": "#778899",
"lightslategrey": "#778899",
"lightsteelblue": "#b0c4de",
"lightyellow": "#ffffe0",
"lime": "#00ff00",
"limegreen": "#32cd32",
"linen": "#faf0e6",
"magenta": "#ff00ff",
"maroon": "#800000",
"mediumaquamarine": "#66cdaa",
"mediumblue": "#0000cd",
"mediumorchid": "#ba55d3",
"mediumpurple": "#9370db",
"mediumseagreen": "#3cb371",
"mediumslateblue": "#7b68ee",
"mediumspringgreen": "#00fa9a",
"mediumturquoise": "#48d1cc",
"mediumvioletred": "#c71585",
"midnightblue": "#191970",
"mintcream": "#f5fffa",
"mistyrose": "#ffe4e1",
"moccasin": "#ffe4b5",
"navajowhite": "#ffdead",
"navy": "#000080",
"oldlace": "#fdf5e6",
"olive": "#808000",
"olivedrab": "#6b8e23",
"orange": "#ffa500",
"orangered": "#ff4500",
"orchid": "#da70d6",
"palegoldenrod": "#eee8aa",
"palegreen": "#98fb98",
"paleturquoise": "#afeeee",
"palevioletred": "#db7093",
"papayawhip": "#ffefd5",
"peachpuff": "#ffdab9",
"peru": "#cd853f",
"pink": "#ffc0cb",
"plum": "#dda0dd",
"powderblue": "#b0e0e6",
"purple": "#800080",
"rebeccapurple": "#663399",
"red": "#ff0000",
"rosybrown": "#bc8f8f",
"royalblue": "#4169e1",
"saddlebrown": "#8b4513",
"salmon": "#fa8072",
"sandybrown": "#f4a460",
"seagreen": "#2e8b57",
"seashell": "#fff5ee",
"sienna": "#a0522d",
"silver": "#c0c0c0",
"skyblue": "#87ceeb",
"slateblue": "#6a5acd",
"slategray": "#708090",
"slategrey": "#708090",
"snow": "#fffafa",
"springgreen": "#00ff7f",
"steelblue": "#4682b4",
"tan": "#d2b48c",
"teal": "#008080",
"thistle": "#d8bfd8",
"tomato": "#ff6347",
"turquoise": "#40e0d0",
"violet": "#ee82ee",
"wheat": "#f5deb3",
"white": "#ffffff",
"whitesmoke": "#f5f5f5",
"yellow": "#ffff00",
"yellowgreen": "#9acd32",
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,244 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# WCK-style drawing interface operations
#
# History:
# 2003-12-07 fl created
# 2005-05-15 fl updated; added to PIL as ImageDraw2
# 2005-05-15 fl added text support
# 2005-05-20 fl added arc/chord/pieslice support
#
# Copyright (c) 2003-2005 by Secret Labs AB
# Copyright (c) 2003-2005 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
"""
(Experimental) WCK-style drawing interface operations
.. seealso:: :py:mod:`PIL.ImageDraw`
"""
from __future__ import annotations
from typing import Any, AnyStr, BinaryIO
from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath
from ._typing import Coords, StrOrBytesPath
class Pen:
"""Stores an outline color and width."""
def __init__(self, color: str, width: int = 1, opacity: int = 255) -> None:
self.color = ImageColor.getrgb(color)
self.width = width
class Brush:
"""Stores a fill color"""
def __init__(self, color: str, opacity: int = 255) -> None:
self.color = ImageColor.getrgb(color)
class Font:
"""Stores a TrueType font and color"""
def __init__(
self, color: str, file: StrOrBytesPath | BinaryIO, size: float = 12
) -> None:
# FIXME: add support for bitmap fonts
self.color = ImageColor.getrgb(color)
self.font = ImageFont.truetype(file, size)
class Draw:
"""
(Experimental) WCK-style drawing interface
"""
def __init__(
self,
image: Image.Image | str,
size: tuple[int, int] | list[int] | None = None,
color: float | tuple[float, ...] | str | None = None,
) -> None:
if isinstance(image, str):
if size is None:
msg = "If image argument is mode string, size must be a list or tuple"
raise ValueError(msg)
image = Image.new(image, size, color)
self.draw = ImageDraw.Draw(image)
self.image = image
self.transform: tuple[float, float, float, float, float, float] | None = None
def flush(self) -> Image.Image:
return self.image
def render(
self,
op: str,
xy: Coords,
pen: Pen | Brush | None,
brush: Brush | Pen | None = None,
**kwargs: Any,
) -> None:
# handle color arguments
outline = fill = None
width = 1
if isinstance(pen, Pen):
outline = pen.color
width = pen.width
elif isinstance(brush, Pen):
outline = brush.color
width = brush.width
if isinstance(brush, Brush):
fill = brush.color
elif isinstance(pen, Brush):
fill = pen.color
# handle transformation
if self.transform:
path = ImagePath.Path(xy)
path.transform(self.transform)
xy = path
# render the item
if op in ("arc", "line"):
kwargs.setdefault("fill", outline)
else:
kwargs.setdefault("fill", fill)
kwargs.setdefault("outline", outline)
if op == "line":
kwargs.setdefault("width", width)
getattr(self.draw, op)(xy, **kwargs)
def settransform(self, offset: tuple[float, float]) -> None:
"""Sets a transformation offset."""
xoffset, yoffset = offset
self.transform = (1, 0, xoffset, 0, 1, yoffset)
def arc(
self,
xy: Coords,
pen: Pen | Brush | None,
start: float,
end: float,
*options: Any,
) -> None:
"""
Draws an arc (a portion of a circle outline) between the start and end
angles, inside the given bounding box.
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.arc`
"""
self.render("arc", xy, pen, *options, start=start, end=end)
def chord(
self,
xy: Coords,
pen: Pen | Brush | None,
start: float,
end: float,
*options: Any,
) -> None:
"""
Same as :py:meth:`~PIL.ImageDraw2.Draw.arc`, but connects the end points
with a straight line.
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.chord`
"""
self.render("chord", xy, pen, *options, start=start, end=end)
def ellipse(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
"""
Draws an ellipse inside the given bounding box.
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.ellipse`
"""
self.render("ellipse", xy, pen, *options)
def line(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
"""
Draws a line between the coordinates in the ``xy`` list.
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.line`
"""
self.render("line", xy, pen, *options)
def pieslice(
self,
xy: Coords,
pen: Pen | Brush | None,
start: float,
end: float,
*options: Any,
) -> None:
"""
Same as arc, but also draws straight lines between the end points and the
center of the bounding box.
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.pieslice`
"""
self.render("pieslice", xy, pen, *options, start=start, end=end)
def polygon(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
"""
Draws a polygon.
The polygon outline consists of straight lines between the given
coordinates, plus a straight line between the last and the first
coordinate.
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.polygon`
"""
self.render("polygon", xy, pen, *options)
def rectangle(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
"""
Draws a rectangle.
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.rectangle`
"""
self.render("rectangle", xy, pen, *options)
def text(self, xy: tuple[float, float], text: AnyStr, font: Font) -> None:
"""
Draws the string at the given position.
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.text`
"""
if self.transform:
path = ImagePath.Path(xy)
path.transform(self.transform)
xy = path
self.draw.text(xy, text, font=font.font, fill=font.color)
def textbbox(
self, xy: tuple[float, float], text: AnyStr, font: Font
) -> tuple[float, float, float, float]:
"""
Returns bounding box (in pixels) of given text.
:return: ``(left, top, right, bottom)`` bounding box
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textbbox`
"""
if self.transform:
path = ImagePath.Path(xy)
path.transform(self.transform)
xy = path
return self.draw.textbbox(xy, text, font=font.font)
def textlength(self, text: AnyStr, font: Font) -> float:
"""
Returns length (in pixels) of given text.
This is the amount by which following text should be offset.
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textlength`
"""
return self.draw.textlength(text, font=font.font)

View File

@@ -1,113 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# image enhancement classes
#
# For a background, see "Image Processing By Interpolation and
# Extrapolation", Paul Haeberli and Douglas Voorhies. Available
# at http://www.graficaobscura.com/interp/index.html
#
# History:
# 1996-03-23 fl Created
# 2009-06-16 fl Fixed mean calculation
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1996.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image, ImageFilter, ImageStat
class _Enhance:
image: Image.Image
degenerate: Image.Image
def enhance(self, factor: float) -> Image.Image:
"""
Returns an enhanced image.
:param factor: A floating point value controlling the enhancement.
Factor 1.0 always returns a copy of the original image,
lower factors mean less color (brightness, contrast,
etc), and higher values more. There are no restrictions
on this value.
:rtype: :py:class:`~PIL.Image.Image`
"""
return Image.blend(self.degenerate, self.image, factor)
class Color(_Enhance):
"""Adjust image color balance.
This class can be used to adjust the colour balance of an image, in
a manner similar to the controls on a colour TV set. An enhancement
factor of 0.0 gives a black and white image. A factor of 1.0 gives
the original image.
"""
def __init__(self, image: Image.Image) -> None:
self.image = image
self.intermediate_mode = "L"
if "A" in image.getbands():
self.intermediate_mode = "LA"
if self.intermediate_mode != image.mode:
image = image.convert(self.intermediate_mode).convert(image.mode)
self.degenerate = image
class Contrast(_Enhance):
"""Adjust image contrast.
This class can be used to control the contrast of an image, similar
to the contrast control on a TV set. An enhancement factor of 0.0
gives a solid gray image. A factor of 1.0 gives the original image.
"""
def __init__(self, image: Image.Image) -> None:
self.image = image
if image.mode != "L":
image = image.convert("L")
mean = int(ImageStat.Stat(image).mean[0] + 0.5)
self.degenerate = Image.new("L", image.size, mean)
if self.degenerate.mode != self.image.mode:
self.degenerate = self.degenerate.convert(self.image.mode)
if "A" in self.image.getbands():
self.degenerate.putalpha(self.image.getchannel("A"))
class Brightness(_Enhance):
"""Adjust image brightness.
This class can be used to control the brightness of an image. An
enhancement factor of 0.0 gives a black image. A factor of 1.0 gives the
original image.
"""
def __init__(self, image: Image.Image) -> None:
self.image = image
self.degenerate = Image.new(image.mode, image.size, 0)
if "A" in image.getbands():
self.degenerate.putalpha(image.getchannel("A"))
class Sharpness(_Enhance):
"""Adjust image sharpness.
This class can be used to adjust the sharpness of an image. An
enhancement factor of 0.0 gives a blurred image, a factor of 1.0 gives the
original image, and a factor of 2.0 gives a sharpened image.
"""
def __init__(self, image: Image.Image) -> None:
self.image = image
self.degenerate = image.filter(ImageFilter.SMOOTH)
if "A" in image.getbands():
self.degenerate.putalpha(image.getchannel("A"))

View File

@@ -1,935 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# base class for image file handlers
#
# history:
# 1995-09-09 fl Created
# 1996-03-11 fl Fixed load mechanism.
# 1996-04-15 fl Added pcx/xbm decoders.
# 1996-04-30 fl Added encoders.
# 1996-12-14 fl Added load helpers
# 1997-01-11 fl Use encode_to_file where possible
# 1997-08-27 fl Flush output in _save
# 1998-03-05 fl Use memory mapping for some modes
# 1999-02-04 fl Use memory mapping also for "I;16" and "I;16B"
# 1999-05-31 fl Added image parser
# 2000-10-12 fl Set readonly flag on memory-mapped images
# 2002-03-20 fl Use better messages for common decoder errors
# 2003-04-21 fl Fall back on mmap/map_buffer if map is not available
# 2003-10-30 fl Added StubImageFile class
# 2004-02-25 fl Made incremental parser more robust
#
# Copyright (c) 1997-2004 by Secret Labs AB
# Copyright (c) 1995-2004 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import abc
import io
import itertools
import logging
import os
import struct
from typing import IO, Any, NamedTuple, cast
from . import ExifTags, Image
from ._util import DeferredError, is_path
TYPE_CHECKING = False
if TYPE_CHECKING:
from ._typing import StrOrBytesPath
logger = logging.getLogger(__name__)
MAXBLOCK = 65536
"""
By default, Pillow processes image data in blocks. This helps to prevent excessive use
of resources. Codecs may disable this behaviour with ``_pulls_fd`` or ``_pushes_fd``.
When reading an image, this is the number of bytes to read at once.
When writing an image, this is the number of bytes to write at once.
If the image width times 4 is greater, then that will be used instead.
Plugins may also set a greater number.
User code may set this to another number.
"""
SAFEBLOCK = 1024 * 1024
LOAD_TRUNCATED_IMAGES = False
"""Whether or not to load truncated image files. User code may change this."""
ERRORS = {
-1: "image buffer overrun error",
-2: "decoding error",
-3: "unknown error",
-8: "bad configuration",
-9: "out of memory error",
}
"""
Dict of known error codes returned from :meth:`.PyDecoder.decode`,
:meth:`.PyEncoder.encode` :meth:`.PyEncoder.encode_to_pyfd` and
:meth:`.PyEncoder.encode_to_file`.
"""
#
# --------------------------------------------------------------------
# Helpers
def _get_oserror(error: int, *, encoder: bool) -> OSError:
try:
msg = Image.core.getcodecstatus(error)
except AttributeError:
msg = ERRORS.get(error)
if not msg:
msg = f"{'encoder' if encoder else 'decoder'} error {error}"
msg += f" when {'writing' if encoder else 'reading'} image file"
return OSError(msg)
def _tilesort(t: _Tile) -> int:
# sort on offset
return t[2]
class _Tile(NamedTuple):
codec_name: str
extents: tuple[int, int, int, int] | None
offset: int = 0
args: tuple[Any, ...] | str | None = None
#
# --------------------------------------------------------------------
# ImageFile base class
class ImageFile(Image.Image):
"""Base class for image file format handlers."""
def __init__(
self, fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None
) -> None:
super().__init__()
self._min_frame = 0
self.custom_mimetype: str | None = None
self.tile: list[_Tile] = []
""" A list of tile descriptors """
self.readonly = 1 # until we know better
self.decoderconfig: tuple[Any, ...] = ()
self.decodermaxblock = MAXBLOCK
self.fp: IO[bytes] | None
self._fp: IO[bytes] | DeferredError
if is_path(fp):
# filename
self.fp = open(fp, "rb")
self.filename = os.fspath(fp)
self._exclusive_fp = True
else:
# stream
self.fp = cast(IO[bytes], fp)
self.filename = filename if filename is not None else ""
# can be overridden
self._exclusive_fp = False
try:
try:
self._open()
if isinstance(self, StubImageFile):
if loader := self._load():
loader.open(self)
except (
IndexError, # end of data
TypeError, # end of data (ord)
KeyError, # unsupported mode
EOFError, # got header but not the first frame
struct.error,
) as v:
raise SyntaxError(v) from v
if not self.mode or self.size[0] <= 0 or self.size[1] <= 0:
msg = "not identified by this driver"
raise SyntaxError(msg)
except BaseException:
# close the file only if we have opened it this constructor
if self._exclusive_fp:
self.fp.close()
raise
def _open(self) -> None:
pass
# Context manager support
def __enter__(self) -> ImageFile:
return self
def _close_fp(self) -> None:
if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError):
if self._fp != self.fp:
self._fp.close()
self._fp = DeferredError(ValueError("Operation on closed image"))
if self.fp:
self.fp.close()
def __exit__(self, *args: object) -> None:
if getattr(self, "_exclusive_fp", False):
self._close_fp()
self.fp = None
def close(self) -> None:
"""
Closes the file pointer, if possible.
This operation will destroy the image core and release its memory.
The image data will be unusable afterward.
This function is required to close images that have multiple frames or
have not had their file read and closed by the
:py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for
more information.
"""
try:
self._close_fp()
self.fp = None
except Exception as msg:
logger.debug("Error closing: %s", msg)
super().close()
def get_child_images(self) -> list[ImageFile]:
child_images = []
exif = self.getexif()
ifds = []
if ExifTags.Base.SubIFDs in exif:
subifd_offsets = exif[ExifTags.Base.SubIFDs]
if subifd_offsets:
if not isinstance(subifd_offsets, tuple):
subifd_offsets = (subifd_offsets,)
ifds = [
(exif._get_ifd_dict(subifd_offset), subifd_offset)
for subifd_offset in subifd_offsets
]
ifd1 = exif.get_ifd(ExifTags.IFD.IFD1)
if ifd1 and ifd1.get(ExifTags.Base.JpegIFOffset):
assert exif._info is not None
ifds.append((ifd1, exif._info.next))
offset = None
for ifd, ifd_offset in ifds:
assert self.fp is not None
current_offset = self.fp.tell()
if offset is None:
offset = current_offset
fp = self.fp
if ifd is not None:
thumbnail_offset = ifd.get(ExifTags.Base.JpegIFOffset)
if thumbnail_offset is not None:
thumbnail_offset += getattr(self, "_exif_offset", 0)
self.fp.seek(thumbnail_offset)
length = ifd.get(ExifTags.Base.JpegIFByteCount)
assert isinstance(length, int)
data = self.fp.read(length)
fp = io.BytesIO(data)
with Image.open(fp) as im:
from . import TiffImagePlugin
if thumbnail_offset is None and isinstance(
im, TiffImagePlugin.TiffImageFile
):
im._frame_pos = [ifd_offset]
im._seek(0)
im.load()
child_images.append(im)
if offset is not None:
assert self.fp is not None
self.fp.seek(offset)
return child_images
def get_format_mimetype(self) -> str | None:
if self.custom_mimetype:
return self.custom_mimetype
if self.format is not None:
return Image.MIME.get(self.format.upper())
return None
def __getstate__(self) -> list[Any]:
return super().__getstate__() + [self.filename]
def __setstate__(self, state: list[Any]) -> None:
self.tile = []
if len(state) > 5:
self.filename = state[5]
super().__setstate__(state)
def verify(self) -> None:
"""Check file integrity"""
# raise exception if something's wrong. must be called
# directly after open, and closes file when finished.
if self._exclusive_fp and self.fp:
self.fp.close()
self.fp = None
def load(self) -> Image.core.PixelAccess | None:
"""Load image data based on tile list"""
if not self.tile and self._im is None:
msg = "cannot load this image"
raise OSError(msg)
pixel = Image.Image.load(self)
if not self.tile:
return pixel
self.map: mmap.mmap | None = None
use_mmap = self.filename and len(self.tile) == 1
assert self.fp is not None
readonly = 0
# look for read/seek overrides
if hasattr(self, "load_read"):
read = self.load_read
# don't use mmap if there are custom read/seek functions
use_mmap = False
else:
read = self.fp.read
if hasattr(self, "load_seek"):
seek = self.load_seek
use_mmap = False
else:
seek = self.fp.seek
if use_mmap:
# try memory mapping
decoder_name, extents, offset, args = self.tile[0]
if isinstance(args, str):
args = (args, 0, 1)
if (
decoder_name == "raw"
and isinstance(args, tuple)
and len(args) >= 3
and args[0] == self.mode
and args[0] in Image._MAPMODES
):
if offset < 0:
msg = "Tile offset cannot be negative"
raise ValueError(msg)
try:
# use mmap, if possible
import mmap
with open(self.filename) as fp:
self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ)
if offset + self.size[1] * args[1] > self.map.size():
msg = "buffer is not large enough"
raise OSError(msg)
self.im = Image.core.map_buffer(
self.map, self.size, decoder_name, offset, args
)
readonly = 1
# After trashing self.im,
# we might need to reload the palette data.
if self.palette:
self.palette.dirty = 1
except (AttributeError, OSError, ImportError):
self.map = None
self.load_prepare()
err_code = -3 # initialize to unknown error
if not self.map:
# sort tiles in file order
self.tile.sort(key=_tilesort)
# FIXME: This is a hack to handle TIFF's JpegTables tag.
prefix = getattr(self, "tile_prefix", b"")
# Remove consecutive duplicates that only differ by their offset
self.tile = [
list(tiles)[-1]
for _, tiles in itertools.groupby(
self.tile, lambda tile: (tile[0], tile[1], tile[3])
)
]
for i, (decoder_name, extents, offset, args) in enumerate(self.tile):
seek(offset)
decoder = Image._getdecoder(
self.mode, decoder_name, args, self.decoderconfig
)
try:
decoder.setimage(self.im, extents)
if decoder.pulls_fd:
decoder.setfd(self.fp)
err_code = decoder.decode(b"")[1]
else:
b = prefix
while True:
read_bytes = self.decodermaxblock
if i + 1 < len(self.tile):
next_offset = self.tile[i + 1].offset
if next_offset > offset:
read_bytes = next_offset - offset
try:
s = read(read_bytes)
except (IndexError, struct.error) as e:
# truncated png/gif
if LOAD_TRUNCATED_IMAGES:
break
else:
msg = "image file is truncated"
raise OSError(msg) from e
if not s: # truncated jpeg
if LOAD_TRUNCATED_IMAGES:
break
else:
msg = (
"image file is truncated "
f"({len(b)} bytes not processed)"
)
raise OSError(msg)
b = b + s
n, err_code = decoder.decode(b)
if n < 0:
break
b = b[n:]
finally:
# Need to cleanup here to prevent leaks
decoder.cleanup()
self.tile = []
self.readonly = readonly
self.load_end()
if self._exclusive_fp and self._close_exclusive_fp_after_loading:
self.fp.close()
self.fp = None
if not self.map and not LOAD_TRUNCATED_IMAGES and err_code < 0:
# still raised if decoder fails to return anything
raise _get_oserror(err_code, encoder=False)
return Image.Image.load(self)
def load_prepare(self) -> None:
# create image memory if necessary
if self._im is None:
self.im = Image.core.new(self.mode, self.size)
# create palette (optional)
if self.mode == "P":
Image.Image.load(self)
def load_end(self) -> None:
# may be overridden
pass
# may be defined for contained formats
# def load_seek(self, pos: int) -> None:
# pass
# may be defined for blocked formats (e.g. PNG)
# def load_read(self, read_bytes: int) -> bytes:
# pass
def _seek_check(self, frame: int) -> bool:
if (
frame < self._min_frame
# Only check upper limit on frames if additional seek operations
# are not required to do so
or (
not (hasattr(self, "_n_frames") and self._n_frames is None)
and frame >= getattr(self, "n_frames") + self._min_frame
)
):
msg = "attempt to seek outside sequence"
raise EOFError(msg)
return self.tell() != frame
class StubHandler(abc.ABC):
def open(self, im: StubImageFile) -> None:
pass
@abc.abstractmethod
def load(self, im: StubImageFile) -> Image.Image:
pass
class StubImageFile(ImageFile, metaclass=abc.ABCMeta):
"""
Base class for stub image loaders.
A stub loader is an image loader that can identify files of a
certain format, but relies on external code to load the file.
"""
@abc.abstractmethod
def _open(self) -> None:
pass
def load(self) -> Image.core.PixelAccess | None:
loader = self._load()
if loader is None:
msg = f"cannot find loader for this {self.format} file"
raise OSError(msg)
image = loader.load(self)
assert image is not None
# become the other object (!)
self.__class__ = image.__class__ # type: ignore[assignment]
self.__dict__ = image.__dict__
return image.load()
@abc.abstractmethod
def _load(self) -> StubHandler | None:
"""(Hook) Find actual image loader."""
pass
class Parser:
"""
Incremental image parser. This class implements the standard
feed/close consumer interface.
"""
incremental = None
image: Image.Image | None = None
data: bytes | None = None
decoder: Image.core.ImagingDecoder | PyDecoder | None = None
offset = 0
finished = 0
def reset(self) -> None:
"""
(Consumer) Reset the parser. Note that you can only call this
method immediately after you've created a parser; parser
instances cannot be reused.
"""
assert self.data is None, "cannot reuse parsers"
def feed(self, data: bytes) -> None:
"""
(Consumer) Feed data to the parser.
:param data: A string buffer.
:exception OSError: If the parser failed to parse the image file.
"""
# collect data
if self.finished:
return
if self.data is None:
self.data = data
else:
self.data = self.data + data
# parse what we have
if self.decoder:
if self.offset > 0:
# skip header
skip = min(len(self.data), self.offset)
self.data = self.data[skip:]
self.offset = self.offset - skip
if self.offset > 0 or not self.data:
return
n, e = self.decoder.decode(self.data)
if n < 0:
# end of stream
self.data = None
self.finished = 1
if e < 0:
# decoding error
self.image = None
raise _get_oserror(e, encoder=False)
else:
# end of image
return
self.data = self.data[n:]
elif self.image:
# if we end up here with no decoder, this file cannot
# be incrementally parsed. wait until we've gotten all
# available data
pass
else:
# attempt to open this file
try:
with io.BytesIO(self.data) as fp:
im = Image.open(fp)
except OSError:
pass # not enough data
else:
flag = hasattr(im, "load_seek") or hasattr(im, "load_read")
if not flag and len(im.tile) == 1:
# initialize decoder
im.load_prepare()
d, e, o, a = im.tile[0]
im.tile = []
self.decoder = Image._getdecoder(im.mode, d, a, im.decoderconfig)
self.decoder.setimage(im.im, e)
# calculate decoder offset
self.offset = o
if self.offset <= len(self.data):
self.data = self.data[self.offset :]
self.offset = 0
self.image = im
def __enter__(self) -> Parser:
return self
def __exit__(self, *args: object) -> None:
self.close()
def close(self) -> Image.Image:
"""
(Consumer) Close the stream.
:returns: An image object.
:exception OSError: If the parser failed to parse the image file either
because it cannot be identified or cannot be
decoded.
"""
# finish decoding
if self.decoder:
# get rid of what's left in the buffers
self.feed(b"")
self.data = self.decoder = None
if not self.finished:
msg = "image was incomplete"
raise OSError(msg)
if not self.image:
msg = "cannot parse this image"
raise OSError(msg)
if self.data:
# incremental parsing not possible; reopen the file
# not that we have all data
with io.BytesIO(self.data) as fp:
try:
self.image = Image.open(fp)
finally:
self.image.load()
return self.image
# --------------------------------------------------------------------
def _save(im: Image.Image, fp: IO[bytes], tile: list[_Tile], bufsize: int = 0) -> None:
"""Helper to save image based on tile list
:param im: Image object.
:param fp: File object.
:param tile: Tile list.
:param bufsize: Optional buffer size
"""
im.load()
if not hasattr(im, "encoderconfig"):
im.encoderconfig = ()
tile.sort(key=_tilesort)
# FIXME: make MAXBLOCK a configuration parameter
# It would be great if we could have the encoder specify what it needs
# But, it would need at least the image size in most cases. RawEncode is
# a tricky case.
bufsize = max(MAXBLOCK, bufsize, im.size[0] * 4) # see RawEncode.c
try:
fh = fp.fileno()
fp.flush()
_encode_tile(im, fp, tile, bufsize, fh)
except (AttributeError, io.UnsupportedOperation) as exc:
_encode_tile(im, fp, tile, bufsize, None, exc)
if hasattr(fp, "flush"):
fp.flush()
def _encode_tile(
im: Image.Image,
fp: IO[bytes],
tile: list[_Tile],
bufsize: int,
fh: int | None,
exc: BaseException | None = None,
) -> None:
for encoder_name, extents, offset, args in tile:
if offset > 0:
fp.seek(offset)
encoder = Image._getencoder(im.mode, encoder_name, args, im.encoderconfig)
try:
encoder.setimage(im.im, extents)
if encoder.pushes_fd:
encoder.setfd(fp)
errcode = encoder.encode_to_pyfd()[1]
else:
if exc:
# compress to Python file-compatible object
while True:
errcode, data = encoder.encode(bufsize)[1:]
fp.write(data)
if errcode:
break
else:
# slight speedup: compress to real file object
assert fh is not None
errcode = encoder.encode_to_file(fh, bufsize)
if errcode < 0:
raise _get_oserror(errcode, encoder=True) from exc
finally:
encoder.cleanup()
def _safe_read(fp: IO[bytes], size: int) -> bytes:
"""
Reads large blocks in a safe way. Unlike fp.read(n), this function
doesn't trust the user. If the requested size is larger than
SAFEBLOCK, the file is read block by block.
:param fp: File handle. Must implement a <b>read</b> method.
:param size: Number of bytes to read.
:returns: A string containing <i>size</i> bytes of data.
Raises an OSError if the file is truncated and the read cannot be completed
"""
if size <= 0:
return b""
if size <= SAFEBLOCK:
data = fp.read(size)
if len(data) < size:
msg = "Truncated File Read"
raise OSError(msg)
return data
blocks: list[bytes] = []
remaining_size = size
while remaining_size > 0:
block = fp.read(min(remaining_size, SAFEBLOCK))
if not block:
break
blocks.append(block)
remaining_size -= len(block)
if sum(len(block) for block in blocks) < size:
msg = "Truncated File Read"
raise OSError(msg)
return b"".join(blocks)
class PyCodecState:
def __init__(self) -> None:
self.xsize = 0
self.ysize = 0
self.xoff = 0
self.yoff = 0
def extents(self) -> tuple[int, int, int, int]:
return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize
class PyCodec:
fd: IO[bytes] | None
def __init__(self, mode: str, *args: Any) -> None:
self.im: Image.core.ImagingCore | None = None
self.state = PyCodecState()
self.fd = None
self.mode = mode
self.init(args)
def init(self, args: tuple[Any, ...]) -> None:
"""
Override to perform codec specific initialization
:param args: Tuple of arg items from the tile entry
:returns: None
"""
self.args = args
def cleanup(self) -> None:
"""
Override to perform codec specific cleanup
:returns: None
"""
pass
def setfd(self, fd: IO[bytes]) -> None:
"""
Called from ImageFile to set the Python file-like object
:param fd: A Python file-like object
:returns: None
"""
self.fd = fd
def setimage(
self,
im: Image.core.ImagingCore,
extents: tuple[int, int, int, int] | None = None,
) -> None:
"""
Called from ImageFile to set the core output image for the codec
:param im: A core image object
:param extents: a 4 tuple of (x0, y0, x1, y1) defining the rectangle
for this tile
:returns: None
"""
# following c code
self.im = im
if extents:
x0, y0, x1, y1 = extents
if x0 < 0 or y0 < 0 or x1 > self.im.size[0] or y1 > self.im.size[1]:
msg = "Tile cannot extend outside image"
raise ValueError(msg)
self.state.xoff = x0
self.state.yoff = y0
self.state.xsize = x1 - x0
self.state.ysize = y1 - y0
else:
self.state.xsize, self.state.ysize = self.im.size
if self.state.xsize <= 0 or self.state.ysize <= 0:
msg = "Size must be positive"
raise ValueError(msg)
class PyDecoder(PyCodec):
"""
Python implementation of a format decoder. Override this class and
add the decoding logic in the :meth:`decode` method.
See :ref:`Writing Your Own File Codec in Python<file-codecs-py>`
"""
_pulls_fd = False
@property
def pulls_fd(self) -> bool:
return self._pulls_fd
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
"""
Override to perform the decoding process.
:param buffer: A bytes object with the data to be decoded.
:returns: A tuple of ``(bytes consumed, errcode)``.
If finished with decoding return -1 for the bytes consumed.
Err codes are from :data:`.ImageFile.ERRORS`.
"""
msg = "unavailable in base decoder"
raise NotImplementedError(msg)
def set_as_raw(
self, data: bytes, rawmode: str | None = None, extra: tuple[Any, ...] = ()
) -> None:
"""
Convenience method to set the internal image from a stream of raw data
:param data: Bytes to be set
:param rawmode: The rawmode to be used for the decoder.
If not specified, it will default to the mode of the image
:param extra: Extra arguments for the decoder.
:returns: None
"""
if not rawmode:
rawmode = self.mode
d = Image._getdecoder(self.mode, "raw", rawmode, extra)
assert self.im is not None
d.setimage(self.im, self.state.extents())
s = d.decode(data)
if s[0] >= 0:
msg = "not enough image data"
raise ValueError(msg)
if s[1] != 0:
msg = "cannot decode image data"
raise ValueError(msg)
class PyEncoder(PyCodec):
"""
Python implementation of a format encoder. Override this class and
add the decoding logic in the :meth:`encode` method.
See :ref:`Writing Your Own File Codec in Python<file-codecs-py>`
"""
_pushes_fd = False
@property
def pushes_fd(self) -> bool:
return self._pushes_fd
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
"""
Override to perform the encoding process.
:param bufsize: Buffer size.
:returns: A tuple of ``(bytes encoded, errcode, bytes)``.
If finished with encoding return 1 for the error code.
Err codes are from :data:`.ImageFile.ERRORS`.
"""
msg = "unavailable in base encoder"
raise NotImplementedError(msg)
def encode_to_pyfd(self) -> tuple[int, int]:
"""
If ``pushes_fd`` is ``True``, then this method will be used,
and ``encode()`` will only be called once.
:returns: A tuple of ``(bytes consumed, errcode)``.
Err codes are from :data:`.ImageFile.ERRORS`.
"""
if not self.pushes_fd:
return 0, -8 # bad configuration
bytes_consumed, errcode, data = self.encode(0)
if data:
assert self.fd is not None
self.fd.write(data)
return bytes_consumed, errcode
def encode_to_file(self, fh: int, bufsize: int) -> int:
"""
:param fh: File handle.
:param bufsize: Buffer size.
:returns: If finished successfully, return 0.
Otherwise, return an error code. Err codes are from
:data:`.ImageFile.ERRORS`.
"""
errcode = 0
while errcode == 0:
status, errcode, buf = self.encode(bufsize)
if status > 0:
os.write(fh, buf[status:])
return errcode

View File

@@ -1,607 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# standard filters
#
# History:
# 1995-11-27 fl Created
# 2002-06-08 fl Added rank and mode filters
# 2003-09-15 fl Fixed rank calculation in rank filter; added expand call
#
# Copyright (c) 1997-2003 by Secret Labs AB.
# Copyright (c) 1995-2002 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import abc
import functools
from collections.abc import Sequence
from typing import cast
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType
from typing import Any
from . import _imaging
from ._typing import NumpyArray
class Filter(abc.ABC):
@abc.abstractmethod
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
pass
class MultibandFilter(Filter):
pass
class BuiltinFilter(MultibandFilter):
filterargs: tuple[Any, ...]
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
if image.mode == "P":
msg = "cannot filter palette images"
raise ValueError(msg)
return image.filter(*self.filterargs)
class Kernel(BuiltinFilter):
"""
Create a convolution kernel. This only supports 3x3 and 5x5 integer and floating
point kernels.
Kernels can only be applied to "L" and "RGB" images.
:param size: Kernel size, given as (width, height). This must be (3,3) or (5,5).
:param kernel: A sequence containing kernel weights. The kernel will be flipped
vertically before being applied to the image.
:param scale: Scale factor. If given, the result for each pixel is divided by this
value. The default is the sum of the kernel weights.
:param offset: Offset. If given, this value is added to the result, after it has
been divided by the scale factor.
"""
name = "Kernel"
def __init__(
self,
size: tuple[int, int],
kernel: Sequence[float],
scale: float | None = None,
offset: float = 0,
) -> None:
if scale is None:
# default scale is sum of kernel
scale = functools.reduce(lambda a, b: a + b, kernel)
if size[0] * size[1] != len(kernel):
msg = "not enough coefficients in kernel"
raise ValueError(msg)
self.filterargs = size, scale, offset, kernel
class RankFilter(Filter):
"""
Create a rank filter. The rank filter sorts all pixels in
a window of the given size, and returns the ``rank``'th value.
:param size: The kernel size, in pixels.
:param rank: What pixel value to pick. Use 0 for a min filter,
``size * size / 2`` for a median filter, ``size * size - 1``
for a max filter, etc.
"""
name = "Rank"
def __init__(self, size: int, rank: int) -> None:
self.size = size
self.rank = rank
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
if image.mode == "P":
msg = "cannot filter palette images"
raise ValueError(msg)
image = image.expand(self.size // 2, self.size // 2)
return image.rankfilter(self.size, self.rank)
class MedianFilter(RankFilter):
"""
Create a median filter. Picks the median pixel value in a window with the
given size.
:param size: The kernel size, in pixels.
"""
name = "Median"
def __init__(self, size: int = 3) -> None:
self.size = size
self.rank = size * size // 2
class MinFilter(RankFilter):
"""
Create a min filter. Picks the lowest pixel value in a window with the
given size.
:param size: The kernel size, in pixels.
"""
name = "Min"
def __init__(self, size: int = 3) -> None:
self.size = size
self.rank = 0
class MaxFilter(RankFilter):
"""
Create a max filter. Picks the largest pixel value in a window with the
given size.
:param size: The kernel size, in pixels.
"""
name = "Max"
def __init__(self, size: int = 3) -> None:
self.size = size
self.rank = size * size - 1
class ModeFilter(Filter):
"""
Create a mode filter. Picks the most frequent pixel value in a box with the
given size. Pixel values that occur only once or twice are ignored; if no
pixel value occurs more than twice, the original pixel value is preserved.
:param size: The kernel size, in pixels.
"""
name = "Mode"
def __init__(self, size: int = 3) -> None:
self.size = size
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
return image.modefilter(self.size)
class GaussianBlur(MultibandFilter):
"""Blurs the image with a sequence of extended box filters, which
approximates a Gaussian kernel. For details on accuracy see
<https://www.mia.uni-saarland.de/Publications/gwosdek-ssvm11.pdf>
:param radius: Standard deviation of the Gaussian kernel. Either a sequence of two
numbers for x and y, or a single number for both.
"""
name = "GaussianBlur"
def __init__(self, radius: float | Sequence[float] = 2) -> None:
self.radius = radius
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
xy = self.radius
if isinstance(xy, (int, float)):
xy = (xy, xy)
if xy == (0, 0):
return image.copy()
return image.gaussian_blur(xy)
class BoxBlur(MultibandFilter):
"""Blurs the image by setting each pixel to the average value of the pixels
in a square box extending radius pixels in each direction.
Supports float radius of arbitrary size. Uses an optimized implementation
which runs in linear time relative to the size of the image
for any radius value.
:param radius: Size of the box in a direction. Either a sequence of two numbers for
x and y, or a single number for both.
Radius 0 does not blur, returns an identical image.
Radius 1 takes 1 pixel in each direction, i.e. 9 pixels in total.
"""
name = "BoxBlur"
def __init__(self, radius: float | Sequence[float]) -> None:
xy = radius if isinstance(radius, (tuple, list)) else (radius, radius)
if xy[0] < 0 or xy[1] < 0:
msg = "radius must be >= 0"
raise ValueError(msg)
self.radius = radius
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
xy = self.radius
if isinstance(xy, (int, float)):
xy = (xy, xy)
if xy == (0, 0):
return image.copy()
return image.box_blur(xy)
class UnsharpMask(MultibandFilter):
"""Unsharp mask filter.
See Wikipedia's entry on `digital unsharp masking`_ for an explanation of
the parameters.
:param radius: Blur Radius
:param percent: Unsharp strength, in percent
:param threshold: Threshold controls the minimum brightness change that
will be sharpened
.. _digital unsharp masking: https://en.wikipedia.org/wiki/Unsharp_masking#Digital_unsharp_masking
"""
name = "UnsharpMask"
def __init__(
self, radius: float = 2, percent: int = 150, threshold: int = 3
) -> None:
self.radius = radius
self.percent = percent
self.threshold = threshold
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
return image.unsharp_mask(self.radius, self.percent, self.threshold)
class BLUR(BuiltinFilter):
name = "Blur"
# fmt: off
filterargs = (5, 5), 16, 0, (
1, 1, 1, 1, 1,
1, 0, 0, 0, 1,
1, 0, 0, 0, 1,
1, 0, 0, 0, 1,
1, 1, 1, 1, 1,
)
# fmt: on
class CONTOUR(BuiltinFilter):
name = "Contour"
# fmt: off
filterargs = (3, 3), 1, 255, (
-1, -1, -1,
-1, 8, -1,
-1, -1, -1,
)
# fmt: on
class DETAIL(BuiltinFilter):
name = "Detail"
# fmt: off
filterargs = (3, 3), 6, 0, (
0, -1, 0,
-1, 10, -1,
0, -1, 0,
)
# fmt: on
class EDGE_ENHANCE(BuiltinFilter):
name = "Edge-enhance"
# fmt: off
filterargs = (3, 3), 2, 0, (
-1, -1, -1,
-1, 10, -1,
-1, -1, -1,
)
# fmt: on
class EDGE_ENHANCE_MORE(BuiltinFilter):
name = "Edge-enhance More"
# fmt: off
filterargs = (3, 3), 1, 0, (
-1, -1, -1,
-1, 9, -1,
-1, -1, -1,
)
# fmt: on
class EMBOSS(BuiltinFilter):
name = "Emboss"
# fmt: off
filterargs = (3, 3), 1, 128, (
-1, 0, 0,
0, 1, 0,
0, 0, 0,
)
# fmt: on
class FIND_EDGES(BuiltinFilter):
name = "Find Edges"
# fmt: off
filterargs = (3, 3), 1, 0, (
-1, -1, -1,
-1, 8, -1,
-1, -1, -1,
)
# fmt: on
class SHARPEN(BuiltinFilter):
name = "Sharpen"
# fmt: off
filterargs = (3, 3), 16, 0, (
-2, -2, -2,
-2, 32, -2,
-2, -2, -2,
)
# fmt: on
class SMOOTH(BuiltinFilter):
name = "Smooth"
# fmt: off
filterargs = (3, 3), 13, 0, (
1, 1, 1,
1, 5, 1,
1, 1, 1,
)
# fmt: on
class SMOOTH_MORE(BuiltinFilter):
name = "Smooth More"
# fmt: off
filterargs = (5, 5), 100, 0, (
1, 1, 1, 1, 1,
1, 5, 5, 5, 1,
1, 5, 44, 5, 1,
1, 5, 5, 5, 1,
1, 1, 1, 1, 1,
)
# fmt: on
class Color3DLUT(MultibandFilter):
"""Three-dimensional color lookup table.
Transforms 3-channel pixels using the values of the channels as coordinates
in the 3D lookup table and interpolating the nearest elements.
This method allows you to apply almost any color transformation
in constant time by using pre-calculated decimated tables.
.. versionadded:: 5.2.0
:param size: Size of the table. One int or tuple of (int, int, int).
Minimal size in any dimension is 2, maximum is 65.
:param table: Flat lookup table. A list of ``channels * size**3``
float elements or a list of ``size**3`` channels-sized
tuples with floats. Channels are changed first,
then first dimension, then second, then third.
Value 0.0 corresponds lowest value of output, 1.0 highest.
:param channels: Number of channels in the table. Could be 3 or 4.
Default is 3.
:param target_mode: A mode for the result image. Should have not less
than ``channels`` channels. Default is ``None``,
which means that mode wouldn't be changed.
"""
name = "Color 3D LUT"
def __init__(
self,
size: int | tuple[int, int, int],
table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray,
channels: int = 3,
target_mode: str | None = None,
**kwargs: bool,
) -> None:
if channels not in (3, 4):
msg = "Only 3 or 4 output channels are supported"
raise ValueError(msg)
self.size = size = self._check_size(size)
self.channels = channels
self.mode = target_mode
# Hidden flag `_copy_table=False` could be used to avoid extra copying
# of the table if the table is specially made for the constructor.
copy_table = kwargs.get("_copy_table", True)
items = size[0] * size[1] * size[2]
wrong_size = False
numpy: ModuleType | None = None
if hasattr(table, "shape"):
try:
import numpy
except ImportError:
pass
if numpy and isinstance(table, numpy.ndarray):
numpy_table: NumpyArray = table
if copy_table:
numpy_table = numpy_table.copy()
if numpy_table.shape in [
(items * channels,),
(items, channels),
(size[2], size[1], size[0], channels),
]:
table = numpy_table.reshape(items * channels)
else:
wrong_size = True
else:
if copy_table:
table = list(table)
# Convert to a flat list
if table and isinstance(table[0], (list, tuple)):
raw_table = cast(Sequence[Sequence[int]], table)
flat_table: list[int] = []
for pixel in raw_table:
if len(pixel) != channels:
msg = (
"The elements of the table should "
f"have a length of {channels}."
)
raise ValueError(msg)
flat_table.extend(pixel)
table = flat_table
if wrong_size or len(table) != items * channels:
msg = (
"The table should have either channels * size**3 float items "
"or size**3 items of channels-sized tuples with floats. "
f"Table should be: {channels}x{size[0]}x{size[1]}x{size[2]}. "
f"Actual length: {len(table)}"
)
raise ValueError(msg)
self.table = table
@staticmethod
def _check_size(size: Any) -> tuple[int, int, int]:
try:
_, _, _ = size
except ValueError as e:
msg = "Size should be either an integer or a tuple of three integers."
raise ValueError(msg) from e
except TypeError:
size = (size, size, size)
size = tuple(int(x) for x in size)
for size_1d in size:
if not 2 <= size_1d <= 65:
msg = "Size should be in [2, 65] range."
raise ValueError(msg)
return size
@classmethod
def generate(
cls,
size: int | tuple[int, int, int],
callback: Callable[[float, float, float], tuple[float, ...]],
channels: int = 3,
target_mode: str | None = None,
) -> Color3DLUT:
"""Generates new LUT using provided callback.
:param size: Size of the table. Passed to the constructor.
:param callback: Function with three parameters which correspond
three color channels. Will be called ``size**3``
times with values from 0.0 to 1.0 and should return
a tuple with ``channels`` elements.
:param channels: The number of channels which should return callback.
:param target_mode: Passed to the constructor of the resulting
lookup table.
"""
size_1d, size_2d, size_3d = cls._check_size(size)
if channels not in (3, 4):
msg = "Only 3 or 4 output channels are supported"
raise ValueError(msg)
table: list[float] = [0] * (size_1d * size_2d * size_3d * channels)
idx_out = 0
for b in range(size_3d):
for g in range(size_2d):
for r in range(size_1d):
table[idx_out : idx_out + channels] = callback(
r / (size_1d - 1), g / (size_2d - 1), b / (size_3d - 1)
)
idx_out += channels
return cls(
(size_1d, size_2d, size_3d),
table,
channels=channels,
target_mode=target_mode,
_copy_table=False,
)
def transform(
self,
callback: Callable[..., tuple[float, ...]],
with_normals: bool = False,
channels: int | None = None,
target_mode: str | None = None,
) -> Color3DLUT:
"""Transforms the table values using provided callback and returns
a new LUT with altered values.
:param callback: A function which takes old lookup table values
and returns a new set of values. The number
of arguments which function should take is
``self.channels`` or ``3 + self.channels``
if ``with_normals`` flag is set.
Should return a tuple of ``self.channels`` or
``channels`` elements if it is set.
:param with_normals: If true, ``callback`` will be called with
coordinates in the color cube as the first
three arguments. Otherwise, ``callback``
will be called only with actual color values.
:param channels: The number of channels in the resulting lookup table.
:param target_mode: Passed to the constructor of the resulting
lookup table.
"""
if channels not in (None, 3, 4):
msg = "Only 3 or 4 output channels are supported"
raise ValueError(msg)
ch_in = self.channels
ch_out = channels or ch_in
size_1d, size_2d, size_3d = self.size
table: list[float] = [0] * (size_1d * size_2d * size_3d * ch_out)
idx_in = 0
idx_out = 0
for b in range(size_3d):
for g in range(size_2d):
for r in range(size_1d):
values = self.table[idx_in : idx_in + ch_in]
if with_normals:
values = callback(
r / (size_1d - 1),
g / (size_2d - 1),
b / (size_3d - 1),
*values,
)
else:
values = callback(*values)
table[idx_out : idx_out + ch_out] = values
idx_in += ch_in
idx_out += ch_out
return type(self)(
self.size,
table,
channels=ch_out,
target_mode=target_mode or self.mode,
_copy_table=False,
)
def __repr__(self) -> str:
r = [
f"{self.__class__.__name__} from {self.table.__class__.__name__}",
"size={:d}x{:d}x{:d}".format(*self.size),
f"channels={self.channels:d}",
]
if self.mode:
r.append(f"target_mode={self.mode}")
return "<{}>".format(" ".join(r))
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
from . import Image
return image.color_lut_3d(
self.mode or image.mode,
Image.Resampling.BILINEAR,
self.channels,
self.size,
self.table,
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,231 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# screen grabber
#
# History:
# 2001-04-26 fl created
# 2001-09-17 fl use builtin driver, if present
# 2002-11-19 fl added grabclipboard support
#
# Copyright (c) 2001-2002 by Secret Labs AB
# Copyright (c) 2001-2002 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
import os
import shutil
import subprocess
import sys
import tempfile
from . import Image
TYPE_CHECKING = False
if TYPE_CHECKING:
from . import ImageWin
def grab(
bbox: tuple[int, int, int, int] | None = None,
include_layered_windows: bool = False,
all_screens: bool = False,
xdisplay: str | None = None,
window: int | ImageWin.HWND | None = None,
) -> Image.Image:
im: Image.Image
if xdisplay is None:
if sys.platform == "darwin":
fh, filepath = tempfile.mkstemp(".png")
os.close(fh)
args = ["screencapture"]
if window is not None:
args += ["-l", str(window)]
elif bbox:
left, top, right, bottom = bbox
args += ["-R", f"{left},{top},{right-left},{bottom-top}"]
args += ["-x", filepath]
retcode = subprocess.call(args)
if retcode:
raise subprocess.CalledProcessError(retcode, args)
im = Image.open(filepath)
im.load()
os.unlink(filepath)
if bbox:
if window is not None:
# Determine if the window was in Retina mode or not
# by capturing it without the shadow,
# and checking how different the width is
fh, filepath = tempfile.mkstemp(".png")
os.close(fh)
args = ["screencapture", "-l", str(window), "-o", "-x", filepath]
retcode = subprocess.call(args)
if retcode:
raise subprocess.CalledProcessError(retcode, args)
with Image.open(filepath) as im_no_shadow:
retina = im.width - im_no_shadow.width > 100
os.unlink(filepath)
# Since screencapture's -R does not work with -l,
# crop the image manually
if retina:
left, top, right, bottom = bbox
im_cropped = im.resize(
(right - left, bottom - top),
box=tuple(coord * 2 for coord in bbox),
)
else:
im_cropped = im.crop(bbox)
im.close()
return im_cropped
else:
im_resized = im.resize((right - left, bottom - top))
im.close()
return im_resized
return im
elif sys.platform == "win32":
if window is not None:
all_screens = -1
offset, size, data = Image.core.grabscreen_win32(
include_layered_windows,
all_screens,
int(window) if window is not None else 0,
)
im = Image.frombytes(
"RGB",
size,
data,
# RGB, 32-bit line padding, origin lower left corner
"raw",
"BGR",
(size[0] * 3 + 3) & -4,
-1,
)
if bbox:
x0, y0 = offset
left, top, right, bottom = bbox
im = im.crop((left - x0, top - y0, right - x0, bottom - y0))
return im
# Cast to Optional[str] needed for Windows and macOS.
display_name: str | None = xdisplay
try:
if not Image.core.HAVE_XCB:
msg = "Pillow was built without XCB support"
raise OSError(msg)
size, data = Image.core.grabscreen_x11(display_name)
except OSError:
if display_name is None and sys.platform not in ("darwin", "win32"):
if shutil.which("gnome-screenshot"):
args = ["gnome-screenshot", "-f"]
elif shutil.which("grim"):
args = ["grim"]
elif shutil.which("spectacle"):
args = ["spectacle", "-n", "-b", "-f", "-o"]
else:
raise
fh, filepath = tempfile.mkstemp(".png")
os.close(fh)
args.append(filepath)
retcode = subprocess.call(args)
if retcode:
raise subprocess.CalledProcessError(retcode, args)
im = Image.open(filepath)
im.load()
os.unlink(filepath)
if bbox:
im_cropped = im.crop(bbox)
im.close()
return im_cropped
return im
else:
raise
else:
im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1)
if bbox:
im = im.crop(bbox)
return im
def grabclipboard() -> Image.Image | list[str] | None:
if sys.platform == "darwin":
p = subprocess.run(
["osascript", "-e", "get the clipboard as «class PNGf»"],
capture_output=True,
)
if p.returncode != 0:
return None
import binascii
data = io.BytesIO(binascii.unhexlify(p.stdout[11:-3]))
return Image.open(data)
elif sys.platform == "win32":
fmt, data = Image.core.grabclipboard_win32()
if fmt == "file": # CF_HDROP
import struct
o = struct.unpack_from("I", data)[0]
if data[16] == 0:
files = data[o:].decode("mbcs").split("\0")
else:
files = data[o:].decode("utf-16le").split("\0")
return files[: files.index("")]
if isinstance(data, bytes):
data = io.BytesIO(data)
if fmt == "png":
from . import PngImagePlugin
return PngImagePlugin.PngImageFile(data)
elif fmt == "DIB":
from . import BmpImagePlugin
return BmpImagePlugin.DibImageFile(data)
return None
else:
if os.getenv("WAYLAND_DISPLAY"):
session_type = "wayland"
elif os.getenv("DISPLAY"):
session_type = "x11"
else: # Session type check failed
session_type = None
if shutil.which("wl-paste") and session_type in ("wayland", None):
args = ["wl-paste", "-t", "image"]
elif shutil.which("xclip") and session_type in ("x11", None):
args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]
else:
msg = "wl-paste or xclip is required for ImageGrab.grabclipboard() on Linux"
raise NotImplementedError(msg)
p = subprocess.run(args, capture_output=True)
if p.returncode != 0:
err = p.stderr
for silent_error in [
# wl-paste, when the clipboard is empty
b"Nothing is copied",
# Ubuntu/Debian wl-paste, when the clipboard is empty
b"No selection",
# Ubuntu/Debian wl-paste, when an image isn't available
b"No suitable type of content copied",
# wl-paste or Ubuntu/Debian xclip, when an image isn't available
b" not available",
# xclip, when an image isn't available
b"cannot convert ",
# xclip, when the clipboard isn't initialized
b"xclip: Error: There is no owner for the ",
]:
if silent_error in err:
return None
msg = f"{args[0]} error"
if err:
msg += f": {err.strip().decode()}"
raise ChildProcessError(msg)
data = io.BytesIO(p.stdout)
im = Image.open(data)
im.load()
return im

View File

@@ -1,314 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# a simple math add-on for the Python Imaging Library
#
# History:
# 1999-02-15 fl Original PIL Plus release
# 2005-05-05 fl Simplified and cleaned up for PIL 1.1.6
# 2005-09-12 fl Fixed int() and float() for Python 2.4.1
#
# Copyright (c) 1999-2005 by Secret Labs AB
# Copyright (c) 2005 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import builtins
from . import Image, _imagingmath
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from types import CodeType
from typing import Any
class _Operand:
"""Wraps an image operand, providing standard operators"""
def __init__(self, im: Image.Image):
self.im = im
def __fixup(self, im1: _Operand | float) -> Image.Image:
# convert image to suitable mode
if isinstance(im1, _Operand):
# argument was an image.
if im1.im.mode in ("1", "L"):
return im1.im.convert("I")
elif im1.im.mode in ("I", "F"):
return im1.im
else:
msg = f"unsupported mode: {im1.im.mode}"
raise ValueError(msg)
else:
# argument was a constant
if isinstance(im1, (int, float)) and self.im.mode in ("1", "L", "I"):
return Image.new("I", self.im.size, im1)
else:
return Image.new("F", self.im.size, im1)
def apply(
self,
op: str,
im1: _Operand | float,
im2: _Operand | float | None = None,
mode: str | None = None,
) -> _Operand:
im_1 = self.__fixup(im1)
if im2 is None:
# unary operation
out = Image.new(mode or im_1.mode, im_1.size, None)
try:
op = getattr(_imagingmath, f"{op}_{im_1.mode}")
except AttributeError as e:
msg = f"bad operand type for '{op}'"
raise TypeError(msg) from e
_imagingmath.unop(op, out.getim(), im_1.getim())
else:
# binary operation
im_2 = self.__fixup(im2)
if im_1.mode != im_2.mode:
# convert both arguments to floating point
if im_1.mode != "F":
im_1 = im_1.convert("F")
if im_2.mode != "F":
im_2 = im_2.convert("F")
if im_1.size != im_2.size:
# crop both arguments to a common size
size = (
min(im_1.size[0], im_2.size[0]),
min(im_1.size[1], im_2.size[1]),
)
if im_1.size != size:
im_1 = im_1.crop((0, 0) + size)
if im_2.size != size:
im_2 = im_2.crop((0, 0) + size)
out = Image.new(mode or im_1.mode, im_1.size, None)
try:
op = getattr(_imagingmath, f"{op}_{im_1.mode}")
except AttributeError as e:
msg = f"bad operand type for '{op}'"
raise TypeError(msg) from e
_imagingmath.binop(op, out.getim(), im_1.getim(), im_2.getim())
return _Operand(out)
# unary operators
def __bool__(self) -> bool:
# an image is "true" if it contains at least one non-zero pixel
return self.im.getbbox() is not None
def __abs__(self) -> _Operand:
return self.apply("abs", self)
def __pos__(self) -> _Operand:
return self
def __neg__(self) -> _Operand:
return self.apply("neg", self)
# binary operators
def __add__(self, other: _Operand | float) -> _Operand:
return self.apply("add", self, other)
def __radd__(self, other: _Operand | float) -> _Operand:
return self.apply("add", other, self)
def __sub__(self, other: _Operand | float) -> _Operand:
return self.apply("sub", self, other)
def __rsub__(self, other: _Operand | float) -> _Operand:
return self.apply("sub", other, self)
def __mul__(self, other: _Operand | float) -> _Operand:
return self.apply("mul", self, other)
def __rmul__(self, other: _Operand | float) -> _Operand:
return self.apply("mul", other, self)
def __truediv__(self, other: _Operand | float) -> _Operand:
return self.apply("div", self, other)
def __rtruediv__(self, other: _Operand | float) -> _Operand:
return self.apply("div", other, self)
def __mod__(self, other: _Operand | float) -> _Operand:
return self.apply("mod", self, other)
def __rmod__(self, other: _Operand | float) -> _Operand:
return self.apply("mod", other, self)
def __pow__(self, other: _Operand | float) -> _Operand:
return self.apply("pow", self, other)
def __rpow__(self, other: _Operand | float) -> _Operand:
return self.apply("pow", other, self)
# bitwise
def __invert__(self) -> _Operand:
return self.apply("invert", self)
def __and__(self, other: _Operand | float) -> _Operand:
return self.apply("and", self, other)
def __rand__(self, other: _Operand | float) -> _Operand:
return self.apply("and", other, self)
def __or__(self, other: _Operand | float) -> _Operand:
return self.apply("or", self, other)
def __ror__(self, other: _Operand | float) -> _Operand:
return self.apply("or", other, self)
def __xor__(self, other: _Operand | float) -> _Operand:
return self.apply("xor", self, other)
def __rxor__(self, other: _Operand | float) -> _Operand:
return self.apply("xor", other, self)
def __lshift__(self, other: _Operand | float) -> _Operand:
return self.apply("lshift", self, other)
def __rshift__(self, other: _Operand | float) -> _Operand:
return self.apply("rshift", self, other)
# logical
def __eq__(self, other: _Operand | float) -> _Operand: # type: ignore[override]
return self.apply("eq", self, other)
def __ne__(self, other: _Operand | float) -> _Operand: # type: ignore[override]
return self.apply("ne", self, other)
def __lt__(self, other: _Operand | float) -> _Operand:
return self.apply("lt", self, other)
def __le__(self, other: _Operand | float) -> _Operand:
return self.apply("le", self, other)
def __gt__(self, other: _Operand | float) -> _Operand:
return self.apply("gt", self, other)
def __ge__(self, other: _Operand | float) -> _Operand:
return self.apply("ge", self, other)
# conversions
def imagemath_int(self: _Operand) -> _Operand:
return _Operand(self.im.convert("I"))
def imagemath_float(self: _Operand) -> _Operand:
return _Operand(self.im.convert("F"))
# logical
def imagemath_equal(self: _Operand, other: _Operand | float | None) -> _Operand:
return self.apply("eq", self, other, mode="I")
def imagemath_notequal(self: _Operand, other: _Operand | float | None) -> _Operand:
return self.apply("ne", self, other, mode="I")
def imagemath_min(self: _Operand, other: _Operand | float | None) -> _Operand:
return self.apply("min", self, other)
def imagemath_max(self: _Operand, other: _Operand | float | None) -> _Operand:
return self.apply("max", self, other)
def imagemath_convert(self: _Operand, mode: str) -> _Operand:
return _Operand(self.im.convert(mode))
ops = {
"int": imagemath_int,
"float": imagemath_float,
"equal": imagemath_equal,
"notequal": imagemath_notequal,
"min": imagemath_min,
"max": imagemath_max,
"convert": imagemath_convert,
}
def lambda_eval(expression: Callable[[dict[str, Any]], Any], **kw: Any) -> Any:
"""
Returns the result of an image function.
:py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band
images, use the :py:meth:`~PIL.Image.Image.split` method or
:py:func:`~PIL.Image.merge` function.
:param expression: A function that receives a dictionary.
:param **kw: Values to add to the function's dictionary.
:return: The expression result. This is usually an image object, but can
also be an integer, a floating point value, or a pixel tuple,
depending on the expression.
"""
args: dict[str, Any] = ops.copy()
args.update(kw)
for k, v in args.items():
if isinstance(v, Image.Image):
args[k] = _Operand(v)
out = expression(args)
try:
return out.im
except AttributeError:
return out
def unsafe_eval(expression: str, **kw: Any) -> Any:
"""
Evaluates an image expression. This uses Python's ``eval()`` function to process
the expression string, and carries the security risks of doing so. It is not
recommended to process expressions without considering this.
:py:meth:`~lambda_eval` is a more secure alternative.
:py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band
images, use the :py:meth:`~PIL.Image.Image.split` method or
:py:func:`~PIL.Image.merge` function.
:param expression: A string containing a Python-style expression.
:param **kw: Values to add to the evaluation context.
:return: The evaluated expression. This is usually an image object, but can
also be an integer, a floating point value, or a pixel tuple,
depending on the expression.
"""
# build execution namespace
args: dict[str, Any] = ops.copy()
for k in kw:
if "__" in k or hasattr(builtins, k):
msg = f"'{k}' not allowed"
raise ValueError(msg)
args.update(kw)
for k, v in args.items():
if isinstance(v, Image.Image):
args[k] = _Operand(v)
compiled_code = compile(expression, "<string>", "eval")
def scan(code: CodeType) -> None:
for const in code.co_consts:
if type(const) is type(compiled_code):
scan(const)
for name in code.co_names:
if name not in args and name != "abs":
msg = f"'{name}' not allowed"
raise ValueError(msg)
scan(compiled_code)
out = builtins.eval(expression, {"__builtins": {"abs": abs}}, args)
try:
return out.im
except AttributeError:
return out

View File

@@ -1,85 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# standard mode descriptors
#
# History:
# 2006-03-20 fl Added
#
# Copyright (c) 2006 by Secret Labs AB.
# Copyright (c) 2006 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import sys
from functools import lru_cache
from typing import NamedTuple
class ModeDescriptor(NamedTuple):
"""Wrapper for mode strings."""
mode: str
bands: tuple[str, ...]
basemode: str
basetype: str
typestr: str
def __str__(self) -> str:
return self.mode
@lru_cache
def getmode(mode: str) -> ModeDescriptor:
"""Gets a mode descriptor for the given mode."""
endian = "<" if sys.byteorder == "little" else ">"
modes = {
# core modes
# Bits need to be extended to bytes
"1": ("L", "L", ("1",), "|b1"),
"L": ("L", "L", ("L",), "|u1"),
"I": ("L", "I", ("I",), f"{endian}i4"),
"F": ("L", "F", ("F",), f"{endian}f4"),
"P": ("P", "L", ("P",), "|u1"),
"RGB": ("RGB", "L", ("R", "G", "B"), "|u1"),
"RGBX": ("RGB", "L", ("R", "G", "B", "X"), "|u1"),
"RGBA": ("RGB", "L", ("R", "G", "B", "A"), "|u1"),
"CMYK": ("RGB", "L", ("C", "M", "Y", "K"), "|u1"),
"YCbCr": ("RGB", "L", ("Y", "Cb", "Cr"), "|u1"),
# UNDONE - unsigned |u1i1i1
"LAB": ("RGB", "L", ("L", "A", "B"), "|u1"),
"HSV": ("RGB", "L", ("H", "S", "V"), "|u1"),
# extra experimental modes
"RGBa": ("RGB", "L", ("R", "G", "B", "a"), "|u1"),
"LA": ("L", "L", ("L", "A"), "|u1"),
"La": ("L", "L", ("L", "a"), "|u1"),
"PA": ("RGB", "L", ("P", "A"), "|u1"),
}
if mode in modes:
base_mode, base_type, bands, type_str = modes[mode]
return ModeDescriptor(mode, bands, base_mode, base_type, type_str)
mapping_modes = {
# I;16 == I;16L, and I;32 == I;32L
"I;16": "<u2",
"I;16S": "<i2",
"I;16L": "<u2",
"I;16LS": "<i2",
"I;16B": ">u2",
"I;16BS": ">i2",
"I;16N": f"{endian}u2",
"I;16NS": f"{endian}i2",
"I;32": "<u4",
"I;32B": ">u4",
"I;32L": "<u4",
"I;32S": "<i4",
"I;32BS": ">i4",
"I;32LS": "<i4",
}
type_str = mapping_modes[mode]
return ModeDescriptor(mode, ("I",), "L", "L", type_str)

View File

@@ -1,317 +0,0 @@
# A binary morphology add-on for the Python Imaging Library
#
# History:
# 2014-06-04 Initial version.
#
# Copyright (c) 2014 Dov Grobgeld <dov.grobgeld@gmail.com>
from __future__ import annotations
import re
from . import Image, _imagingmorph
LUT_SIZE = 1 << 9
# fmt: off
ROTATION_MATRIX = [
6, 3, 0,
7, 4, 1,
8, 5, 2,
]
MIRROR_MATRIX = [
2, 1, 0,
5, 4, 3,
8, 7, 6,
]
# fmt: on
class LutBuilder:
"""A class for building a MorphLut from a descriptive language
The input patterns is a list of a strings sequences like these::
4:(...
.1.
111)->1
(whitespaces including linebreaks are ignored). The option 4
describes a series of symmetry operations (in this case a
4-rotation), the pattern is described by:
- . or X - Ignore
- 1 - Pixel is on
- 0 - Pixel is off
The result of the operation is described after "->" string.
The default is to return the current pixel value, which is
returned if no other match is found.
Operations:
- 4 - 4 way rotation
- N - Negate
- 1 - Dummy op for no other operation (an op must always be given)
- M - Mirroring
Example::
lb = LutBuilder(patterns = ["4:(... .1. 111)->1"])
lut = lb.build_lut()
"""
def __init__(
self, patterns: list[str] | None = None, op_name: str | None = None
) -> None:
"""
:param patterns: A list of input patterns, or None.
:param op_name: The name of a known pattern. One of "corner", "dilation4",
"dilation8", "erosion4", "erosion8" or "edge".
:exception Exception: If the op_name is not recognized.
"""
self.lut: bytearray | None = None
if op_name is not None:
known_patterns = {
"corner": ["1:(... ... ...)->0", "4:(00. 01. ...)->1"],
"dilation4": ["4:(... .0. .1.)->1"],
"dilation8": ["4:(... .0. .1.)->1", "4:(... .0. ..1)->1"],
"erosion4": ["4:(... .1. .0.)->0"],
"erosion8": ["4:(... .1. .0.)->0", "4:(... .1. ..0)->0"],
"edge": [
"1:(... ... ...)->0",
"4:(.0. .1. ...)->1",
"4:(01. .1. ...)->1",
],
}
if op_name not in known_patterns:
msg = f"Unknown pattern {op_name}!"
raise Exception(msg)
self.patterns = known_patterns[op_name]
elif patterns is not None:
self.patterns = patterns
else:
self.patterns = []
def add_patterns(self, patterns: list[str]) -> None:
"""
Append to list of patterns.
:param patterns: Additional patterns.
"""
self.patterns += patterns
def build_default_lut(self) -> bytearray:
"""
Set the current LUT, and return it.
This is the default LUT that patterns will be applied against when building.
"""
symbols = [0, 1]
m = 1 << 4 # pos of current pixel
self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE))
return self.lut
def get_lut(self) -> bytearray | None:
"""
Returns the current LUT
"""
return self.lut
def _string_permute(self, pattern: str, permutation: list[int]) -> str:
"""Takes a pattern and a permutation and returns the
string permuted according to the permutation list.
"""
assert len(permutation) == 9
return "".join(pattern[p] for p in permutation)
def _pattern_permute(
self, basic_pattern: str, options: str, basic_result: int
) -> list[tuple[str, int]]:
"""Takes a basic pattern and its result and clones
the pattern according to the modifications described in the $options
parameter. It returns a list of all cloned patterns."""
patterns = [(basic_pattern, basic_result)]
# rotations
if "4" in options:
res = patterns[-1][1]
for i in range(4):
patterns.append(
(self._string_permute(patterns[-1][0], ROTATION_MATRIX), res)
)
# mirror
if "M" in options:
n = len(patterns)
for pattern, res in patterns[:n]:
patterns.append((self._string_permute(pattern, MIRROR_MATRIX), res))
# negate
if "N" in options:
n = len(patterns)
for pattern, res in patterns[:n]:
# Swap 0 and 1
pattern = pattern.replace("0", "Z").replace("1", "0").replace("Z", "1")
res = 1 - int(res)
patterns.append((pattern, res))
return patterns
def build_lut(self) -> bytearray:
"""Compile all patterns into a morphology LUT, and return it.
This is the data to be passed into MorphOp."""
self.build_default_lut()
assert self.lut is not None
patterns = []
# Parse and create symmetries of the patterns strings
for p in self.patterns:
m = re.search(r"(\w):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", ""))
if not m:
msg = 'Syntax error in pattern "' + p + '"'
raise Exception(msg)
options = m.group(1)
pattern = m.group(2)
result = int(m.group(3))
# Get rid of spaces
pattern = pattern.replace(" ", "").replace("\n", "")
patterns += self._pattern_permute(pattern, options, result)
# Compile the patterns into regular expressions for speed
compiled_patterns = []
for pattern in patterns:
p = pattern[0].replace(".", "X").replace("X", "[01]")
compiled_patterns.append((re.compile(p), pattern[1]))
# Step through table and find patterns that match.
# Note that all the patterns are searched. The last one found takes priority
for i in range(LUT_SIZE):
# Build the bit pattern
bitpattern = bin(i)[2:]
bitpattern = ("0" * (9 - len(bitpattern)) + bitpattern)[::-1]
for pattern, r in compiled_patterns:
if pattern.match(bitpattern):
self.lut[i] = [0, 1][r]
return self.lut
class MorphOp:
"""A class for binary morphological operators"""
def __init__(
self,
lut: bytearray | None = None,
op_name: str | None = None,
patterns: list[str] | None = None,
) -> None:
"""Create a binary morphological operator.
If the LUT is not provided, then it is built using LutBuilder from the op_name
or the patterns.
:param lut: The LUT data.
:param patterns: A list of input patterns, or None.
:param op_name: The name of a known pattern. One of "corner", "dilation4",
"dilation8", "erosion4", "erosion8", "edge".
:exception Exception: If the op_name is not recognized.
"""
if patterns is None and op_name is None:
self.lut = lut
else:
self.lut = LutBuilder(patterns, op_name).build_lut()
def apply(self, image: Image.Image) -> tuple[int, Image.Image]:
"""Run a single morphological operation on an image.
Returns a tuple of the number of changed pixels and the
morphed image.
:param image: A 1-mode or L-mode image.
:exception Exception: If the current operator is None.
:exception ValueError: If the image is not 1 or L mode."""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
if image.mode not in ("1", "L"):
msg = "Image mode must be 1 or L"
raise ValueError(msg)
outimage = Image.new(image.mode, image.size)
count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim())
return count, outimage
def match(self, image: Image.Image) -> list[tuple[int, int]]:
"""Get a list of coordinates matching the morphological operation on
an image.
Returns a list of tuples of (x,y) coordinates of all matching pixels. See
:ref:`coordinate-system`.
:param image: A 1-mode or L-mode image.
:exception Exception: If the current operator is None.
:exception ValueError: If the image is not 1 or L mode."""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
if image.mode not in ("1", "L"):
msg = "Image mode must be 1 or L"
raise ValueError(msg)
return _imagingmorph.match(bytes(self.lut), image.getim())
def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]:
"""Get a list of all turned on pixels in a 1 or L mode image.
Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See
:ref:`coordinate-system`.
:param image: A 1-mode or L-mode image.
:exception ValueError: If the image is not 1 or L mode."""
if image.mode not in ("1", "L"):
msg = "Image mode must be 1 or L"
raise ValueError(msg)
return _imagingmorph.get_on_pixels(image.getim())
def load_lut(self, filename: str) -> None:
"""
Load an operator from an mrl file
:param filename: The file to read from.
:exception Exception: If the length of the file data is not 512.
"""
with open(filename, "rb") as f:
self.lut = bytearray(f.read())
if len(self.lut) != LUT_SIZE:
self.lut = None
msg = "Wrong size operator file!"
raise Exception(msg)
def save_lut(self, filename: str) -> None:
"""
Save an operator to an mrl file.
:param filename: The destination file.
:exception Exception: If the current operator is None.
"""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
with open(filename, "wb") as f:
f.write(self.lut)
def set_lut(self, lut: bytearray | None) -> None:
"""
Set the LUT from an external source
:param lut: A new LUT.
"""
self.lut = lut

View File

@@ -1,746 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# standard image operations
#
# History:
# 2001-10-20 fl Created
# 2001-10-23 fl Added autocontrast operator
# 2001-12-18 fl Added Kevin's fit operator
# 2004-03-14 fl Fixed potential division by zero in equalize
# 2005-05-05 fl Fixed equalize for low number of values
#
# Copyright (c) 2001-2004 by Secret Labs AB
# Copyright (c) 2001-2004 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import functools
import operator
import re
from collections.abc import Sequence
from typing import Literal, Protocol, cast, overload
from . import ExifTags, Image, ImagePalette
#
# helpers
def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]:
if isinstance(border, tuple):
if len(border) == 2:
left, top = right, bottom = border
elif len(border) == 4:
left, top, right, bottom = border
else:
left = top = right = bottom = border
return left, top, right, bottom
def _color(color: str | int | tuple[int, ...], mode: str) -> int | tuple[int, ...]:
if isinstance(color, str):
from . import ImageColor
color = ImageColor.getcolor(color, mode)
return color
def _lut(image: Image.Image, lut: list[int]) -> Image.Image:
if image.mode == "P":
# FIXME: apply to lookup table, not image data
msg = "mode P support coming soon"
raise NotImplementedError(msg)
elif image.mode in ("L", "RGB"):
if image.mode == "RGB" and len(lut) == 256:
lut = lut + lut + lut
return image.point(lut)
else:
msg = f"not supported for mode {image.mode}"
raise OSError(msg)
#
# actions
def autocontrast(
image: Image.Image,
cutoff: float | tuple[float, float] = 0,
ignore: int | Sequence[int] | None = None,
mask: Image.Image | None = None,
preserve_tone: bool = False,
) -> Image.Image:
"""
Maximize (normalize) image contrast. This function calculates a
histogram of the input image (or mask region), removes ``cutoff`` percent of the
lightest and darkest pixels from the histogram, and remaps the image
so that the darkest pixel becomes black (0), and the lightest
becomes white (255).
:param image: The image to process.
:param cutoff: The percent to cut off from the histogram on the low and
high ends. Either a tuple of (low, high), or a single
number for both.
:param ignore: The background pixel value (use None for no background).
:param mask: Histogram used in contrast operation is computed using pixels
within the mask. If no mask is given the entire image is used
for histogram computation.
:param preserve_tone: Preserve image tone in Photoshop-like style autocontrast.
.. versionadded:: 8.2.0
:return: An image.
"""
if preserve_tone:
histogram = image.convert("L").histogram(mask)
else:
histogram = image.histogram(mask)
lut = []
for layer in range(0, len(histogram), 256):
h = histogram[layer : layer + 256]
if ignore is not None:
# get rid of outliers
if isinstance(ignore, int):
h[ignore] = 0
else:
for ix in ignore:
h[ix] = 0
if cutoff:
# cut off pixels from both ends of the histogram
if not isinstance(cutoff, tuple):
cutoff = (cutoff, cutoff)
# get number of pixels
n = 0
for ix in range(256):
n = n + h[ix]
# remove cutoff% pixels from the low end
cut = int(n * cutoff[0] // 100)
for lo in range(256):
if cut > h[lo]:
cut = cut - h[lo]
h[lo] = 0
else:
h[lo] -= cut
cut = 0
if cut <= 0:
break
# remove cutoff% samples from the high end
cut = int(n * cutoff[1] // 100)
for hi in range(255, -1, -1):
if cut > h[hi]:
cut = cut - h[hi]
h[hi] = 0
else:
h[hi] -= cut
cut = 0
if cut <= 0:
break
# find lowest/highest samples after preprocessing
for lo in range(256):
if h[lo]:
break
for hi in range(255, -1, -1):
if h[hi]:
break
if hi <= lo:
# don't bother
lut.extend(list(range(256)))
else:
scale = 255.0 / (hi - lo)
offset = -lo * scale
for ix in range(256):
ix = int(ix * scale + offset)
if ix < 0:
ix = 0
elif ix > 255:
ix = 255
lut.append(ix)
return _lut(image, lut)
def colorize(
image: Image.Image,
black: str | tuple[int, ...],
white: str | tuple[int, ...],
mid: str | int | tuple[int, ...] | None = None,
blackpoint: int = 0,
whitepoint: int = 255,
midpoint: int = 127,
) -> Image.Image:
"""
Colorize grayscale image.
This function calculates a color wedge which maps all black pixels in
the source image to the first color and all white pixels to the
second color. If ``mid`` is specified, it uses three-color mapping.
The ``black`` and ``white`` arguments should be RGB tuples or color names;
optionally you can use three-color mapping by also specifying ``mid``.
Mapping positions for any of the colors can be specified
(e.g. ``blackpoint``), where these parameters are the integer
value corresponding to where the corresponding color should be mapped.
These parameters must have logical order, such that
``blackpoint <= midpoint <= whitepoint`` (if ``mid`` is specified).
:param image: The image to colorize.
:param black: The color to use for black input pixels.
:param white: The color to use for white input pixels.
:param mid: The color to use for midtone input pixels.
:param blackpoint: an int value [0, 255] for the black mapping.
:param whitepoint: an int value [0, 255] for the white mapping.
:param midpoint: an int value [0, 255] for the midtone mapping.
:return: An image.
"""
# Initial asserts
assert image.mode == "L"
if mid is None:
assert 0 <= blackpoint <= whitepoint <= 255
else:
assert 0 <= blackpoint <= midpoint <= whitepoint <= 255
# Define colors from arguments
rgb_black = cast(Sequence[int], _color(black, "RGB"))
rgb_white = cast(Sequence[int], _color(white, "RGB"))
rgb_mid = cast(Sequence[int], _color(mid, "RGB")) if mid is not None else None
# Empty lists for the mapping
red = []
green = []
blue = []
# Create the low-end values
for i in range(blackpoint):
red.append(rgb_black[0])
green.append(rgb_black[1])
blue.append(rgb_black[2])
# Create the mapping (2-color)
if rgb_mid is None:
range_map = range(whitepoint - blackpoint)
for i in range_map:
red.append(
rgb_black[0] + i * (rgb_white[0] - rgb_black[0]) // len(range_map)
)
green.append(
rgb_black[1] + i * (rgb_white[1] - rgb_black[1]) // len(range_map)
)
blue.append(
rgb_black[2] + i * (rgb_white[2] - rgb_black[2]) // len(range_map)
)
# Create the mapping (3-color)
else:
range_map1 = range(midpoint - blackpoint)
range_map2 = range(whitepoint - midpoint)
for i in range_map1:
red.append(
rgb_black[0] + i * (rgb_mid[0] - rgb_black[0]) // len(range_map1)
)
green.append(
rgb_black[1] + i * (rgb_mid[1] - rgb_black[1]) // len(range_map1)
)
blue.append(
rgb_black[2] + i * (rgb_mid[2] - rgb_black[2]) // len(range_map1)
)
for i in range_map2:
red.append(rgb_mid[0] + i * (rgb_white[0] - rgb_mid[0]) // len(range_map2))
green.append(
rgb_mid[1] + i * (rgb_white[1] - rgb_mid[1]) // len(range_map2)
)
blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2))
# Create the high-end values
for i in range(256 - whitepoint):
red.append(rgb_white[0])
green.append(rgb_white[1])
blue.append(rgb_white[2])
# Return converted image
image = image.convert("RGB")
return _lut(image, red + green + blue)
def contain(
image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC
) -> Image.Image:
"""
Returns a resized version of the image, set to the maximum width and height
within the requested size, while maintaining the original aspect ratio.
:param image: The image to resize.
:param size: The requested output size in pixels, given as a
(width, height) tuple.
:param method: Resampling method to use. Default is
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
See :ref:`concept-filters`.
:return: An image.
"""
im_ratio = image.width / image.height
dest_ratio = size[0] / size[1]
if im_ratio != dest_ratio:
if im_ratio > dest_ratio:
new_height = round(image.height / image.width * size[0])
if new_height != size[1]:
size = (size[0], new_height)
else:
new_width = round(image.width / image.height * size[1])
if new_width != size[0]:
size = (new_width, size[1])
return image.resize(size, resample=method)
def cover(
image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC
) -> Image.Image:
"""
Returns a resized version of the image, so that the requested size is
covered, while maintaining the original aspect ratio.
:param image: The image to resize.
:param size: The requested output size in pixels, given as a
(width, height) tuple.
:param method: Resampling method to use. Default is
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
See :ref:`concept-filters`.
:return: An image.
"""
im_ratio = image.width / image.height
dest_ratio = size[0] / size[1]
if im_ratio != dest_ratio:
if im_ratio < dest_ratio:
new_height = round(image.height / image.width * size[0])
if new_height != size[1]:
size = (size[0], new_height)
else:
new_width = round(image.width / image.height * size[1])
if new_width != size[0]:
size = (new_width, size[1])
return image.resize(size, resample=method)
def pad(
image: Image.Image,
size: tuple[int, int],
method: int = Image.Resampling.BICUBIC,
color: str | int | tuple[int, ...] | None = None,
centering: tuple[float, float] = (0.5, 0.5),
) -> Image.Image:
"""
Returns a resized and padded version of the image, expanded to fill the
requested aspect ratio and size.
:param image: The image to resize and crop.
:param size: The requested output size in pixels, given as a
(width, height) tuple.
:param method: Resampling method to use. Default is
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
See :ref:`concept-filters`.
:param color: The background color of the padded image.
:param centering: Control the position of the original image within the
padded version.
(0.5, 0.5) will keep the image centered
(0, 0) will keep the image aligned to the top left
(1, 1) will keep the image aligned to the bottom
right
:return: An image.
"""
resized = contain(image, size, method)
if resized.size == size:
out = resized
else:
out = Image.new(image.mode, size, color)
if resized.palette:
palette = resized.getpalette()
if palette is not None:
out.putpalette(palette)
if resized.width != size[0]:
x = round((size[0] - resized.width) * max(0, min(centering[0], 1)))
out.paste(resized, (x, 0))
else:
y = round((size[1] - resized.height) * max(0, min(centering[1], 1)))
out.paste(resized, (0, y))
return out
def crop(image: Image.Image, border: int = 0) -> Image.Image:
"""
Remove border from image. The same amount of pixels are removed
from all four sides. This function works on all image modes.
.. seealso:: :py:meth:`~PIL.Image.Image.crop`
:param image: The image to crop.
:param border: The number of pixels to remove.
:return: An image.
"""
left, top, right, bottom = _border(border)
return image.crop((left, top, image.size[0] - right, image.size[1] - bottom))
def scale(
image: Image.Image, factor: float, resample: int = Image.Resampling.BICUBIC
) -> Image.Image:
"""
Returns a rescaled image by a specific factor given in parameter.
A factor greater than 1 expands the image, between 0 and 1 contracts the
image.
:param image: The image to rescale.
:param factor: The expansion factor, as a float.
:param resample: Resampling method to use. Default is
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
See :ref:`concept-filters`.
:returns: An :py:class:`~PIL.Image.Image` object.
"""
if factor == 1:
return image.copy()
elif factor <= 0:
msg = "the factor must be greater than 0"
raise ValueError(msg)
else:
size = (round(factor * image.width), round(factor * image.height))
return image.resize(size, resample)
class SupportsGetMesh(Protocol):
"""
An object that supports the ``getmesh`` method, taking an image as an
argument, and returning a list of tuples. Each tuple contains two tuples,
the source box as a tuple of 4 integers, and a tuple of 8 integers for the
final quadrilateral, in order of top left, bottom left, bottom right, top
right.
"""
def getmesh(
self, image: Image.Image
) -> list[
tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]]
]: ...
def deform(
image: Image.Image,
deformer: SupportsGetMesh,
resample: int = Image.Resampling.BILINEAR,
) -> Image.Image:
"""
Deform the image.
:param image: The image to deform.
:param deformer: A deformer object. Any object that implements a
``getmesh`` method can be used.
:param resample: An optional resampling filter. Same values possible as
in the PIL.Image.transform function.
:return: An image.
"""
return image.transform(
image.size, Image.Transform.MESH, deformer.getmesh(image), resample
)
def equalize(image: Image.Image, mask: Image.Image | None = None) -> Image.Image:
"""
Equalize the image histogram. This function applies a non-linear
mapping to the input image, in order to create a uniform
distribution of grayscale values in the output image.
:param image: The image to equalize.
:param mask: An optional mask. If given, only the pixels selected by
the mask are included in the analysis.
:return: An image.
"""
if image.mode == "P":
image = image.convert("RGB")
h = image.histogram(mask)
lut = []
for b in range(0, len(h), 256):
histo = [_f for _f in h[b : b + 256] if _f]
if len(histo) <= 1:
lut.extend(list(range(256)))
else:
step = (functools.reduce(operator.add, histo) - histo[-1]) // 255
if not step:
lut.extend(list(range(256)))
else:
n = step // 2
for i in range(256):
lut.append(n // step)
n = n + h[i + b]
return _lut(image, lut)
def expand(
image: Image.Image,
border: int | tuple[int, ...] = 0,
fill: str | int | tuple[int, ...] = 0,
) -> Image.Image:
"""
Add border to the image
:param image: The image to expand.
:param border: Border width, in pixels.
:param fill: Pixel fill value (a color value). Default is 0 (black).
:return: An image.
"""
left, top, right, bottom = _border(border)
width = left + image.size[0] + right
height = top + image.size[1] + bottom
color = _color(fill, image.mode)
if image.palette:
mode = image.palette.mode
palette = ImagePalette.ImagePalette(mode, image.getpalette(mode))
if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4):
color = palette.getcolor(color)
else:
palette = None
out = Image.new(image.mode, (width, height), color)
if palette:
out.putpalette(palette.palette, mode)
out.paste(image, (left, top))
return out
def fit(
image: Image.Image,
size: tuple[int, int],
method: int = Image.Resampling.BICUBIC,
bleed: float = 0.0,
centering: tuple[float, float] = (0.5, 0.5),
) -> Image.Image:
"""
Returns a resized and cropped version of the image, cropped to the
requested aspect ratio and size.
This function was contributed by Kevin Cazabon.
:param image: The image to resize and crop.
:param size: The requested output size in pixels, given as a
(width, height) tuple.
:param method: Resampling method to use. Default is
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
See :ref:`concept-filters`.
:param bleed: Remove a border around the outside of the image from all
four edges. The value is a decimal percentage (use 0.01 for
one percent). The default value is 0 (no border).
Cannot be greater than or equal to 0.5.
:param centering: Control the cropping position. Use (0.5, 0.5) for
center cropping (e.g. if cropping the width, take 50% off
of the left side, and therefore 50% off the right side).
(0.0, 0.0) will crop from the top left corner (i.e. if
cropping the width, take all of the crop off of the right
side, and if cropping the height, take all of it off the
bottom). (1.0, 0.0) will crop from the bottom left
corner, etc. (i.e. if cropping the width, take all of the
crop off the left side, and if cropping the height take
none from the top, and therefore all off the bottom).
:return: An image.
"""
# by Kevin Cazabon, Feb 17/2000
# kevin@cazabon.com
# https://www.cazabon.com
centering_x, centering_y = centering
if not 0.0 <= centering_x <= 1.0:
centering_x = 0.5
if not 0.0 <= centering_y <= 1.0:
centering_y = 0.5
if not 0.0 <= bleed < 0.5:
bleed = 0.0
# calculate the area to use for resizing and cropping, subtracting
# the 'bleed' around the edges
# number of pixels to trim off on Top and Bottom, Left and Right
bleed_pixels = (bleed * image.size[0], bleed * image.size[1])
live_size = (
image.size[0] - bleed_pixels[0] * 2,
image.size[1] - bleed_pixels[1] * 2,
)
# calculate the aspect ratio of the live_size
live_size_ratio = live_size[0] / live_size[1]
# calculate the aspect ratio of the output image
output_ratio = size[0] / size[1]
# figure out if the sides or top/bottom will be cropped off
if live_size_ratio == output_ratio:
# live_size is already the needed ratio
crop_width = live_size[0]
crop_height = live_size[1]
elif live_size_ratio >= output_ratio:
# live_size is wider than what's needed, crop the sides
crop_width = output_ratio * live_size[1]
crop_height = live_size[1]
else:
# live_size is taller than what's needed, crop the top and bottom
crop_width = live_size[0]
crop_height = live_size[0] / output_ratio
# make the crop
crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering_x
crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering_y
crop = (crop_left, crop_top, crop_left + crop_width, crop_top + crop_height)
# resize the image and return it
return image.resize(size, method, box=crop)
def flip(image: Image.Image) -> Image.Image:
"""
Flip the image vertically (top to bottom).
:param image: The image to flip.
:return: An image.
"""
return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
def grayscale(image: Image.Image) -> Image.Image:
"""
Convert the image to grayscale.
:param image: The image to convert.
:return: An image.
"""
return image.convert("L")
def invert(image: Image.Image) -> Image.Image:
"""
Invert (negate) the image.
:param image: The image to invert.
:return: An image.
"""
lut = list(range(255, -1, -1))
return image.point(lut) if image.mode == "1" else _lut(image, lut)
def mirror(image: Image.Image) -> Image.Image:
"""
Flip image horizontally (left to right).
:param image: The image to mirror.
:return: An image.
"""
return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
def posterize(image: Image.Image, bits: int) -> Image.Image:
"""
Reduce the number of bits for each color channel.
:param image: The image to posterize.
:param bits: The number of bits to keep for each channel (1-8).
:return: An image.
"""
mask = ~(2 ** (8 - bits) - 1)
lut = [i & mask for i in range(256)]
return _lut(image, lut)
def solarize(image: Image.Image, threshold: int = 128) -> Image.Image:
"""
Invert all pixel values above a threshold.
:param image: The image to solarize.
:param threshold: All pixels above this grayscale level are inverted.
:return: An image.
"""
lut = []
for i in range(256):
if i < threshold:
lut.append(i)
else:
lut.append(255 - i)
return _lut(image, lut)
@overload
def exif_transpose(image: Image.Image, *, in_place: Literal[True]) -> None: ...
@overload
def exif_transpose(
image: Image.Image, *, in_place: Literal[False] = False
) -> Image.Image: ...
def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None:
"""
If an image has an EXIF Orientation tag, other than 1, transpose the image
accordingly, and remove the orientation data.
:param image: The image to transpose.
:param in_place: Boolean. Keyword-only argument.
If ``True``, the original image is modified in-place, and ``None`` is returned.
If ``False`` (default), a new :py:class:`~PIL.Image.Image` object is returned
with the transposition applied. If there is no transposition, a copy of the
image will be returned.
"""
image.load()
image_exif = image.getexif()
orientation = image_exif.get(ExifTags.Base.Orientation, 1)
method = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
4: Image.Transpose.FLIP_TOP_BOTTOM,
5: Image.Transpose.TRANSPOSE,
6: Image.Transpose.ROTATE_270,
7: Image.Transpose.TRANSVERSE,
8: Image.Transpose.ROTATE_90,
}.get(orientation)
if method is not None:
if in_place:
image.im = image.im.transpose(method)
image._size = image.im.size
else:
transposed_image = image.transpose(method)
exif_image = image if in_place else transposed_image
exif = exif_image.getexif()
if ExifTags.Base.Orientation in exif:
del exif[ExifTags.Base.Orientation]
if "exif" in exif_image.info:
exif_image.info["exif"] = exif.tobytes()
elif "Raw profile type exif" in exif_image.info:
exif_image.info["Raw profile type exif"] = exif.tobytes().hex()
for key in ("XML:com.adobe.xmp", "xmp"):
if key in exif_image.info:
for pattern in (
r'tiff:Orientation="([0-9])"',
r"<tiff:Orientation>([0-9])</tiff:Orientation>",
):
value = exif_image.info[key]
if isinstance(value, str):
value = re.sub(pattern, "", value)
elif isinstance(value, tuple):
value = tuple(
re.sub(pattern.encode(), b"", v) for v in value
)
else:
value = re.sub(pattern.encode(), b"", value)
exif_image.info[key] = value
if not in_place:
return transposed_image
elif not in_place:
return image.copy()
return None

View File

@@ -1,290 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# image palette object
#
# History:
# 1996-03-11 fl Rewritten.
# 1997-01-03 fl Up and running.
# 1997-08-23 fl Added load hack
# 2001-04-16 fl Fixed randint shadow bug in random()
#
# Copyright (c) 1997-2001 by Secret Labs AB
# Copyright (c) 1996-1997 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import array
from collections.abc import Sequence
from typing import IO
from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile
TYPE_CHECKING = False
if TYPE_CHECKING:
from . import Image
class ImagePalette:
"""
Color palette for palette mapped images
:param mode: The mode to use for the palette. See:
:ref:`concept-modes`. Defaults to "RGB"
:param palette: An optional palette. If given, it must be a bytearray,
an array or a list of ints between 0-255. The list must consist of
all channels for one color followed by the next color (e.g. RGBRGBRGB).
Defaults to an empty palette.
"""
def __init__(
self,
mode: str = "RGB",
palette: Sequence[int] | bytes | bytearray | None = None,
) -> None:
self.mode = mode
self.rawmode: str | None = None # if set, palette contains raw data
self.palette = palette or bytearray()
self.dirty: int | None = None
@property
def palette(self) -> Sequence[int] | bytes | bytearray:
return self._palette
@palette.setter
def palette(self, palette: Sequence[int] | bytes | bytearray) -> None:
self._colors: dict[tuple[int, ...], int] | None = None
self._palette = palette
@property
def colors(self) -> dict[tuple[int, ...], int]:
if self._colors is None:
mode_len = len(self.mode)
self._colors = {}
for i in range(0, len(self.palette), mode_len):
color = tuple(self.palette[i : i + mode_len])
if color in self._colors:
continue
self._colors[color] = i // mode_len
return self._colors
@colors.setter
def colors(self, colors: dict[tuple[int, ...], int]) -> None:
self._colors = colors
def copy(self) -> ImagePalette:
new = ImagePalette()
new.mode = self.mode
new.rawmode = self.rawmode
if self.palette is not None:
new.palette = self.palette[:]
new.dirty = self.dirty
return new
def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]:
"""
Get palette contents in format suitable for the low-level
``im.putpalette`` primitive.
.. warning:: This method is experimental.
"""
if self.rawmode:
return self.rawmode, self.palette
return self.mode, self.tobytes()
def tobytes(self) -> bytes:
"""Convert palette to bytes.
.. warning:: This method is experimental.
"""
if self.rawmode:
msg = "palette contains raw palette data"
raise ValueError(msg)
if isinstance(self.palette, bytes):
return self.palette
arr = array.array("B", self.palette)
return arr.tobytes()
# Declare tostring as an alias for tobytes
tostring = tobytes
def _new_color_index(
self, image: Image.Image | None = None, e: Exception | None = None
) -> int:
if not isinstance(self.palette, bytearray):
self._palette = bytearray(self.palette)
index = len(self.palette) // len(self.mode)
special_colors: tuple[int | tuple[int, ...] | None, ...] = ()
if image:
special_colors = (
image.info.get("background"),
image.info.get("transparency"),
)
while index in special_colors:
index += 1
if index >= 256:
if image:
# Search for an unused index
for i, count in reversed(list(enumerate(image.histogram()))):
if count == 0 and i not in special_colors:
index = i
break
if index >= 256:
msg = "cannot allocate more than 256 colors"
raise ValueError(msg) from e
return index
def getcolor(
self,
color: tuple[int, ...],
image: Image.Image | None = None,
) -> int:
"""Given an rgb tuple, allocate palette entry.
.. warning:: This method is experimental.
"""
if self.rawmode:
msg = "palette contains raw palette data"
raise ValueError(msg)
if isinstance(color, tuple):
if self.mode == "RGB":
if len(color) == 4:
if color[3] != 255:
msg = "cannot add non-opaque RGBA color to RGB palette"
raise ValueError(msg)
color = color[:3]
elif self.mode == "RGBA":
if len(color) == 3:
color += (255,)
try:
return self.colors[color]
except KeyError as e:
# allocate new color slot
index = self._new_color_index(image, e)
assert isinstance(self._palette, bytearray)
self.colors[color] = index
mode_len = len(self.mode)
if index * mode_len < len(self.palette):
self._palette = (
self._palette[: index * mode_len]
+ bytes(color)
+ self._palette[index * mode_len + mode_len :]
)
else:
self._palette += bytes(color)
self.dirty = 1
return index
else:
msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable]
raise ValueError(msg)
def save(self, fp: str | IO[str]) -> None:
"""Save palette to text file.
.. warning:: This method is experimental.
"""
if self.rawmode:
msg = "palette contains raw palette data"
raise ValueError(msg)
open_fp = False
if isinstance(fp, str):
fp = open(fp, "w")
open_fp = True
try:
fp.write("# Palette\n")
fp.write(f"# Mode: {self.mode}\n")
palette_len = len(self.palette)
for i in range(256):
fp.write(f"{i}")
for j in range(i * len(self.mode), (i + 1) * len(self.mode)):
fp.write(f" {self.palette[j] if j < palette_len else 0}")
fp.write("\n")
finally:
if open_fp:
fp.close()
# --------------------------------------------------------------------
# Internal
def raw(rawmode: str, data: Sequence[int] | bytes | bytearray) -> ImagePalette:
palette = ImagePalette()
palette.rawmode = rawmode
palette.palette = data
palette.dirty = 1
return palette
# --------------------------------------------------------------------
# Factories
def make_linear_lut(black: int, white: float) -> list[int]:
if black == 0:
return [int(white * i // 255) for i in range(256)]
msg = "unavailable when black is non-zero"
raise NotImplementedError(msg) # FIXME
def make_gamma_lut(exp: float) -> list[int]:
return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)]
def negative(mode: str = "RGB") -> ImagePalette:
palette = list(range(256 * len(mode)))
palette.reverse()
return ImagePalette(mode, [i // len(mode) for i in palette])
def random(mode: str = "RGB") -> ImagePalette:
from random import randint
palette = [randint(0, 255) for _ in range(256 * len(mode))]
return ImagePalette(mode, palette)
def sepia(white: str = "#fff0c0") -> ImagePalette:
bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)]
return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)])
def wedge(mode: str = "RGB") -> ImagePalette:
palette = list(range(256 * len(mode)))
return ImagePalette(mode, [i // len(mode) for i in palette])
def load(filename: str) -> tuple[bytes, str]:
# FIXME: supports GIMP gradients only
with open(filename, "rb") as fp:
paletteHandlers: list[
type[
GimpPaletteFile.GimpPaletteFile
| GimpGradientFile.GimpGradientFile
| PaletteFile.PaletteFile
]
] = [
GimpPaletteFile.GimpPaletteFile,
GimpGradientFile.GimpGradientFile,
PaletteFile.PaletteFile,
]
for paletteHandler in paletteHandlers:
try:
fp.seek(0)
lut = paletteHandler(fp).getpalette()
if lut:
break
except (SyntaxError, ValueError):
pass
else:
msg = "cannot load palette"
raise OSError(msg)
return lut # data, rawmode

View File

@@ -1,20 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# path interface
#
# History:
# 1996-11-04 fl Created
# 2002-04-14 fl Added documentation stub class
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1996.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image
Path = Image.core.path

View File

@@ -1,219 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# a simple Qt image interface.
#
# history:
# 2006-06-03 fl: created
# 2006-06-04 fl: inherit from QImage instead of wrapping it
# 2006-06-05 fl: removed toimage helper; move string support to ImageQt
# 2013-11-13 fl: add support for Qt5 (aurelien.ballier@cyclonit.com)
#
# Copyright (c) 2006 by Secret Labs AB
# Copyright (c) 2006 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import sys
from io import BytesIO
from . import Image
from ._util import is_path
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any
from . import ImageFile
QBuffer: type
qt_version: str | None
qt_versions = [
["6", "PyQt6"],
["side6", "PySide6"],
]
# If a version has already been imported, attempt it first
qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True)
for version, qt_module in qt_versions:
try:
qRgba: Callable[[int, int, int, int], int]
if qt_module == "PyQt6":
from PyQt6.QtCore import QBuffer, QByteArray, QIODevice
from PyQt6.QtGui import QImage, QPixmap, qRgba
elif qt_module == "PySide6":
from PySide6.QtCore import ( # type: ignore[assignment]
QBuffer,
QByteArray,
QIODevice,
)
from PySide6.QtGui import QImage, QPixmap, qRgba # type: ignore[assignment]
except (ImportError, RuntimeError):
continue
qt_is_installed = True
qt_version = version
break
else:
qt_is_installed = False
qt_version = None
def rgb(r: int, g: int, b: int, a: int = 255) -> int:
"""(Internal) Turns an RGB color into a Qt compatible color integer."""
# use qRgb to pack the colors, and then turn the resulting long
# into a negative integer with the same bitpattern.
return qRgba(r, g, b, a) & 0xFFFFFFFF
def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile:
"""
:param im: QImage or PIL ImageQt object
"""
buffer = QBuffer()
qt_openmode: object
if qt_version == "6":
try:
qt_openmode = getattr(QIODevice, "OpenModeFlag")
except AttributeError:
qt_openmode = getattr(QIODevice, "OpenMode")
else:
qt_openmode = QIODevice
buffer.open(getattr(qt_openmode, "ReadWrite"))
# preserve alpha channel with png
# otherwise ppm is more friendly with Image.open
if im.hasAlphaChannel():
im.save(buffer, "png")
else:
im.save(buffer, "ppm")
b = BytesIO()
b.write(buffer.data())
buffer.close()
b.seek(0)
return Image.open(b)
def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile:
return fromqimage(im)
def align8to32(bytes: bytes, width: int, mode: str) -> bytes:
"""
converts each scanline of data from 8 bit to 32 bit aligned
"""
bits_per_pixel = {"1": 1, "L": 8, "P": 8, "I;16": 16}[mode]
# calculate bytes per line and the extra padding if needed
bits_per_line = bits_per_pixel * width
full_bytes_per_line, remaining_bits_per_line = divmod(bits_per_line, 8)
bytes_per_line = full_bytes_per_line + (1 if remaining_bits_per_line else 0)
extra_padding = -bytes_per_line % 4
# already 32 bit aligned by luck
if not extra_padding:
return bytes
new_data = [
bytes[i * bytes_per_line : (i + 1) * bytes_per_line] + b"\x00" * extra_padding
for i in range(len(bytes) // bytes_per_line)
]
return b"".join(new_data)
def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]:
data = None
colortable = None
exclusive_fp = False
# handle filename, if given instead of image name
if hasattr(im, "toUtf8"):
# FIXME - is this really the best way to do this?
im = str(im.toUtf8(), "utf-8")
if is_path(im):
im = Image.open(im)
exclusive_fp = True
assert isinstance(im, Image.Image)
qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage
if im.mode == "1":
format = getattr(qt_format, "Format_Mono")
elif im.mode == "L":
format = getattr(qt_format, "Format_Indexed8")
colortable = [rgb(i, i, i) for i in range(256)]
elif im.mode == "P":
format = getattr(qt_format, "Format_Indexed8")
palette = im.getpalette()
assert palette is not None
colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)]
elif im.mode == "RGB":
# Populate the 4th channel with 255
im = im.convert("RGBA")
data = im.tobytes("raw", "BGRA")
format = getattr(qt_format, "Format_RGB32")
elif im.mode == "RGBA":
data = im.tobytes("raw", "BGRA")
format = getattr(qt_format, "Format_ARGB32")
elif im.mode == "I;16":
im = im.point(lambda i: i * 256)
format = getattr(qt_format, "Format_Grayscale16")
else:
if exclusive_fp:
im.close()
msg = f"unsupported image mode {repr(im.mode)}"
raise ValueError(msg)
size = im.size
__data = data or align8to32(im.tobytes(), size[0], im.mode)
if exclusive_fp:
im.close()
return {"data": __data, "size": size, "format": format, "colortable": colortable}
if qt_is_installed:
class ImageQt(QImage):
def __init__(self, im: Image.Image | str | QByteArray) -> None:
"""
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
class.
:param im: A PIL Image object, or a file name (given either as
Python string or a PyQt string object).
"""
im_data = _toqclass_helper(im)
# must keep a reference, or Qt will crash!
# All QImage constructors that take data operate on an existing
# buffer, so this buffer has to hang on for the life of the image.
# Fixes https://github.com/python-pillow/Pillow/issues/1370
self.__data = im_data["data"]
super().__init__(
self.__data,
im_data["size"][0],
im_data["size"][1],
im_data["format"],
)
if im_data["colortable"]:
self.setColorTable(im_data["colortable"])
def toqimage(im: Image.Image | str | QByteArray) -> ImageQt:
return ImageQt(im)
def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap:
qimage = toqimage(im)
pixmap = getattr(QPixmap, "fromImage")(qimage)
if qt_version == "6":
pixmap.detach()
return pixmap

View File

@@ -1,88 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# sequence support classes
#
# history:
# 1997-02-20 fl Created
#
# Copyright (c) 1997 by Secret Labs AB.
# Copyright (c) 1997 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
##
from __future__ import annotations
from . import Image
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
class Iterator:
"""
This class implements an iterator object that can be used to loop
over an image sequence.
You can use the ``[]`` operator to access elements by index. This operator
will raise an :py:exc:`IndexError` if you try to access a nonexistent
frame.
:param im: An image object.
"""
def __init__(self, im: Image.Image) -> None:
if not hasattr(im, "seek"):
msg = "im must have seek method"
raise AttributeError(msg)
self.im = im
self.position = getattr(self.im, "_min_frame", 0)
def __getitem__(self, ix: int) -> Image.Image:
try:
self.im.seek(ix)
return self.im
except EOFError as e:
msg = "end of sequence"
raise IndexError(msg) from e
def __iter__(self) -> Iterator:
return self
def __next__(self) -> Image.Image:
try:
self.im.seek(self.position)
self.position += 1
return self.im
except EOFError as e:
msg = "end of sequence"
raise StopIteration(msg) from e
def all_frames(
im: Image.Image | list[Image.Image],
func: Callable[[Image.Image], Image.Image] | None = None,
) -> list[Image.Image]:
"""
Applies a given function to all frames in an image or a list of images.
The frames are returned as a list of separate images.
:param im: An image, or a list of images.
:param func: The function to apply to all of the image frames.
:returns: A list of images.
"""
if not isinstance(im, list):
im = [im]
ims = []
for imSequence in im:
current = imSequence.tell()
ims += [im_frame.copy() for im_frame in Iterator(imSequence)]
imSequence.seek(current)
return [func(im) for im in ims] if func else ims

View File

@@ -1,362 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# im.show() drivers
#
# History:
# 2008-04-06 fl Created
#
# Copyright (c) Secret Labs AB 2008.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import abc
import os
import shutil
import subprocess
import sys
from shlex import quote
from typing import Any
from . import Image
_viewers = []
def register(viewer: type[Viewer] | Viewer, order: int = 1) -> None:
"""
The :py:func:`register` function is used to register additional viewers::
from PIL import ImageShow
ImageShow.register(MyViewer()) # MyViewer will be used as a last resort
ImageShow.register(MySecondViewer(), 0) # MySecondViewer will be prioritised
ImageShow.register(ImageShow.XVViewer(), 0) # XVViewer will be prioritised
:param viewer: The viewer to be registered.
:param order:
Zero or a negative integer to prepend this viewer to the list,
a positive integer to append it.
"""
if isinstance(viewer, type) and issubclass(viewer, Viewer):
viewer = viewer()
if order > 0:
_viewers.append(viewer)
else:
_viewers.insert(0, viewer)
def show(image: Image.Image, title: str | None = None, **options: Any) -> bool:
r"""
Display a given image.
:param image: An image object.
:param title: Optional title. Not all viewers can display the title.
:param \**options: Additional viewer options.
:returns: ``True`` if a suitable viewer was found, ``False`` otherwise.
"""
for viewer in _viewers:
if viewer.show(image, title=title, **options):
return True
return False
class Viewer:
"""Base class for viewers."""
# main api
def show(self, image: Image.Image, **options: Any) -> int:
"""
The main function for displaying an image.
Converts the given image to the target format and displays it.
"""
if not (
image.mode in ("1", "RGBA")
or (self.format == "PNG" and image.mode in ("I;16", "LA"))
):
base = Image.getmodebase(image.mode)
if image.mode != base:
image = image.convert(base)
return self.show_image(image, **options)
# hook methods
format: str | None = None
"""The format to convert the image into."""
options: dict[str, Any] = {}
"""Additional options used to convert the image."""
def get_format(self, image: Image.Image) -> str | None:
"""Return format name, or ``None`` to save as PGM/PPM."""
return self.format
def get_command(self, file: str, **options: Any) -> str:
"""
Returns the command used to display the file.
Not implemented in the base class.
"""
msg = "unavailable in base viewer"
raise NotImplementedError(msg)
def save_image(self, image: Image.Image) -> str:
"""Save to temporary file and return filename."""
return image._dump(format=self.get_format(image), **self.options)
def show_image(self, image: Image.Image, **options: Any) -> int:
"""Display the given image."""
return self.show_file(self.save_image(image), **options)
def show_file(self, path: str, **options: Any) -> int:
"""
Display given file.
"""
if not os.path.exists(path):
raise FileNotFoundError
os.system(self.get_command(path, **options)) # nosec
return 1
# --------------------------------------------------------------------
class WindowsViewer(Viewer):
"""The default viewer on Windows is the default system application for PNG files."""
format = "PNG"
options = {"compress_level": 1, "save_all": True}
def get_command(self, file: str, **options: Any) -> str:
return (
f'start "Pillow" /WAIT "{file}" '
"&& ping -n 4 127.0.0.1 >NUL "
f'&& del /f "{file}"'
)
def show_file(self, path: str, **options: Any) -> int:
"""
Display given file.
"""
if not os.path.exists(path):
raise FileNotFoundError
subprocess.Popen(
self.get_command(path, **options),
shell=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW"),
) # nosec
return 1
if sys.platform == "win32":
register(WindowsViewer)
class MacViewer(Viewer):
"""The default viewer on macOS using ``Preview.app``."""
format = "PNG"
options = {"compress_level": 1, "save_all": True}
def get_command(self, file: str, **options: Any) -> str:
# on darwin open returns immediately resulting in the temp
# file removal while app is opening
command = "open -a Preview.app"
command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&"
return command
def show_file(self, path: str, **options: Any) -> int:
"""
Display given file.
"""
if not os.path.exists(path):
raise FileNotFoundError
subprocess.call(["open", "-a", "Preview.app", path])
pyinstaller = getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
executable = (not pyinstaller and sys.executable) or shutil.which("python3")
if executable:
subprocess.Popen(
[
executable,
"-c",
"import os, sys, time; time.sleep(20); os.remove(sys.argv[1])",
path,
]
)
return 1
if sys.platform == "darwin":
register(MacViewer)
class UnixViewer(abc.ABC, Viewer):
format = "PNG"
options = {"compress_level": 1, "save_all": True}
@abc.abstractmethod
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
pass
def get_command(self, file: str, **options: Any) -> str:
command = self.get_command_ex(file, **options)[0]
return f"{command} {quote(file)}"
class XDGViewer(UnixViewer):
"""
The freedesktop.org ``xdg-open`` command.
"""
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
command = executable = "xdg-open"
return command, executable
def show_file(self, path: str, **options: Any) -> int:
"""
Display given file.
"""
if not os.path.exists(path):
raise FileNotFoundError
subprocess.Popen(["xdg-open", path])
return 1
class DisplayViewer(UnixViewer):
"""
The ImageMagick ``display`` command.
This viewer supports the ``title`` parameter.
"""
def get_command_ex(
self, file: str, title: str | None = None, **options: Any
) -> tuple[str, str]:
command = executable = "display"
if title:
command += f" -title {quote(title)}"
return command, executable
def show_file(self, path: str, **options: Any) -> int:
"""
Display given file.
"""
if not os.path.exists(path):
raise FileNotFoundError
args = ["display"]
title = options.get("title")
if title:
args += ["-title", title]
args.append(path)
subprocess.Popen(args)
return 1
class GmDisplayViewer(UnixViewer):
"""The GraphicsMagick ``gm display`` command."""
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
executable = "gm"
command = "gm display"
return command, executable
def show_file(self, path: str, **options: Any) -> int:
"""
Display given file.
"""
if not os.path.exists(path):
raise FileNotFoundError
subprocess.Popen(["gm", "display", path])
return 1
class EogViewer(UnixViewer):
"""The GNOME Image Viewer ``eog`` command."""
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
executable = "eog"
command = "eog -n"
return command, executable
def show_file(self, path: str, **options: Any) -> int:
"""
Display given file.
"""
if not os.path.exists(path):
raise FileNotFoundError
subprocess.Popen(["eog", "-n", path])
return 1
class XVViewer(UnixViewer):
"""
The X Viewer ``xv`` command.
This viewer supports the ``title`` parameter.
"""
def get_command_ex(
self, file: str, title: str | None = None, **options: Any
) -> tuple[str, str]:
# note: xv is pretty outdated. most modern systems have
# imagemagick's display command instead.
command = executable = "xv"
if title:
command += f" -name {quote(title)}"
return command, executable
def show_file(self, path: str, **options: Any) -> int:
"""
Display given file.
"""
if not os.path.exists(path):
raise FileNotFoundError
args = ["xv"]
title = options.get("title")
if title:
args += ["-name", title]
args.append(path)
subprocess.Popen(args)
return 1
if sys.platform not in ("win32", "darwin"): # unixoids
if shutil.which("xdg-open"):
register(XDGViewer)
if shutil.which("display"):
register(DisplayViewer)
if shutil.which("gm"):
register(GmDisplayViewer)
if shutil.which("eog"):
register(EogViewer)
if shutil.which("xv"):
register(XVViewer)
class IPythonViewer(Viewer):
"""The viewer for IPython frontends."""
def show_image(self, image: Image.Image, **options: Any) -> int:
ipython_display(image)
return 1
try:
from IPython.display import display as ipython_display
except ImportError:
pass
else:
register(IPythonViewer)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Syntax: python3 ImageShow.py imagefile [title]")
sys.exit()
with Image.open(sys.argv[1]) as im:
print(show(im, *sys.argv[2:]))

View File

@@ -1,167 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# global image statistics
#
# History:
# 1996-04-05 fl Created
# 1997-05-21 fl Added mask; added rms, var, stddev attributes
# 1997-08-05 fl Added median
# 1998-07-05 hk Fixed integer overflow error
#
# Notes:
# This class shows how to implement delayed evaluation of attributes.
# To get a certain value, simply access the corresponding attribute.
# The __getattr__ dispatcher takes care of the rest.
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1996-97.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import math
from functools import cached_property
from . import Image
class Stat:
def __init__(
self, image_or_list: Image.Image | list[int], mask: Image.Image | None = None
) -> None:
"""
Calculate statistics for the given image. If a mask is included,
only the regions covered by that mask are included in the
statistics. You can also pass in a previously calculated histogram.
:param image: A PIL image, or a precalculated histogram.
.. note::
For a PIL image, calculations rely on the
:py:meth:`~PIL.Image.Image.histogram` method. The pixel counts are
grouped into 256 bins, even if the image has more than 8 bits per
channel. So ``I`` and ``F`` mode images have a maximum ``mean``,
``median`` and ``rms`` of 255, and cannot have an ``extrema`` maximum
of more than 255.
:param mask: An optional mask.
"""
if isinstance(image_or_list, Image.Image):
self.h = image_or_list.histogram(mask)
elif isinstance(image_or_list, list):
self.h = image_or_list
else:
msg = "first argument must be image or list" # type: ignore[unreachable]
raise TypeError(msg)
self.bands = list(range(len(self.h) // 256))
@cached_property
def extrema(self) -> list[tuple[int, int]]:
"""
Min/max values for each band in the image.
.. note::
This relies on the :py:meth:`~PIL.Image.Image.histogram` method, and
simply returns the low and high bins used. This is correct for
images with 8 bits per channel, but fails for other modes such as
``I`` or ``F``. Instead, use :py:meth:`~PIL.Image.Image.getextrema` to
return per-band extrema for the image. This is more correct and
efficient because, for non-8-bit modes, the histogram method uses
:py:meth:`~PIL.Image.Image.getextrema` to determine the bins used.
"""
def minmax(histogram: list[int]) -> tuple[int, int]:
res_min, res_max = 255, 0
for i in range(256):
if histogram[i]:
res_min = i
break
for i in range(255, -1, -1):
if histogram[i]:
res_max = i
break
return res_min, res_max
return [minmax(self.h[i:]) for i in range(0, len(self.h), 256)]
@cached_property
def count(self) -> list[int]:
"""Total number of pixels for each band in the image."""
return [sum(self.h[i : i + 256]) for i in range(0, len(self.h), 256)]
@cached_property
def sum(self) -> list[float]:
"""Sum of all pixels for each band in the image."""
v = []
for i in range(0, len(self.h), 256):
layer_sum = 0.0
for j in range(256):
layer_sum += j * self.h[i + j]
v.append(layer_sum)
return v
@cached_property
def sum2(self) -> list[float]:
"""Squared sum of all pixels for each band in the image."""
v = []
for i in range(0, len(self.h), 256):
sum2 = 0.0
for j in range(256):
sum2 += (j**2) * float(self.h[i + j])
v.append(sum2)
return v
@cached_property
def mean(self) -> list[float]:
"""Average (arithmetic mean) pixel level for each band in the image."""
return [self.sum[i] / self.count[i] if self.count[i] else 0 for i in self.bands]
@cached_property
def median(self) -> list[int]:
"""Median pixel level for each band in the image."""
v = []
for i in self.bands:
s = 0
half = self.count[i] // 2
b = i * 256
for j in range(256):
s = s + self.h[b + j]
if s > half:
break
v.append(j)
return v
@cached_property
def rms(self) -> list[float]:
"""RMS (root-mean-square) for each band in the image."""
return [
math.sqrt(self.sum2[i] / self.count[i]) if self.count[i] else 0
for i in self.bands
]
@cached_property
def var(self) -> list[float]:
"""Variance for each band in the image."""
return [
(
(self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i]
if self.count[i]
else 0
)
for i in self.bands
]
@cached_property
def stddev(self) -> list[float]:
"""Standard deviation for each band in the image."""
return [math.sqrt(self.var[i]) for i in self.bands]
Global = Stat # compatibility

View File

@@ -1,508 +0,0 @@
from __future__ import annotations
import math
import re
from typing import AnyStr, Generic, NamedTuple
from . import ImageFont
from ._typing import _Ink
Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont
class _Line(NamedTuple):
x: float
y: float
anchor: str
text: str | bytes
class _Wrap(Generic[AnyStr]):
lines: list[AnyStr] = []
position = 0
offset = 0
def __init__(
self,
text: Text[AnyStr],
width: int,
height: int | None = None,
font: Font | None = None,
) -> None:
self.text: Text[AnyStr] = text
self.width = width
self.height = height
self.font = font
input_text = self.text.text
emptystring = "" if isinstance(input_text, str) else b""
line = emptystring
for word in re.findall(
r"\s*\S+" if isinstance(input_text, str) else rb"\s*\S+", input_text
):
newlines = re.findall(
r"[^\S\n]*\n" if isinstance(input_text, str) else rb"[^\S\n]*\n", word
)
if newlines:
if not self.add_line(line):
break
for i, line in enumerate(newlines):
if i != 0 and not self.add_line(emptystring):
break
self.position += len(line)
word = word[len(line) :]
line = emptystring
new_line = line + word
if self.text._get_bbox(new_line, self.font)[2] <= width:
# This word fits on the line
line = new_line
continue
# This word does not fit on the line
if line and not self.add_line(line):
break
original_length = len(word)
word = word.lstrip()
self.offset = original_length - len(word)
if self.text._get_bbox(word, self.font)[2] > width:
if font is None:
msg = "Word does not fit within line"
raise ValueError(msg)
break
line = word
else:
if line:
self.add_line(line)
self.remaining_text: AnyStr = input_text[self.position :]
def add_line(self, line: AnyStr) -> bool:
lines = self.lines + [line]
if self.height is not None:
last_line_y = self.text._split(lines=lines)[-1].y
last_line_height = self.text._get_bbox(line, self.font)[3]
if last_line_y + last_line_height > self.height:
return False
self.lines = lines
self.position += len(line) + self.offset
self.offset = 0
return True
class Text(Generic[AnyStr]):
def __init__(
self,
text: AnyStr,
font: Font | None = None,
mode: str = "RGB",
spacing: float = 4,
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
) -> None:
"""
:param text: String to be drawn.
:param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance,
:py:class:`~PIL.ImageFont.FreeTypeFont` instance,
:py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If
``None``, the default font from :py:meth:`.ImageFont.load_default`
will be used.
:param mode: The image mode this will be used with.
:param spacing: The number of pixels between lines.
:param direction: Direction of the text. It can be ``"rtl"`` (right to left),
``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.
:param features: A list of OpenType font features to be used during text
layout. This is usually used to turn on optional font features
that are not enabled by default, for example ``"dlig"`` or
``"ss01"``, but can be also used to turn off default font
features, for example ``"-liga"`` to disable ligatures or
``"-kern"`` to disable kerning. To get all supported
features, see `OpenType docs`_.
Requires libraqm.
:param language: Language of the text. Different languages may use
different glyph shapes or ligatures. This parameter tells
the font which language the text is in, and to apply the
correct substitutions as appropriate, if available.
It should be a `BCP 47 language code`_.
Requires libraqm.
"""
self.text: AnyStr = text
self.font = font or ImageFont.load_default()
self.mode = mode
self.spacing = spacing
self.direction = direction
self.features = features
self.language = language
self.embedded_color = False
self.stroke_width: float = 0
self.stroke_fill: _Ink | None = None
def embed_color(self) -> None:
"""
Use embedded color glyphs (COLR, CBDT, SBIX).
"""
if self.mode not in ("RGB", "RGBA"):
msg = "Embedded color supported only in RGB and RGBA modes"
raise ValueError(msg)
self.embedded_color = True
def stroke(self, width: float = 0, fill: _Ink | None = None) -> None:
"""
:param width: The width of the text stroke.
:param fill: Color to use for the text stroke when drawing. If not given, will
default to the ``fill`` parameter from
:py:meth:`.ImageDraw.ImageDraw.text`.
"""
self.stroke_width = width
self.stroke_fill = fill
def _get_fontmode(self) -> str:
if self.mode in ("1", "P", "I", "F"):
return "1"
elif self.embedded_color:
return "RGBA"
else:
return "L"
def wrap(
self,
width: int,
height: int | None = None,
scaling: str | tuple[str, int] | None = None,
) -> Text[AnyStr] | None:
"""
Wrap text to fit within a given width.
:param width: The width to fit within.
:param height: An optional height limit. Any text that does not fit within this
will be returned as a new :py:class:`.Text` object.
:param scaling: An optional directive to scale the text, either "grow" as much
as possible within the given dimensions, or "shrink" until it
fits. It can also be a tuple of (direction, limit), with an
integer limit to stop scaling at.
:returns: An :py:class:`.Text` object, or None.
"""
if isinstance(self.font, ImageFont.TransposedFont):
msg = "TransposedFont not supported"
raise ValueError(msg)
if self.direction not in (None, "ltr"):
msg = "Only ltr direction supported"
raise ValueError(msg)
if scaling is None:
wrap = _Wrap(self, width, height)
else:
if not isinstance(self.font, ImageFont.FreeTypeFont):
msg = "'scaling' only supports FreeTypeFont"
raise ValueError(msg)
if height is None:
msg = "'scaling' requires 'height'"
raise ValueError(msg)
if isinstance(scaling, str):
limit = 1
else:
scaling, limit = scaling
font = self.font
wrap = _Wrap(self, width, height, font)
if scaling == "shrink":
if not wrap.remaining_text:
return None
size = math.ceil(font.size)
while wrap.remaining_text:
if size == max(limit, 1):
msg = "Text could not be scaled"
raise ValueError(msg)
size -= 1
font = self.font.font_variant(size=size)
wrap = _Wrap(self, width, height, font)
self.font = font
else:
if wrap.remaining_text:
msg = "Text could not be scaled"
raise ValueError(msg)
size = math.floor(font.size)
while not wrap.remaining_text:
if size == limit:
msg = "Text could not be scaled"
raise ValueError(msg)
size += 1
font = self.font.font_variant(size=size)
last_wrap = wrap
wrap = _Wrap(self, width, height, font)
size -= 1
if size != self.font.size:
self.font = self.font.font_variant(size=size)
wrap = last_wrap
if wrap.remaining_text:
text = Text(
text=wrap.remaining_text,
font=self.font,
mode=self.mode,
spacing=self.spacing,
direction=self.direction,
features=self.features,
language=self.language,
)
text.embedded_color = self.embedded_color
text.stroke_width = self.stroke_width
text.stroke_fill = self.stroke_fill
else:
text = None
newline = "\n" if isinstance(self.text, str) else b"\n"
self.text = newline.join(wrap.lines)
return text
def get_length(self) -> float:
"""
Returns length (in pixels with 1/64 precision) of text.
This is the amount by which following text should be offset.
Text bounding box may extend past the length in some fonts,
e.g. when using italics or accents.
The result is returned as a float; it is a whole number if using basic layout.
Note that the sum of two lengths may not equal the length of a concatenated
string due to kerning. If you need to adjust for kerning, include the following
character and subtract its length.
For example, instead of::
hello = ImageText.Text("Hello", font).get_length()
world = ImageText.Text("World", font).get_length()
helloworld = ImageText.Text("HelloWorld", font).get_length()
assert hello + world == helloworld
use::
hello = (
ImageText.Text("HelloW", font).get_length() -
ImageText.Text("W", font).get_length()
) # adjusted for kerning
world = ImageText.Text("World", font).get_length()
helloworld = ImageText.Text("HelloWorld", font).get_length()
assert hello + world == helloworld
or disable kerning with (requires libraqm)::
hello = ImageText.Text("Hello", font, features=["-kern"]).get_length()
world = ImageText.Text("World", font, features=["-kern"]).get_length()
helloworld = ImageText.Text(
"HelloWorld", font, features=["-kern"]
).get_length()
assert hello + world == helloworld
:return: Either width for horizontal text, or height for vertical text.
"""
if isinstance(self.text, str):
multiline = "\n" in self.text
else:
multiline = b"\n" in self.text
if multiline:
msg = "can't measure length of multiline text"
raise ValueError(msg)
return self.font.getlength(
self.text,
self._get_fontmode(),
self.direction,
self.features,
self.language,
)
def _split(
self,
xy: tuple[float, float] = (0, 0),
anchor: str | None = None,
align: str = "left",
lines: list[str] | list[bytes] | None = None,
) -> list[_Line]:
if anchor is None:
anchor = "lt" if self.direction == "ttb" else "la"
elif len(anchor) != 2:
msg = "anchor must be a 2 character string"
raise ValueError(msg)
if lines is None:
lines = (
self.text.split("\n")
if isinstance(self.text, str)
else self.text.split(b"\n")
)
if len(lines) == 1:
return [_Line(xy[0], xy[1], anchor, lines[0])]
if anchor[1] in "tb" and self.direction != "ttb":
msg = "anchor not supported for multiline text"
raise ValueError(msg)
fontmode = self._get_fontmode()
line_spacing = (
self.font.getbbox(
"A",
fontmode,
None,
self.features,
self.language,
self.stroke_width,
)[3]
+ self.stroke_width
+ self.spacing
)
top = xy[1]
parts = []
if self.direction == "ttb":
left = xy[0]
for line in lines:
parts.append(_Line(left, top, anchor, line))
left += line_spacing
else:
widths = []
max_width: float = 0
for line in lines:
line_width = self.font.getlength(
line, fontmode, self.direction, self.features, self.language
)
widths.append(line_width)
max_width = max(max_width, line_width)
if anchor[1] == "m":
top -= (len(lines) - 1) * line_spacing / 2.0
elif anchor[1] == "d":
top -= (len(lines) - 1) * line_spacing
idx = -1
for line in lines:
left = xy[0]
idx += 1
width_difference = max_width - widths[idx]
# align by align parameter
if align in ("left", "justify"):
pass
elif align == "center":
left += width_difference / 2.0
elif align == "right":
left += width_difference
else:
msg = 'align must be "left", "center", "right" or "justify"'
raise ValueError(msg)
if (
align == "justify"
and width_difference != 0
and idx != len(lines) - 1
):
words = (
line.split(" ") if isinstance(line, str) else line.split(b" ")
)
if len(words) > 1:
# align left by anchor
if anchor[0] == "m":
left -= max_width / 2.0
elif anchor[0] == "r":
left -= max_width
word_widths = [
self.font.getlength(
word,
fontmode,
self.direction,
self.features,
self.language,
)
for word in words
]
word_anchor = "l" + anchor[1]
width_difference = max_width - sum(word_widths)
i = 0
for word in words:
parts.append(_Line(left, top, word_anchor, word))
left += word_widths[i] + width_difference / (len(words) - 1)
i += 1
top += line_spacing
continue
# align left by anchor
if anchor[0] == "m":
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference
parts.append(_Line(left, top, anchor, line))
top += line_spacing
return parts
def _get_bbox(
self, text: str | bytes, font: Font | None = None, anchor: str | None = None
) -> tuple[float, float, float, float]:
return (font or self.font).getbbox(
text,
self._get_fontmode(),
self.direction,
self.features,
self.language,
self.stroke_width,
anchor,
)
def get_bbox(
self,
xy: tuple[float, float] = (0, 0),
anchor: str | None = None,
align: str = "left",
) -> tuple[float, float, float, float]:
"""
Returns bounding box (in pixels) of text.
Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel
precision. The bounding box includes extra margins for some fonts, e.g. italics
or accents.
:param xy: The anchor coordinates of the text.
:param anchor: The text anchor alignment. Determines the relative location of
the anchor to the text. The default alignment is top left,
specifically ``la`` for horizontal text and ``lt`` for
vertical text. See :ref:`text-anchors` for details.
:param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or
``"justify"`` determines the relative alignment of lines. Use the
``anchor`` parameter to specify the alignment to ``xy``.
:return: ``(left, top, right, bottom)`` bounding box
"""
bbox: tuple[float, float, float, float] | None = None
for x, y, anchor, text in self._split(xy, anchor, align):
bbox_line = self._get_bbox(text, anchor=anchor)
bbox_line = (
bbox_line[0] + x,
bbox_line[1] + y,
bbox_line[2] + x,
bbox_line[3] + y,
)
if bbox is None:
bbox = bbox_line
else:
bbox = (
min(bbox[0], bbox_line[0]),
min(bbox[1], bbox_line[1]),
max(bbox[2], bbox_line[2]),
max(bbox[3], bbox_line[3]),
)
assert bbox is not None
return bbox

View File

@@ -1,266 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# a Tk display interface
#
# History:
# 96-04-08 fl Created
# 96-09-06 fl Added getimage method
# 96-11-01 fl Rewritten, removed image attribute and crop method
# 97-05-09 fl Use PyImagingPaste method instead of image type
# 97-05-12 fl Minor tweaks to match the IFUNC95 interface
# 97-05-17 fl Support the "pilbitmap" booster patch
# 97-06-05 fl Added file= and data= argument to image constructors
# 98-03-09 fl Added width and height methods to Image classes
# 98-07-02 fl Use default mode for "P" images without palette attribute
# 98-07-02 fl Explicitly destroy Tkinter image objects
# 99-07-24 fl Support multiple Tk interpreters (from Greg Couch)
# 99-07-26 fl Automatically hook into Tkinter (if possible)
# 99-08-15 fl Hook uses _imagingtk instead of _imaging
#
# Copyright (c) 1997-1999 by Secret Labs AB
# Copyright (c) 1996-1997 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import tkinter
from io import BytesIO
from typing import Any
from . import Image, ImageFile
TYPE_CHECKING = False
if TYPE_CHECKING:
from ._typing import CapsuleType
# --------------------------------------------------------------------
# Check for Tkinter interface hooks
def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None:
source = None
if "file" in kw:
source = kw.pop("file")
elif "data" in kw:
source = BytesIO(kw.pop("data"))
if not source:
return None
return Image.open(source)
def _pyimagingtkcall(
command: str, photo: PhotoImage | tkinter.PhotoImage, ptr: CapsuleType
) -> None:
tk = photo.tk
try:
tk.call(command, photo, repr(ptr))
except tkinter.TclError:
# activate Tkinter hook
# may raise an error if it cannot attach to Tkinter
from . import _imagingtk
_imagingtk.tkinit(tk.interpaddr())
tk.call(command, photo, repr(ptr))
# --------------------------------------------------------------------
# PhotoImage
class PhotoImage:
"""
A Tkinter-compatible photo image. This can be used
everywhere Tkinter expects an image object. If the image is an RGBA
image, pixels having alpha 0 are treated as transparent.
The constructor takes either a PIL image, or a mode and a size.
Alternatively, you can use the ``file`` or ``data`` options to initialize
the photo image object.
:param image: Either a PIL image, or a mode string. If a mode string is
used, a size must also be given.
:param size: If the first argument is a mode string, this defines the size
of the image.
:keyword file: A filename to load the image from (using
``Image.open(file)``).
:keyword data: An 8-bit string containing image data (as loaded from an
image file).
"""
def __init__(
self,
image: Image.Image | str | None = None,
size: tuple[int, int] | None = None,
**kw: Any,
) -> None:
# Tk compatibility: file or data
if image is None:
image = _get_image_from_kw(kw)
if image is None:
msg = "Image is required"
raise ValueError(msg)
elif isinstance(image, str):
mode = image
image = None
if size is None:
msg = "If first argument is mode, size is required"
raise ValueError(msg)
else:
# got an image instead of a mode
mode = image.mode
if mode == "P":
# palette mapped data
image.apply_transparency()
image.load()
mode = image.palette.mode if image.palette else "RGB"
size = image.size
kw["width"], kw["height"] = size
if mode not in ["1", "L", "RGB", "RGBA"]:
mode = Image.getmodebase(mode)
self.__mode = mode
self.__size = size
self.__photo = tkinter.PhotoImage(**kw)
self.tk = self.__photo.tk
if image:
self.paste(image)
def __del__(self) -> None:
try:
name = self.__photo.name
except AttributeError:
return
self.__photo.name = None
try:
self.__photo.tk.call("image", "delete", name)
except Exception:
pass # ignore internal errors
def __str__(self) -> str:
"""
Get the Tkinter photo image identifier. This method is automatically
called by Tkinter whenever a PhotoImage object is passed to a Tkinter
method.
:return: A Tkinter photo image identifier (a string).
"""
return str(self.__photo)
def width(self) -> int:
"""
Get the width of the image.
:return: The width, in pixels.
"""
return self.__size[0]
def height(self) -> int:
"""
Get the height of the image.
:return: The height, in pixels.
"""
return self.__size[1]
def paste(self, im: Image.Image) -> None:
"""
Paste a PIL image into the photo image. Note that this can
be very slow if the photo image is displayed.
:param im: A PIL image. The size must match the target region. If the
mode does not match, the image is converted to the mode of
the bitmap image.
"""
# convert to blittable
ptr = im.getim()
image = im.im
if not image.isblock() or im.mode != self.__mode:
block = Image.core.new_block(self.__mode, im.size)
image.convert2(block, image) # convert directly between buffers
ptr = block.ptr
_pyimagingtkcall("PyImagingPhoto", self.__photo, ptr)
# --------------------------------------------------------------------
# BitmapImage
class BitmapImage:
"""
A Tkinter-compatible bitmap image. This can be used everywhere Tkinter
expects an image object.
The given image must have mode "1". Pixels having value 0 are treated as
transparent. Options, if any, are passed on to Tkinter. The most commonly
used option is ``foreground``, which is used to specify the color for the
non-transparent parts. See the Tkinter documentation for information on
how to specify colours.
:param image: A PIL image.
"""
def __init__(self, image: Image.Image | None = None, **kw: Any) -> None:
# Tk compatibility: file or data
if image is None:
image = _get_image_from_kw(kw)
if image is None:
msg = "Image is required"
raise ValueError(msg)
self.__mode = image.mode
self.__size = image.size
self.__photo = tkinter.BitmapImage(data=image.tobitmap(), **kw)
def __del__(self) -> None:
try:
name = self.__photo.name
except AttributeError:
return
self.__photo.name = None
try:
self.__photo.tk.call("image", "delete", name)
except Exception:
pass # ignore internal errors
def width(self) -> int:
"""
Get the width of the image.
:return: The width, in pixels.
"""
return self.__size[0]
def height(self) -> int:
"""
Get the height of the image.
:return: The height, in pixels.
"""
return self.__size[1]
def __str__(self) -> str:
"""
Get the Tkinter bitmap image identifier. This method is automatically
called by Tkinter whenever a BitmapImage object is passed to a Tkinter
method.
:return: A Tkinter bitmap image identifier (a string).
"""
return str(self.__photo)
def getimage(photo: PhotoImage) -> Image.Image:
"""Copies the contents of a PhotoImage to a PIL image memory."""
im = Image.new("RGBA", (photo.width(), photo.height()))
_pyimagingtkcall("PyImagingPhotoGet", photo, im.getim())
return im

View File

@@ -1,136 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# transform wrappers
#
# History:
# 2002-04-08 fl Created
#
# Copyright (c) 2002 by Secret Labs AB
# Copyright (c) 2002 by Fredrik Lundh
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from . import Image
class Transform(Image.ImageTransformHandler):
"""Base class for other transforms defined in :py:mod:`~PIL.ImageTransform`."""
method: Image.Transform
def __init__(self, data: Sequence[Any]) -> None:
self.data = data
def getdata(self) -> tuple[Image.Transform, Sequence[int]]:
return self.method, self.data
def transform(
self,
size: tuple[int, int],
image: Image.Image,
**options: Any,
) -> Image.Image:
"""Perform the transform. Called from :py:meth:`.Image.transform`."""
# can be overridden
method, data = self.getdata()
return image.transform(size, method, data, **options)
class AffineTransform(Transform):
"""
Define an affine image transform.
This function takes a 6-tuple (a, b, c, d, e, f) which contain the first
two rows from the inverse of an affine transform matrix. For each pixel
(x, y) in the output image, the new value is taken from a position (a x +
b y + c, d x + e y + f) in the input image, rounded to nearest pixel.
This function can be used to scale, translate, rotate, and shear the
original image.
See :py:meth:`.Image.transform`
:param matrix: A 6-tuple (a, b, c, d, e, f) containing the first two rows
from the inverse of an affine transform matrix.
"""
method = Image.Transform.AFFINE
class PerspectiveTransform(Transform):
"""
Define a perspective image transform.
This function takes an 8-tuple (a, b, c, d, e, f, g, h). For each pixel
(x, y) in the output image, the new value is taken from a position
((a x + b y + c) / (g x + h y + 1), (d x + e y + f) / (g x + h y + 1)) in
the input image, rounded to nearest pixel.
This function can be used to scale, translate, rotate, and shear the
original image.
See :py:meth:`.Image.transform`
:param matrix: An 8-tuple (a, b, c, d, e, f, g, h).
"""
method = Image.Transform.PERSPECTIVE
class ExtentTransform(Transform):
"""
Define a transform to extract a subregion from an image.
Maps a rectangle (defined by two corners) from the image to a rectangle of
the given size. The resulting image will contain data sampled from between
the corners, such that (x0, y0) in the input image will end up at (0,0) in
the output image, and (x1, y1) at size.
This method can be used to crop, stretch, shrink, or mirror an arbitrary
rectangle in the current image. It is slightly slower than crop, but about
as fast as a corresponding resize operation.
See :py:meth:`.Image.transform`
:param bbox: A 4-tuple (x0, y0, x1, y1) which specifies two points in the
input image's coordinate system. See :ref:`coordinate-system`.
"""
method = Image.Transform.EXTENT
class QuadTransform(Transform):
"""
Define a quad image transform.
Maps a quadrilateral (a region defined by four corners) from the image to a
rectangle of the given size.
See :py:meth:`.Image.transform`
:param xy: An 8-tuple (x0, y0, x1, y1, x2, y2, x3, y3) which contain the
upper left, lower left, lower right, and upper right corner of the
source quadrilateral.
"""
method = Image.Transform.QUAD
class MeshTransform(Transform):
"""
Define a mesh image transform. A mesh transform consists of one or more
individual quad transforms.
See :py:meth:`.Image.transform`
:param data: A list of (bbox, quad) tuples.
"""
method = Image.Transform.MESH

View File

@@ -1,247 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# a Windows DIB display interface
#
# History:
# 1996-05-20 fl Created
# 1996-09-20 fl Fixed subregion exposure
# 1997-09-21 fl Added draw primitive (for tzPrint)
# 2003-05-21 fl Added experimental Window/ImageWindow classes
# 2003-09-05 fl Added fromstring/tostring methods
#
# Copyright (c) Secret Labs AB 1997-2003.
# Copyright (c) Fredrik Lundh 1996-2003.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image
class HDC:
"""
Wraps an HDC integer. The resulting object can be passed to the
:py:meth:`~PIL.ImageWin.Dib.draw` and :py:meth:`~PIL.ImageWin.Dib.expose`
methods.
"""
def __init__(self, dc: int) -> None:
self.dc = dc
def __int__(self) -> int:
return self.dc
class HWND:
"""
Wraps an HWND integer. The resulting object can be passed to the
:py:meth:`~PIL.ImageWin.Dib.draw` and :py:meth:`~PIL.ImageWin.Dib.expose`
methods, instead of a DC.
"""
def __init__(self, wnd: int) -> None:
self.wnd = wnd
def __int__(self) -> int:
return self.wnd
class Dib:
"""
A Windows bitmap with the given mode and size. The mode can be one of "1",
"L", "P", or "RGB".
If the display requires a palette, this constructor creates a suitable
palette and associates it with the image. For an "L" image, 128 graylevels
are allocated. For an "RGB" image, a 6x6x6 colour cube is used, together
with 20 graylevels.
To make sure that palettes work properly under Windows, you must call the
``palette`` method upon certain events from Windows.
:param image: Either a PIL image, or a mode string. If a mode string is
used, a size must also be given. The mode can be one of "1",
"L", "P", or "RGB".
:param size: If the first argument is a mode string, this
defines the size of the image.
"""
def __init__(
self, image: Image.Image | str, size: tuple[int, int] | None = None
) -> None:
if isinstance(image, str):
mode = image
image = ""
if size is None:
msg = "If first argument is mode, size is required"
raise ValueError(msg)
else:
mode = image.mode
size = image.size
if mode not in ["1", "L", "P", "RGB"]:
mode = Image.getmodebase(mode)
self.image = Image.core.display(mode, size)
self.mode = mode
self.size = size
if image:
assert not isinstance(image, str)
self.paste(image)
def expose(self, handle: int | HDC | HWND) -> None:
"""
Copy the bitmap contents to a device context.
:param handle: Device context (HDC), cast to a Python integer, or an
HDC or HWND instance. In PythonWin, you can use
``CDC.GetHandleAttrib()`` to get a suitable handle.
"""
handle_int = int(handle)
if isinstance(handle, HWND):
dc = self.image.getdc(handle_int)
try:
self.image.expose(dc)
finally:
self.image.releasedc(handle_int, dc)
else:
self.image.expose(handle_int)
def draw(
self,
handle: int | HDC | HWND,
dst: tuple[int, int, int, int],
src: tuple[int, int, int, int] | None = None,
) -> None:
"""
Same as expose, but allows you to specify where to draw the image, and
what part of it to draw.
The destination and source areas are given as 4-tuple rectangles. If
the source is omitted, the entire image is copied. If the source and
the destination have different sizes, the image is resized as
necessary.
"""
if src is None:
src = (0, 0) + self.size
handle_int = int(handle)
if isinstance(handle, HWND):
dc = self.image.getdc(handle_int)
try:
self.image.draw(dc, dst, src)
finally:
self.image.releasedc(handle_int, dc)
else:
self.image.draw(handle_int, dst, src)
def query_palette(self, handle: int | HDC | HWND) -> int:
"""
Installs the palette associated with the image in the given device
context.
This method should be called upon **QUERYNEWPALETTE** and
**PALETTECHANGED** events from Windows. If this method returns a
non-zero value, one or more display palette entries were changed, and
the image should be redrawn.
:param handle: Device context (HDC), cast to a Python integer, or an
HDC or HWND instance.
:return: The number of entries that were changed (if one or more entries,
this indicates that the image should be redrawn).
"""
handle_int = int(handle)
if isinstance(handle, HWND):
handle = self.image.getdc(handle_int)
try:
result = self.image.query_palette(handle)
finally:
self.image.releasedc(handle, handle)
else:
result = self.image.query_palette(handle_int)
return result
def paste(
self, im: Image.Image, box: tuple[int, int, int, int] | None = None
) -> None:
"""
Paste a PIL image into the bitmap image.
:param im: A PIL image. The size must match the target region.
If the mode does not match, the image is converted to the
mode of the bitmap image.
:param box: A 4-tuple defining the left, upper, right, and
lower pixel coordinate. See :ref:`coordinate-system`. If
None is given instead of a tuple, all of the image is
assumed.
"""
im.load()
if self.mode != im.mode:
im = im.convert(self.mode)
if box:
self.image.paste(im.im, box)
else:
self.image.paste(im.im)
def frombytes(self, buffer: bytes) -> None:
"""
Load display memory contents from byte data.
:param buffer: A buffer containing display data (usually
data returned from :py:func:`~PIL.ImageWin.Dib.tobytes`)
"""
self.image.frombytes(buffer)
def tobytes(self) -> bytes:
"""
Copy display memory contents to bytes object.
:return: A bytes object containing display data.
"""
return self.image.tobytes()
class Window:
"""Create a Window with the given title size."""
def __init__(
self, title: str = "PIL", width: int | None = None, height: int | None = None
) -> None:
self.hwnd = Image.core.createwindow(
title, self.__dispatcher, width or 0, height or 0
)
def __dispatcher(self, action: str, *args: int) -> None:
getattr(self, f"ui_handle_{action}")(*args)
def ui_handle_clear(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None:
pass
def ui_handle_damage(self, x0: int, y0: int, x1: int, y1: int) -> None:
pass
def ui_handle_destroy(self) -> None:
pass
def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None:
pass
def ui_handle_resize(self, width: int, height: int) -> None:
pass
def mainloop(self) -> None:
Image.core.eventloop()
class ImageWindow(Window):
"""Create an image window which displays the given image."""
def __init__(self, image: Image.Image | Dib, title: str = "PIL") -> None:
if not isinstance(image, Dib):
image = Dib(image)
self.image = image
width, height = image.size
super().__init__(title, width=width, height=height)
def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None:
self.image.draw(dc, (x0, y0, x1, y1))

View File

@@ -1,103 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# IM Tools support for PIL
#
# history:
# 1996-05-27 fl Created (read 8-bit images only)
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.2)
#
# Copyright (c) Secret Labs AB 1997-2001.
# Copyright (c) Fredrik Lundh 1996-2001.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import re
from . import Image, ImageFile
#
# --------------------------------------------------------------------
field = re.compile(rb"([a-z]*) ([^ \r\n]*)")
##
# Image plugin for IM Tools images.
class ImtImageFile(ImageFile.ImageFile):
format = "IMT"
format_description = "IM Tools"
def _open(self) -> None:
# Quick rejection: if there's not a LF among the first
# 100 bytes, this is (probably) not a text header.
assert self.fp is not None
buffer = self.fp.read(100)
if b"\n" not in buffer:
msg = "not an IM file"
raise SyntaxError(msg)
xsize = ysize = 0
while True:
if buffer:
s = buffer[:1]
buffer = buffer[1:]
else:
s = self.fp.read(1)
if not s:
break
if s == b"\x0c":
# image data begins
self.tile = [
ImageFile._Tile(
"raw",
(0, 0) + self.size,
self.fp.tell() - len(buffer),
self.mode,
)
]
break
else:
# read key/value pair
if b"\n" not in buffer:
buffer += self.fp.read(100)
lines = buffer.split(b"\n")
s += lines.pop(0)
buffer = b"\n".join(lines)
if len(s) == 1 or len(s) > 100:
break
if s[0] == ord(b"*"):
continue # comment
m = field.match(s)
if not m:
break
k, v = m.group(1, 2)
if k == b"width":
xsize = int(v)
self._size = xsize, ysize
elif k == b"height":
ysize = int(v)
self._size = xsize, ysize
elif k == b"pixel" and v == b"n8":
self._mode = "L"
#
# --------------------------------------------------------------------
Image.register_open(ImtImageFile.format, ImtImageFile)
#
# no extension registered (".im" is simply too common)

View File

@@ -1,226 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# IPTC/NAA file handling
#
# history:
# 1995-10-01 fl Created
# 1998-03-09 fl Cleaned up and added to PIL
# 2002-06-18 fl Added getiptcinfo helper
#
# Copyright (c) Secret Labs AB 1997-2002.
# Copyright (c) Fredrik Lundh 1995.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from io import BytesIO
from typing import cast
from . import Image, ImageFile
from ._binary import i16be as i16
from ._binary import i32be as i32
COMPRESSION = {1: "raw", 5: "jpeg"}
#
# Helpers
def _i(c: bytes) -> int:
return i32((b"\0\0\0\0" + c)[-4:])
##
# Image plugin for IPTC/NAA datastreams. To read IPTC/NAA fields
# from TIFF and JPEG files, use the <b>getiptcinfo</b> function.
class IptcImageFile(ImageFile.ImageFile):
format = "IPTC"
format_description = "IPTC/NAA"
def getint(self, key: tuple[int, int]) -> int:
return _i(self.info[key])
def field(self) -> tuple[tuple[int, int] | None, int]:
#
# get a IPTC field header
assert self.fp is not None
s = self.fp.read(5)
if not s.strip(b"\x00"):
return None, 0
tag = s[1], s[2]
# syntax
if s[0] != 0x1C or tag[0] not in [1, 2, 3, 4, 5, 6, 7, 8, 9, 240]:
msg = "invalid IPTC/NAA file"
raise SyntaxError(msg)
# field size
size = s[3]
if size > 132:
msg = "illegal field length in IPTC/NAA file"
raise OSError(msg)
elif size == 128:
size = 0
elif size > 128:
size = _i(self.fp.read(size - 128))
else:
size = i16(s, 3)
return tag, size
def _open(self) -> None:
# load descriptive fields
assert self.fp is not None
while True:
offset = self.fp.tell()
tag, size = self.field()
if not tag or tag == (8, 10):
break
if size:
tagdata = self.fp.read(size)
else:
tagdata = None
if tag in self.info:
if isinstance(self.info[tag], list):
self.info[tag].append(tagdata)
else:
self.info[tag] = [self.info[tag], tagdata]
else:
self.info[tag] = tagdata
# mode
layers = self.info[(3, 60)][0]
component = self.info[(3, 60)][1]
if layers == 1 and not component:
self._mode = "L"
band = None
else:
if layers == 3 and component:
self._mode = "RGB"
elif layers == 4 and component:
self._mode = "CMYK"
if (3, 65) in self.info:
band = self.info[(3, 65)][0] - 1
else:
band = 0
# size
self._size = self.getint((3, 20)), self.getint((3, 30))
# compression
try:
compression = COMPRESSION[self.getint((3, 120))]
except KeyError as e:
msg = "Unknown IPTC image compression"
raise OSError(msg) from e
# tile
if tag == (8, 10):
self.tile = [
ImageFile._Tile("iptc", (0, 0) + self.size, offset, (compression, band))
]
def load(self) -> Image.core.PixelAccess | None:
if self.tile:
args = self.tile[0].args
assert isinstance(args, tuple)
compression, band = args
assert self.fp is not None
self.fp.seek(self.tile[0].offset)
# Copy image data to temporary file
o = BytesIO()
if compression == "raw":
# To simplify access to the extracted file,
# prepend a PPM header
o.write(b"P5\n%d %d\n255\n" % self.size)
while True:
type, size = self.field()
if type != (8, 10):
break
while size > 0:
s = self.fp.read(min(size, 8192))
if not s:
break
o.write(s)
size -= len(s)
with Image.open(o) as _im:
if band is not None:
bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode)
bands[band] = _im
im = Image.merge(self.mode, bands)
else:
im = _im
im.load()
self.im = im.im
self.tile = []
return ImageFile.ImageFile.load(self)
Image.register_open(IptcImageFile.format, IptcImageFile)
Image.register_extension(IptcImageFile.format, ".iim")
def getiptcinfo(
im: ImageFile.ImageFile,
) -> dict[tuple[int, int], bytes | list[bytes]] | None:
"""
Get IPTC information from TIFF, JPEG, or IPTC file.
:param im: An image containing IPTC data.
:returns: A dictionary containing IPTC information, or None if
no IPTC information block was found.
"""
from . import JpegImagePlugin, TiffImagePlugin
data = None
if isinstance(im, IptcImageFile):
# return info dictionary right away
return {k: v for k, v in im.info.items() if isinstance(k, tuple)}
elif isinstance(im, JpegImagePlugin.JpegImageFile):
# extract the IPTC/NAA resource
photoshop = im.info.get("photoshop")
if photoshop:
data = photoshop.get(0x0404)
elif isinstance(im, TiffImagePlugin.TiffImageFile):
# get raw data from the IPTC/NAA tag (PhotoShop tags the data
# as 4-byte integers, so we cannot use the get method...)
try:
data = im.tag_v2._tagdata[TiffImagePlugin.IPTC_NAA_CHUNK]
except KeyError:
pass
if data is None:
return None # no properties
# create an IptcImagePlugin object without initializing it
class FakeImage:
pass
fake_im = FakeImage()
fake_im.__class__ = IptcImageFile # type: ignore[assignment]
iptc_im = cast(IptcImageFile, fake_im)
# parse the IPTC information chunk
iptc_im.info = {}
iptc_im.fp = BytesIO(data)
try:
iptc_im._open()
except (IndexError, KeyError):
pass # expected failure
return {k: v for k, v in iptc_im.info.items() if isinstance(k, tuple)}

View File

@@ -1,460 +0,0 @@
#
# The Python Imaging Library
# $Id$
#
# JPEG2000 file handling
#
# History:
# 2014-03-12 ajh Created
# 2021-06-30 rogermb Extract dpi information from the 'resc' header box
#
# Copyright (c) 2014 Coriolis Systems Limited
# Copyright (c) 2014 Alastair Houghton
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
import os
import struct
from typing import cast
from . import Image, ImageFile, ImagePalette, _binary
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import IO
class BoxReader:
"""
A small helper class to read fields stored in JPEG2000 header boxes
and to easily step into and read sub-boxes.
"""
def __init__(self, fp: IO[bytes], length: int = -1) -> None:
self.fp = fp
self.has_length = length >= 0
self.length = length
self.remaining_in_box = -1
def _can_read(self, num_bytes: int) -> bool:
if self.has_length and self.fp.tell() + num_bytes > self.length:
# Outside box: ensure we don't read past the known file length
return False
if self.remaining_in_box >= 0:
# Inside box contents: ensure read does not go past box boundaries
return num_bytes <= self.remaining_in_box
else:
return True # No length known, just read
def _read_bytes(self, num_bytes: int) -> bytes:
if not self._can_read(num_bytes):
msg = "Not enough data in header"
raise SyntaxError(msg)
data = self.fp.read(num_bytes)
if len(data) < num_bytes:
msg = f"Expected to read {num_bytes} bytes but only got {len(data)}."
raise OSError(msg)
if self.remaining_in_box > 0:
self.remaining_in_box -= num_bytes
return data
def read_fields(self, field_format: str) -> tuple[int | bytes, ...]:
size = struct.calcsize(field_format)
data = self._read_bytes(size)
return struct.unpack(field_format, data)
def read_boxes(self) -> BoxReader:
size = self.remaining_in_box
data = self._read_bytes(size)
return BoxReader(io.BytesIO(data), size)
def has_next_box(self) -> bool:
if self.has_length:
return self.fp.tell() + self.remaining_in_box < self.length
else:
return True
def next_box_type(self) -> bytes:
# Skip the rest of the box if it has not been read
if self.remaining_in_box > 0:
self.fp.seek(self.remaining_in_box, os.SEEK_CUR)
self.remaining_in_box = -1
# Read the length and type of the next box
lbox, tbox = cast(tuple[int, bytes], self.read_fields(">I4s"))
if lbox == 1:
lbox = cast(int, self.read_fields(">Q")[0])
hlen = 16
else:
hlen = 8
if lbox < hlen or not self._can_read(lbox - hlen):
msg = "Invalid header length"
raise SyntaxError(msg)
self.remaining_in_box = lbox - hlen
return tbox
def _parse_codestream(fp: IO[bytes]) -> tuple[tuple[int, int], str]:
"""Parse the JPEG 2000 codestream to extract the size and component
count from the SIZ marker segment, returning a PIL (size, mode) tuple."""
hdr = fp.read(2)
lsiz = _binary.i16be(hdr)
siz = hdr + fp.read(lsiz - 2)
lsiz, rsiz, xsiz, ysiz, xosiz, yosiz, _, _, _, _, csiz = struct.unpack_from(
">HHIIIIIIIIH", siz
)
size = (xsiz - xosiz, ysiz - yosiz)
if csiz == 1:
ssiz = struct.unpack_from(">B", siz, 38)
if (ssiz[0] & 0x7F) + 1 > 8:
mode = "I;16"
else:
mode = "L"
elif csiz == 2:
mode = "LA"
elif csiz == 3:
mode = "RGB"
elif csiz == 4:
mode = "RGBA"
else:
msg = "unable to determine J2K image mode"
raise SyntaxError(msg)
return size, mode
def _res_to_dpi(num: int, denom: int, exp: int) -> float | None:
"""Convert JPEG2000's (numerator, denominator, exponent-base-10) resolution,
calculated as (num / denom) * 10^exp and stored in dots per meter,
to floating-point dots per inch."""
if denom == 0:
return None
return (254 * num * (10**exp)) / (10000 * denom)
def _parse_jp2_header(
fp: IO[bytes],
) -> tuple[
tuple[int, int],
str,
str | None,
tuple[float, float] | None,
ImagePalette.ImagePalette | None,
]:
"""Parse the JP2 header box to extract size, component count,
color space information, and optionally DPI information,
returning a (size, mode, mimetype, dpi) tuple."""
# Find the JP2 header box
reader = BoxReader(fp)
header = None
mimetype = None
while reader.has_next_box():
tbox = reader.next_box_type()
if tbox == b"jp2h":
header = reader.read_boxes()
break
elif tbox == b"ftyp":
if reader.read_fields(">4s")[0] == b"jpx ":
mimetype = "image/jpx"
assert header is not None
size = None
mode = None
bpc = None
nc = None
dpi = None # 2-tuple of DPI info, or None
palette = None
colr = None
while header.has_next_box():
tbox = header.next_box_type()
if tbox == b"ihdr":
height, width, nc, bpc = header.read_fields(">IIHB")
assert isinstance(height, int)
assert isinstance(width, int)
assert isinstance(bpc, int)
size = (width, height)
if nc == 1 and (bpc & 0x7F) > 8:
mode = "I;16"
elif nc == 1:
mode = "L"
elif nc == 2:
mode = "LA"
elif nc == 3:
mode = "RGB"
elif nc == 4:
mode = "RGBA"
elif tbox == b"colr":
meth, _, _, enumcs = header.read_fields(">BBBI")
if meth == 1:
if enumcs in (0, 15):
colr = "1"
elif enumcs == 12:
colr = "CMYK"
if nc == 4:
mode = "CMYK"
elif enumcs == 17:
colr = "L"
elif tbox == b"pclr" and mode in ("L", "LA") and colr not in ("1", "L"):
ne, npc = header.read_fields(">HB")
assert isinstance(ne, int)
assert isinstance(npc, int)
max_bitdepth = 0
for bitdepth in header.read_fields(">" + ("B" * npc)):
assert isinstance(bitdepth, int)
if bitdepth > max_bitdepth:
max_bitdepth = bitdepth
if max_bitdepth <= 8:
if npc == 4:
palette_mode = "CMYK" if colr == "CMYK" else "RGBA"
else:
palette_mode = "RGB"
palette = ImagePalette.ImagePalette(palette_mode)
for i in range(ne):
color: list[int] = []
for value in header.read_fields(">" + ("B" * npc)):
assert isinstance(value, int)
color.append(value)
palette.getcolor(tuple(color))
mode = "P" if mode == "L" else "PA"
elif tbox == b"res ":
res = header.read_boxes()
while res.has_next_box():
tres = res.next_box_type()
if tres == b"resc":
vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB")
assert isinstance(vrcn, int)
assert isinstance(vrcd, int)
assert isinstance(hrcn, int)
assert isinstance(hrcd, int)
assert isinstance(vrce, int)
assert isinstance(hrce, int)
hres = _res_to_dpi(hrcn, hrcd, hrce)
vres = _res_to_dpi(vrcn, vrcd, vrce)
if hres is not None and vres is not None:
dpi = (hres, vres)
break
if size is None or mode is None:
msg = "Malformed JP2 header"
raise SyntaxError(msg)
return size, mode, mimetype, dpi, palette
##
# Image plugin for JPEG2000 images.
class Jpeg2KImageFile(ImageFile.ImageFile):
format = "JPEG2000"
format_description = "JPEG 2000 (ISO 15444)"
def _open(self) -> None:
assert self.fp is not None
sig = self.fp.read(4)
if sig == b"\xff\x4f\xff\x51":
self.codec = "j2k"
self._size, self._mode = _parse_codestream(self.fp)
self._parse_comment()
else:
sig = sig + self.fp.read(8)
if sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a":
self.codec = "jp2"
header = _parse_jp2_header(self.fp)
self._size, self._mode, self.custom_mimetype, dpi, self.palette = header
if dpi is not None:
self.info["dpi"] = dpi
if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"):
hdr = self.fp.read(2)
length = _binary.i16be(hdr)
self.fp.seek(length - 2, os.SEEK_CUR)
self._parse_comment()
else:
msg = "not a JPEG 2000 file"
raise SyntaxError(msg)
self._reduce = 0
self.layers = 0
fd = -1
length = -1
try:
fd = self.fp.fileno()
length = os.fstat(fd).st_size
except Exception:
fd = -1
try:
pos = self.fp.tell()
self.fp.seek(0, io.SEEK_END)
length = self.fp.tell()
self.fp.seek(pos)
except Exception:
length = -1
self.tile = [
ImageFile._Tile(
"jpeg2k",
(0, 0) + self.size,
0,
(self.codec, self._reduce, self.layers, fd, length),
)
]
def _parse_comment(self) -> None:
assert self.fp is not None
while True:
marker = self.fp.read(2)
if not marker:
break
typ = marker[1]
if typ in (0x90, 0xD9):
# Start of tile or end of codestream
break
hdr = self.fp.read(2)
length = _binary.i16be(hdr)
if typ == 0x64:
# Comment
self.info["comment"] = self.fp.read(length - 2)[2:]
break
else:
self.fp.seek(length - 2, os.SEEK_CUR)
@property # type: ignore[override]
def reduce(
self,
) -> (
Callable[[int | tuple[int, int], tuple[int, int, int, int] | None], Image.Image]
| int
):
# https://github.com/python-pillow/Pillow/issues/4343 found that the
# new Image 'reduce' method was shadowed by this plugin's 'reduce'
# property. This attempts to allow for both scenarios
return self._reduce or super().reduce
@reduce.setter
def reduce(self, value: int) -> None:
self._reduce = value
def load(self) -> Image.core.PixelAccess | None:
if self.tile and self._reduce:
power = 1 << self._reduce
adjust = power >> 1
self._size = (
int((self.size[0] + adjust) / power),
int((self.size[1] + adjust) / power),
)
# Update the reduce and layers settings
t = self.tile[0]
assert isinstance(t[3], tuple)
t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4])
self.tile = [ImageFile._Tile(t[0], (0, 0) + self.size, t[2], t3)]
return ImageFile.ImageFile.load(self)
def _accept(prefix: bytes) -> bool:
return prefix.startswith(
(b"\xff\x4f\xff\x51", b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a")
)
# ------------------------------------------------------------
# Save support
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
# Get the keyword arguments
info = im.encoderinfo
if isinstance(filename, str):
filename = filename.encode()
if filename.endswith(b".j2k") or info.get("no_jp2", False):
kind = "j2k"
else:
kind = "jp2"
offset = info.get("offset", None)
tile_offset = info.get("tile_offset", None)
tile_size = info.get("tile_size", None)
quality_mode = info.get("quality_mode", "rates")
quality_layers = info.get("quality_layers", None)
if quality_layers is not None and not (
isinstance(quality_layers, (list, tuple))
and all(
isinstance(quality_layer, (int, float)) for quality_layer in quality_layers
)
):
msg = "quality_layers must be a sequence of numbers"
raise ValueError(msg)
num_resolutions = info.get("num_resolutions", 0)
cblk_size = info.get("codeblock_size", None)
precinct_size = info.get("precinct_size", None)
irreversible = info.get("irreversible", False)
progression = info.get("progression", "LRCP")
cinema_mode = info.get("cinema_mode", "no")
mct = info.get("mct", 0)
signed = info.get("signed", False)
comment = info.get("comment")
if isinstance(comment, str):
comment = comment.encode()
plt = info.get("plt", False)
fd = -1
if hasattr(fp, "fileno"):
try:
fd = fp.fileno()
except Exception:
fd = -1
im.encoderconfig = (
offset,
tile_offset,
tile_size,
quality_mode,
quality_layers,
num_resolutions,
cblk_size,
precinct_size,
irreversible,
progression,
cinema_mode,
mct,
signed,
fd,
comment,
plt,
)
ImageFile._save(im, fp, [ImageFile._Tile("jpeg2k", (0, 0) + im.size, 0, kind)])
# ------------------------------------------------------------
# Registry stuff
Image.register_open(Jpeg2KImageFile.format, Jpeg2KImageFile, _accept)
Image.register_save(Jpeg2KImageFile.format, _save)
Image.register_extensions(
Jpeg2KImageFile.format, [".jp2", ".j2k", ".jpc", ".jpf", ".jpx", ".j2c"]
)
Image.register_mime(Jpeg2KImageFile.format, "image/jp2")

View File

@@ -1,889 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# JPEG (JFIF) file handling
#
# See "Digital Compression and Coding of Continuous-Tone Still Images,
# Part 1, Requirements and Guidelines" (CCITT T.81 / ISO 10918-1)
#
# History:
# 1995-09-09 fl Created
# 1995-09-13 fl Added full parser
# 1996-03-25 fl Added hack to use the IJG command line utilities
# 1996-05-05 fl Workaround Photoshop 2.5 CMYK polarity bug
# 1996-05-28 fl Added draft support, JFIF version (0.1)
# 1996-12-30 fl Added encoder options, added progression property (0.2)
# 1997-08-27 fl Save mode 1 images as BW (0.3)
# 1998-07-12 fl Added YCbCr to draft and save methods (0.4)
# 1998-10-19 fl Don't hang on files using 16-bit DQT's (0.4.1)
# 2001-04-16 fl Extract DPI settings from JFIF files (0.4.2)
# 2002-07-01 fl Skip pad bytes before markers; identify Exif files (0.4.3)
# 2003-04-25 fl Added experimental EXIF decoder (0.5)
# 2003-06-06 fl Added experimental EXIF GPSinfo decoder
# 2003-09-13 fl Extract COM markers
# 2009-09-06 fl Added icc_profile support (from Florian Hoech)
# 2009-03-06 fl Changed CMYK handling; always use Adobe polarity (0.6)
# 2009-03-08 fl Added subsampling support (from Justin Huff).
#
# Copyright (c) 1997-2003 by Secret Labs AB.
# Copyright (c) 1995-1996 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import array
import io
import math
import os
import struct
import subprocess
import sys
import tempfile
import warnings
from . import Image, ImageFile
from ._binary import i16be as i16
from ._binary import i32be as i32
from ._binary import o8
from ._binary import o16be as o16
from .JpegPresets import presets
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import IO, Any
from .MpoImagePlugin import MpoImageFile
#
# Parser
def Skip(self: JpegImageFile, marker: int) -> None:
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
ImageFile._safe_read(self.fp, n)
def APP(self: JpegImageFile, marker: int) -> None:
#
# Application marker. Store these in the APP dictionary.
# Also look for well-known application markers.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
app = f"APP{marker & 15}"
self.app[app] = s # compatibility
self.applist.append((app, s))
if marker == 0xFFE0 and s.startswith(b"JFIF"):
# extract JFIF information
self.info["jfif"] = version = i16(s, 5) # version
self.info["jfif_version"] = divmod(version, 256)
# extract JFIF properties
try:
jfif_unit = s[7]
jfif_density = i16(s, 8), i16(s, 10)
except Exception:
pass
else:
if jfif_unit == 1:
self.info["dpi"] = jfif_density
elif jfif_unit == 2: # cm
# 1 dpcm = 2.54 dpi
self.info["dpi"] = tuple(d * 2.54 for d in jfif_density)
self.info["jfif_unit"] = jfif_unit
self.info["jfif_density"] = jfif_density
elif marker == 0xFFE1 and s.startswith(b"Exif\0\0"):
# extract EXIF information
if "exif" in self.info:
self.info["exif"] += s[6:]
else:
self.info["exif"] = s
self._exif_offset = self.fp.tell() - n + 6
elif marker == 0xFFE1 and s.startswith(b"http://ns.adobe.com/xap/1.0/\x00"):
self.info["xmp"] = s.split(b"\x00", 1)[1]
elif marker == 0xFFE2 and s.startswith(b"FPXR\0"):
# extract FlashPix information (incomplete)
self.info["flashpix"] = s # FIXME: value will change
elif marker == 0xFFE2 and s.startswith(b"ICC_PROFILE\0"):
# Since an ICC profile can be larger than the maximum size of
# a JPEG marker (64K), we need provisions to split it into
# multiple markers. The format defined by the ICC specifies
# one or more APP2 markers containing the following data:
# Identifying string ASCII "ICC_PROFILE\0" (12 bytes)
# Marker sequence number 1, 2, etc (1 byte)
# Number of markers Total of APP2's used (1 byte)
# Profile data (remainder of APP2 data)
# Decoders should use the marker sequence numbers to
# reassemble the profile, rather than assuming that the APP2
# markers appear in the correct sequence.
self.icclist.append(s)
elif marker == 0xFFED and s.startswith(b"Photoshop 3.0\x00"):
# parse the image resource block
offset = 14
photoshop = self.info.setdefault("photoshop", {})
try:
while s[offset : offset + 4] == b"8BIM":
offset += 4
# resource code
code = i16(s, offset)
offset += 2
# resource name (usually empty)
name_len = s[offset]
# name = s[offset+1:offset+1+name_len]
offset += 1 + name_len
offset += offset & 1 # align
# resource data block
size = i32(s, offset)
offset += 4
data = s[offset : offset + size]
if code == 0x03ED: # ResolutionInfo
photoshop[code] = {
"XResolution": i32(data, 0) / 65536,
"DisplayedUnitsX": i16(data, 4),
"YResolution": i32(data, 8) / 65536,
"DisplayedUnitsY": i16(data, 12),
}
else:
photoshop[code] = data
offset += size
offset += offset & 1 # align
except struct.error:
pass # insufficient data
elif marker == 0xFFEE and s.startswith(b"Adobe"):
self.info["adobe"] = i16(s, 5)
# extract Adobe custom properties
try:
adobe_transform = s[11]
except IndexError:
pass
else:
self.info["adobe_transform"] = adobe_transform
elif marker == 0xFFE2 and s.startswith(b"MPF\0"):
# extract MPO information
self.info["mp"] = s[4:]
# offset is current location minus buffer size
# plus constant header size
self.info["mpoffset"] = self.fp.tell() - n + 4
def COM(self: JpegImageFile, marker: int) -> None:
#
# Comment marker. Store these in the APP dictionary.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
self.info["comment"] = s
self.app["COM"] = s # compatibility
self.applist.append(("COM", s))
def SOF(self: JpegImageFile, marker: int) -> None:
#
# Start of frame marker. Defines the size and mode of the
# image. JPEG is colour blind, so we use some simple
# heuristics to map the number of layers to an appropriate
# mode. Note that this could be made a bit brighter, by
# looking for JFIF and Adobe APP markers.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
self._size = i16(s, 3), i16(s, 1)
if self._im is not None and self.size != self.im.size:
self._im = None
self.bits = s[0]
if self.bits != 8:
msg = f"cannot handle {self.bits}-bit layers"
raise SyntaxError(msg)
self.layers = s[5]
if self.layers == 1:
self._mode = "L"
elif self.layers == 3:
self._mode = "RGB"
elif self.layers == 4:
self._mode = "CMYK"
else:
msg = f"cannot handle {self.layers}-layer images"
raise SyntaxError(msg)
if marker in [0xFFC2, 0xFFC6, 0xFFCA, 0xFFCE]:
self.info["progressive"] = self.info["progression"] = 1
if self.icclist:
# fixup icc profile
self.icclist.sort() # sort by sequence number
if self.icclist[0][13] == len(self.icclist):
profile = [p[14:] for p in self.icclist]
icc_profile = b"".join(profile)
else:
icc_profile = None # wrong number of fragments
self.info["icc_profile"] = icc_profile
self.icclist = []
for i in range(6, len(s), 3):
t = s[i : i + 3]
# 4-tuples: id, vsamp, hsamp, qtable
self.layer.append((t[0], t[1] // 16, t[1] & 15, t[2]))
def DQT(self: JpegImageFile, marker: int) -> None:
#
# Define quantization table. Note that there might be more
# than one table in each marker.
# FIXME: The quantization tables can be used to estimate the
# compression quality.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
while len(s):
v = s[0]
precision = 1 if (v // 16 == 0) else 2 # in bytes
qt_length = 1 + precision * 64
if len(s) < qt_length:
msg = "bad quantization table marker"
raise SyntaxError(msg)
data = array.array("B" if precision == 1 else "H", s[1:qt_length])
if sys.byteorder == "little" and precision > 1:
data.byteswap() # the values are always big-endian
self.quantization[v & 15] = [data[i] for i in zigzag_index]
s = s[qt_length:]
#
# JPEG marker table
MARKER = {
0xFFC0: ("SOF0", "Baseline DCT", SOF),
0xFFC1: ("SOF1", "Extended Sequential DCT", SOF),
0xFFC2: ("SOF2", "Progressive DCT", SOF),
0xFFC3: ("SOF3", "Spatial lossless", SOF),
0xFFC4: ("DHT", "Define Huffman table", Skip),
0xFFC5: ("SOF5", "Differential sequential DCT", SOF),
0xFFC6: ("SOF6", "Differential progressive DCT", SOF),
0xFFC7: ("SOF7", "Differential spatial", SOF),
0xFFC8: ("JPG", "Extension", None),
0xFFC9: ("SOF9", "Extended sequential DCT (AC)", SOF),
0xFFCA: ("SOF10", "Progressive DCT (AC)", SOF),
0xFFCB: ("SOF11", "Spatial lossless DCT (AC)", SOF),
0xFFCC: ("DAC", "Define arithmetic coding conditioning", Skip),
0xFFCD: ("SOF13", "Differential sequential DCT (AC)", SOF),
0xFFCE: ("SOF14", "Differential progressive DCT (AC)", SOF),
0xFFCF: ("SOF15", "Differential spatial (AC)", SOF),
0xFFD0: ("RST0", "Restart 0", None),
0xFFD1: ("RST1", "Restart 1", None),
0xFFD2: ("RST2", "Restart 2", None),
0xFFD3: ("RST3", "Restart 3", None),
0xFFD4: ("RST4", "Restart 4", None),
0xFFD5: ("RST5", "Restart 5", None),
0xFFD6: ("RST6", "Restart 6", None),
0xFFD7: ("RST7", "Restart 7", None),
0xFFD8: ("SOI", "Start of image", None),
0xFFD9: ("EOI", "End of image", None),
0xFFDA: ("SOS", "Start of scan", Skip),
0xFFDB: ("DQT", "Define quantization table", DQT),
0xFFDC: ("DNL", "Define number of lines", Skip),
0xFFDD: ("DRI", "Define restart interval", Skip),
0xFFDE: ("DHP", "Define hierarchical progression", SOF),
0xFFDF: ("EXP", "Expand reference component", Skip),
0xFFE0: ("APP0", "Application segment 0", APP),
0xFFE1: ("APP1", "Application segment 1", APP),
0xFFE2: ("APP2", "Application segment 2", APP),
0xFFE3: ("APP3", "Application segment 3", APP),
0xFFE4: ("APP4", "Application segment 4", APP),
0xFFE5: ("APP5", "Application segment 5", APP),
0xFFE6: ("APP6", "Application segment 6", APP),
0xFFE7: ("APP7", "Application segment 7", APP),
0xFFE8: ("APP8", "Application segment 8", APP),
0xFFE9: ("APP9", "Application segment 9", APP),
0xFFEA: ("APP10", "Application segment 10", APP),
0xFFEB: ("APP11", "Application segment 11", APP),
0xFFEC: ("APP12", "Application segment 12", APP),
0xFFED: ("APP13", "Application segment 13", APP),
0xFFEE: ("APP14", "Application segment 14", APP),
0xFFEF: ("APP15", "Application segment 15", APP),
0xFFF0: ("JPG0", "Extension 0", None),
0xFFF1: ("JPG1", "Extension 1", None),
0xFFF2: ("JPG2", "Extension 2", None),
0xFFF3: ("JPG3", "Extension 3", None),
0xFFF4: ("JPG4", "Extension 4", None),
0xFFF5: ("JPG5", "Extension 5", None),
0xFFF6: ("JPG6", "Extension 6", None),
0xFFF7: ("JPG7", "Extension 7", None),
0xFFF8: ("JPG8", "Extension 8", None),
0xFFF9: ("JPG9", "Extension 9", None),
0xFFFA: ("JPG10", "Extension 10", None),
0xFFFB: ("JPG11", "Extension 11", None),
0xFFFC: ("JPG12", "Extension 12", None),
0xFFFD: ("JPG13", "Extension 13", None),
0xFFFE: ("COM", "Comment", COM),
}
def _accept(prefix: bytes) -> bool:
# Magic number was taken from https://en.wikipedia.org/wiki/JPEG
return prefix.startswith(b"\xff\xd8\xff")
##
# Image plugin for JPEG and JFIF images.
class JpegImageFile(ImageFile.ImageFile):
format = "JPEG"
format_description = "JPEG (ISO 10918)"
def _open(self) -> None:
assert self.fp is not None
s = self.fp.read(3)
if not _accept(s):
msg = "not a JPEG file"
raise SyntaxError(msg)
s = b"\xff"
# Create attributes
self.bits = self.layers = 0
self._exif_offset = 0
# JPEG specifics (internal)
self.layer: list[tuple[int, int, int, int]] = []
self._huffman_dc: dict[Any, Any] = {}
self._huffman_ac: dict[Any, Any] = {}
self.quantization: dict[int, list[int]] = {}
self.app: dict[str, bytes] = {} # compatibility
self.applist: list[tuple[str, bytes]] = []
self.icclist: list[bytes] = []
while True:
i = s[0]
if i == 0xFF:
s = s + self.fp.read(1)
i = i16(s)
else:
# Skip non-0xFF junk
s = self.fp.read(1)
continue
if i in MARKER:
name, description, handler = MARKER[i]
if handler is not None:
handler(self, i)
if i == 0xFFDA: # start of scan
rawmode = self.mode
if self.mode == "CMYK":
rawmode = "CMYK;I" # assume adobe conventions
self.tile = [
ImageFile._Tile("jpeg", (0, 0) + self.size, 0, (rawmode, ""))
]
# self.__offset = self.fp.tell()
break
s = self.fp.read(1)
elif i in {0, 0xFFFF}:
# padded marker or junk; move on
s = b"\xff"
elif i == 0xFF00: # Skip extraneous data (escaped 0xFF)
s = self.fp.read(1)
else:
msg = "no marker found"
raise SyntaxError(msg)
self._read_dpi_from_exif()
def __getstate__(self) -> list[Any]:
return super().__getstate__() + [self.layers, self.layer]
def __setstate__(self, state: list[Any]) -> None:
self.layers, self.layer = state[6:]
super().__setstate__(state)
def load_read(self, read_bytes: int) -> bytes:
"""
internal: read more image data
For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker
so libjpeg can finish decoding
"""
assert self.fp is not None
s = self.fp.read(read_bytes)
if not s and ImageFile.LOAD_TRUNCATED_IMAGES and not hasattr(self, "_ended"):
# Premature EOF.
# Pretend file is finished adding EOI marker
self._ended = True
return b"\xff\xd9"
return s
def draft(
self, mode: str | None, size: tuple[int, int] | None
) -> tuple[str, tuple[int, int, float, float]] | None:
if len(self.tile) != 1:
return None
# Protect from second call
if self.decoderconfig:
return None
d, e, o, a = self.tile[0]
scale = 1
original_size = self.size
assert isinstance(a, tuple)
if a[0] == "RGB" and mode in ["L", "YCbCr"]:
self._mode = mode
a = mode, ""
if size:
scale = min(self.size[0] // size[0], self.size[1] // size[1])
for s in [8, 4, 2, 1]:
if scale >= s:
break
assert e is not None
e = (
e[0],
e[1],
(e[2] - e[0] + s - 1) // s + e[0],
(e[3] - e[1] + s - 1) // s + e[1],
)
self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s)
scale = s
self.tile = [ImageFile._Tile(d, e, o, a)]
self.decoderconfig = (scale, 0)
box = (0, 0, original_size[0] / scale, original_size[1] / scale)
return self.mode, box
def load_djpeg(self) -> None:
# ALTERNATIVE: handle JPEGs via the IJG command line utilities
f, path = tempfile.mkstemp()
os.close(f)
if os.path.exists(self.filename):
subprocess.check_call(["djpeg", "-outfile", path, self.filename])
else:
try:
os.unlink(path)
except OSError:
pass
msg = "Invalid Filename"
raise ValueError(msg)
try:
with Image.open(path) as _im:
_im.load()
self.im = _im.im
finally:
try:
os.unlink(path)
except OSError:
pass
self._mode = self.im.mode
self._size = self.im.size
self.tile = []
def _getexif(self) -> dict[int, Any] | None:
return _getexif(self)
def _read_dpi_from_exif(self) -> None:
# If DPI isn't in JPEG header, fetch from EXIF
if "dpi" in self.info or "exif" not in self.info:
return
try:
exif = self.getexif()
resolution_unit = exif[0x0128]
x_resolution = exif[0x011A]
try:
dpi = float(x_resolution[0]) / x_resolution[1]
except TypeError:
dpi = x_resolution
if math.isnan(dpi):
msg = "DPI is not a number"
raise ValueError(msg)
if resolution_unit == 3: # cm
# 1 dpcm = 2.54 dpi
dpi *= 2.54
self.info["dpi"] = dpi, dpi
except (
struct.error, # truncated EXIF
KeyError, # dpi not included
SyntaxError, # invalid/unreadable EXIF
TypeError, # dpi is an invalid float
ValueError, # dpi is an invalid float
ZeroDivisionError, # invalid dpi rational value
):
self.info["dpi"] = 72, 72
def _getmp(self) -> dict[int, Any] | None:
return _getmp(self)
def _getexif(self: JpegImageFile) -> dict[int, Any] | None:
if "exif" not in self.info:
return None
return self.getexif()._get_merged_dict()
def _getmp(self: JpegImageFile) -> dict[int, Any] | None:
# Extract MP information. This method was inspired by the "highly
# experimental" _getexif version that's been in use for years now,
# itself based on the ImageFileDirectory class in the TIFF plugin.
# The MP record essentially consists of a TIFF file embedded in a JPEG
# application marker.
try:
data = self.info["mp"]
except KeyError:
return None
file_contents = io.BytesIO(data)
head = file_contents.read(8)
endianness = ">" if head.startswith(b"\x4d\x4d\x00\x2a") else "<"
# process dictionary
from . import TiffImagePlugin
try:
info = TiffImagePlugin.ImageFileDirectory_v2(head)
file_contents.seek(info.next)
info.load(file_contents)
mp = dict(info)
except Exception as e:
msg = "malformed MP Index (unreadable directory)"
raise SyntaxError(msg) from e
# it's an error not to have a number of images
try:
quant = mp[0xB001]
except KeyError as e:
msg = "malformed MP Index (no number of images)"
raise SyntaxError(msg) from e
# get MP entries
mpentries = []
try:
rawmpentries = mp[0xB002]
for entrynum in range(quant):
unpackedentry = struct.unpack_from(
f"{endianness}LLLHH", rawmpentries, entrynum * 16
)
labels = ("Attribute", "Size", "DataOffset", "EntryNo1", "EntryNo2")
mpentry = dict(zip(labels, unpackedentry))
mpentryattr = {
"DependentParentImageFlag": bool(mpentry["Attribute"] & (1 << 31)),
"DependentChildImageFlag": bool(mpentry["Attribute"] & (1 << 30)),
"RepresentativeImageFlag": bool(mpentry["Attribute"] & (1 << 29)),
"Reserved": (mpentry["Attribute"] & (3 << 27)) >> 27,
"ImageDataFormat": (mpentry["Attribute"] & (7 << 24)) >> 24,
"MPType": mpentry["Attribute"] & 0x00FFFFFF,
}
if mpentryattr["ImageDataFormat"] == 0:
mpentryattr["ImageDataFormat"] = "JPEG"
else:
msg = "unsupported picture format in MPO"
raise SyntaxError(msg)
mptypemap = {
0x000000: "Undefined",
0x010001: "Large Thumbnail (VGA Equivalent)",
0x010002: "Large Thumbnail (Full HD Equivalent)",
0x020001: "Multi-Frame Image (Panorama)",
0x020002: "Multi-Frame Image: (Disparity)",
0x020003: "Multi-Frame Image: (Multi-Angle)",
0x030000: "Baseline MP Primary Image",
}
mpentryattr["MPType"] = mptypemap.get(mpentryattr["MPType"], "Unknown")
mpentry["Attribute"] = mpentryattr
mpentries.append(mpentry)
mp[0xB002] = mpentries
except KeyError as e:
msg = "malformed MP Index (bad MP Entry)"
raise SyntaxError(msg) from e
# Next we should try and parse the individual image unique ID list;
# we don't because I've never seen this actually used in a real MPO
# file and so can't test it.
return mp
# --------------------------------------------------------------------
# stuff to save JPEG files
RAWMODE = {
"1": "L",
"L": "L",
"RGB": "RGB",
"RGBX": "RGB",
"CMYK": "CMYK;I", # assume adobe conventions
"YCbCr": "YCbCr",
}
# fmt: off
zigzag_index = (
0, 1, 5, 6, 14, 15, 27, 28,
2, 4, 7, 13, 16, 26, 29, 42,
3, 8, 12, 17, 25, 30, 41, 43,
9, 11, 18, 24, 31, 40, 44, 53,
10, 19, 23, 32, 39, 45, 52, 54,
20, 22, 33, 38, 46, 51, 55, 60,
21, 34, 37, 47, 50, 56, 59, 61,
35, 36, 48, 49, 57, 58, 62, 63,
)
samplings = {
(1, 1, 1, 1, 1, 1): 0,
(2, 1, 1, 1, 1, 1): 1,
(2, 2, 1, 1, 1, 1): 2,
}
# fmt: on
def get_sampling(im: Image.Image) -> int:
# There's no subsampling when images have only 1 layer
# (grayscale images) or when they are CMYK (4 layers),
# so set subsampling to the default value.
#
# NOTE: currently Pillow can't encode JPEG to YCCK format.
# If YCCK support is added in the future, subsampling code will have
# to be updated (here and in JpegEncode.c) to deal with 4 layers.
if not isinstance(im, JpegImageFile) or im.layers in (1, 4):
return -1
sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3]
return samplings.get(sampling, -1)
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
try:
rawmode = RAWMODE[im.mode]
except KeyError as e:
msg = f"cannot write mode {im.mode} as JPEG"
raise OSError(msg) from e
info = im.encoderinfo
dpi = [round(x) for x in info.get("dpi", (0, 0))]
quality = info.get("quality", -1)
subsampling = info.get("subsampling", -1)
qtables = info.get("qtables")
if quality == "keep":
quality = -1
subsampling = "keep"
qtables = "keep"
elif quality in presets:
preset = presets[quality]
quality = -1
subsampling = preset.get("subsampling", -1)
qtables = preset.get("quantization")
elif not isinstance(quality, int):
msg = "Invalid quality setting"
raise ValueError(msg)
else:
if subsampling in presets:
subsampling = presets[subsampling].get("subsampling", -1)
if isinstance(qtables, str) and qtables in presets:
qtables = presets[qtables].get("quantization")
if subsampling == "4:4:4":
subsampling = 0
elif subsampling == "4:2:2":
subsampling = 1
elif subsampling == "4:2:0":
subsampling = 2
elif subsampling == "4:1:1":
# For compatibility. Before Pillow 4.3, 4:1:1 actually meant 4:2:0.
# Set 4:2:0 if someone is still using that value.
subsampling = 2
elif subsampling == "keep":
if im.format != "JPEG":
msg = "Cannot use 'keep' when original image is not a JPEG"
raise ValueError(msg)
subsampling = get_sampling(im)
def validate_qtables(
qtables: (
str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None
),
) -> list[list[int]] | None:
if qtables is None:
return qtables
if isinstance(qtables, str):
try:
lines = [
int(num)
for line in qtables.splitlines()
for num in line.split("#", 1)[0].split()
]
except ValueError as e:
msg = "Invalid quantization table"
raise ValueError(msg) from e
else:
qtables = [lines[s : s + 64] for s in range(0, len(lines), 64)]
if isinstance(qtables, (tuple, list, dict)):
if isinstance(qtables, dict):
qtables = [
qtables[key] for key in range(len(qtables)) if key in qtables
]
elif isinstance(qtables, tuple):
qtables = list(qtables)
if not (0 < len(qtables) < 5):
msg = "None or too many quantization tables"
raise ValueError(msg)
try:
for idx, table in enumerate(qtables):
if len(table) != 64:
msg = "Invalid quantization table"
raise TypeError(msg)
qtables[idx] = list(array.array("H", table))
except TypeError as e:
msg = "Invalid quantization table"
raise ValueError(msg) from e
return qtables
if qtables == "keep":
if im.format != "JPEG":
msg = "Cannot use 'keep' when original image is not a JPEG"
raise ValueError(msg)
qtables = getattr(im, "quantization", None)
qtables = validate_qtables(qtables)
extra = info.get("extra", b"")
MAX_BYTES_IN_MARKER = 65533
if xmp := info.get("xmp"):
overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00"
max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len
if len(xmp) > max_data_bytes_in_marker:
msg = "XMP data is too long"
raise ValueError(msg)
size = o16(2 + overhead_len + len(xmp))
extra += b"\xff\xe1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp
if icc_profile := info.get("icc_profile"):
overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers))
max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len
markers = []
while icc_profile:
markers.append(icc_profile[:max_data_bytes_in_marker])
icc_profile = icc_profile[max_data_bytes_in_marker:]
i = 1
for marker in markers:
size = o16(2 + overhead_len + len(marker))
extra += (
b"\xff\xe2"
+ size
+ b"ICC_PROFILE\0"
+ o8(i)
+ o8(len(markers))
+ marker
)
i += 1
comment = info.get("comment", im.info.get("comment"))
# "progressive" is the official name, but older documentation
# says "progression"
# FIXME: issue a warning if the wrong form is used (post-1.1.7)
progressive = info.get("progressive", False) or info.get("progression", False)
optimize = info.get("optimize", False)
exif = info.get("exif", b"")
if isinstance(exif, Image.Exif):
exif = exif.tobytes()
if len(exif) > MAX_BYTES_IN_MARKER:
msg = "EXIF data is too long"
raise ValueError(msg)
# get keyword arguments
im.encoderconfig = (
quality,
progressive,
info.get("smooth", 0),
optimize,
info.get("keep_rgb", False),
info.get("streamtype", 0),
dpi,
subsampling,
info.get("restart_marker_blocks", 0),
info.get("restart_marker_rows", 0),
qtables,
comment,
extra,
exif,
)
# if we optimize, libjpeg needs a buffer big enough to hold the whole image
# in a shot. Guessing on the size, at im.size bytes. (raw pixel size is
# channels*size, this is a value that's been used in a django patch.
# https://github.com/matthewwithanm/django-imagekit/issues/50
if optimize or progressive:
# CMYK can be bigger
if im.mode == "CMYK":
bufsize = 4 * im.size[0] * im.size[1]
# keep sets quality to -1, but the actual value may be high.
elif quality >= 95 or quality == -1:
bufsize = 2 * im.size[0] * im.size[1]
else:
bufsize = im.size[0] * im.size[1]
if exif:
bufsize += len(exif) + 5
if extra:
bufsize += len(extra) + 1
else:
# The EXIF info needs to be written as one block, + APP1, + one spare byte.
# Ensure that our buffer is big enough. Same with the icc_profile block.
bufsize = max(len(exif) + 5, len(extra) + 1)
ImageFile._save(
im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize
)
##
# Factory for making JPEG and MPO instances
def jpeg_factory(
fp: IO[bytes], filename: str | bytes | None = None
) -> JpegImageFile | MpoImageFile:
im = JpegImageFile(fp, filename)
try:
mpheader = im._getmp()
if mpheader is not None and mpheader[45057] > 1:
for segment, content in im.applist:
if segment == "APP1" and b' hdrgm:Version="' in content:
# Ultra HDR images are not yet supported
return im
# It's actually an MPO
from .MpoImagePlugin import MpoImageFile
# Don't reload everything, just convert it.
im = MpoImageFile.adopt(im, mpheader)
except (TypeError, IndexError):
# It is really a JPEG
pass
except SyntaxError:
warnings.warn(
"Image appears to be a malformed MPO file, it will be "
"interpreted as a base JPEG file"
)
return im
# ---------------------------------------------------------------------
# Registry stuff
Image.register_open(JpegImageFile.format, jpeg_factory, _accept)
Image.register_save(JpegImageFile.format, _save)
Image.register_extensions(JpegImageFile.format, [".jfif", ".jpe", ".jpg", ".jpeg"])
Image.register_mime(JpegImageFile.format, "image/jpeg")

View File

@@ -1,242 +0,0 @@
"""
JPEG quality settings equivalent to the Photoshop settings.
Can be used when saving JPEG files.
The following presets are available by default:
``web_low``, ``web_medium``, ``web_high``, ``web_very_high``, ``web_maximum``,
``low``, ``medium``, ``high``, ``maximum``.
More presets can be added to the :py:data:`presets` dict if needed.
To apply the preset, specify::
quality="preset_name"
To apply only the quantization table::
qtables="preset_name"
To apply only the subsampling setting::
subsampling="preset_name"
Example::
im.save("image_name.jpg", quality="web_high")
Subsampling
-----------
Subsampling is the practice of encoding images by implementing less resolution
for chroma information than for luma information.
(ref.: https://en.wikipedia.org/wiki/Chroma_subsampling)
Possible subsampling values are 0, 1 and 2 that correspond to 4:4:4, 4:2:2 and
4:2:0.
You can get the subsampling of a JPEG with the
:func:`.JpegImagePlugin.get_sampling` function.
In JPEG compressed data a JPEG marker is used instead of an EXIF tag.
(ref.: https://exiv2.org/tags.html)
Quantization tables
-------------------
They are values use by the DCT (Discrete cosine transform) to remove
*unnecessary* information from the image (the lossy part of the compression).
(ref.: https://en.wikipedia.org/wiki/Quantization_matrix#Quantization_matrices,
https://en.wikipedia.org/wiki/JPEG#Quantization)
You can get the quantization tables of a JPEG with::
im.quantization
This will return a dict with a number of lists. You can pass this dict
directly as the qtables argument when saving a JPEG.
The quantization table format in presets is a list with sublists. These formats
are interchangeable.
Libjpeg ref.:
https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html
"""
from __future__ import annotations
# fmt: off
presets = {
'web_low': {'subsampling': 2, # "4:2:0"
'quantization': [
[20, 16, 25, 39, 50, 46, 62, 68,
16, 18, 23, 38, 38, 53, 65, 68,
25, 23, 31, 38, 53, 65, 68, 68,
39, 38, 38, 53, 65, 68, 68, 68,
50, 38, 53, 65, 68, 68, 68, 68,
46, 53, 65, 68, 68, 68, 68, 68,
62, 65, 68, 68, 68, 68, 68, 68,
68, 68, 68, 68, 68, 68, 68, 68],
[21, 25, 32, 38, 54, 68, 68, 68,
25, 28, 24, 38, 54, 68, 68, 68,
32, 24, 32, 43, 66, 68, 68, 68,
38, 38, 43, 53, 68, 68, 68, 68,
54, 54, 66, 68, 68, 68, 68, 68,
68, 68, 68, 68, 68, 68, 68, 68,
68, 68, 68, 68, 68, 68, 68, 68,
68, 68, 68, 68, 68, 68, 68, 68]
]},
'web_medium': {'subsampling': 2, # "4:2:0"
'quantization': [
[16, 11, 11, 16, 23, 27, 31, 30,
11, 12, 12, 15, 20, 23, 23, 30,
11, 12, 13, 16, 23, 26, 35, 47,
16, 15, 16, 23, 26, 37, 47, 64,
23, 20, 23, 26, 39, 51, 64, 64,
27, 23, 26, 37, 51, 64, 64, 64,
31, 23, 35, 47, 64, 64, 64, 64,
30, 30, 47, 64, 64, 64, 64, 64],
[17, 15, 17, 21, 20, 26, 38, 48,
15, 19, 18, 17, 20, 26, 35, 43,
17, 18, 20, 22, 26, 30, 46, 53,
21, 17, 22, 28, 30, 39, 53, 64,
20, 20, 26, 30, 39, 48, 64, 64,
26, 26, 30, 39, 48, 63, 64, 64,
38, 35, 46, 53, 64, 64, 64, 64,
48, 43, 53, 64, 64, 64, 64, 64]
]},
'web_high': {'subsampling': 0, # "4:4:4"
'quantization': [
[6, 4, 4, 6, 9, 11, 12, 16,
4, 5, 5, 6, 8, 10, 12, 12,
4, 5, 5, 6, 10, 12, 14, 19,
6, 6, 6, 11, 12, 15, 19, 28,
9, 8, 10, 12, 16, 20, 27, 31,
11, 10, 12, 15, 20, 27, 31, 31,
12, 12, 14, 19, 27, 31, 31, 31,
16, 12, 19, 28, 31, 31, 31, 31],
[7, 7, 13, 24, 26, 31, 31, 31,
7, 12, 16, 21, 31, 31, 31, 31,
13, 16, 17, 31, 31, 31, 31, 31,
24, 21, 31, 31, 31, 31, 31, 31,
26, 31, 31, 31, 31, 31, 31, 31,
31, 31, 31, 31, 31, 31, 31, 31,
31, 31, 31, 31, 31, 31, 31, 31,
31, 31, 31, 31, 31, 31, 31, 31]
]},
'web_very_high': {'subsampling': 0, # "4:4:4"
'quantization': [
[2, 2, 2, 2, 3, 4, 5, 6,
2, 2, 2, 2, 3, 4, 5, 6,
2, 2, 2, 2, 4, 5, 7, 9,
2, 2, 2, 4, 5, 7, 9, 12,
3, 3, 4, 5, 8, 10, 12, 12,
4, 4, 5, 7, 10, 12, 12, 12,
5, 5, 7, 9, 12, 12, 12, 12,
6, 6, 9, 12, 12, 12, 12, 12],
[3, 3, 5, 9, 13, 15, 15, 15,
3, 4, 6, 11, 14, 12, 12, 12,
5, 6, 9, 14, 12, 12, 12, 12,
9, 11, 14, 12, 12, 12, 12, 12,
13, 14, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12]
]},
'web_maximum': {'subsampling': 0, # "4:4:4"
'quantization': [
[1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 2,
1, 1, 1, 1, 1, 1, 2, 2,
1, 1, 1, 1, 1, 2, 2, 3,
1, 1, 1, 1, 2, 2, 3, 3,
1, 1, 1, 2, 2, 3, 3, 3,
1, 1, 2, 2, 3, 3, 3, 3],
[1, 1, 1, 2, 2, 3, 3, 3,
1, 1, 1, 2, 3, 3, 3, 3,
1, 1, 1, 3, 3, 3, 3, 3,
2, 2, 3, 3, 3, 3, 3, 3,
2, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3]
]},
'low': {'subsampling': 2, # "4:2:0"
'quantization': [
[18, 14, 14, 21, 30, 35, 34, 17,
14, 16, 16, 19, 26, 23, 12, 12,
14, 16, 17, 21, 23, 12, 12, 12,
21, 19, 21, 23, 12, 12, 12, 12,
30, 26, 23, 12, 12, 12, 12, 12,
35, 23, 12, 12, 12, 12, 12, 12,
34, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12],
[20, 19, 22, 27, 20, 20, 17, 17,
19, 25, 23, 14, 14, 12, 12, 12,
22, 23, 14, 14, 12, 12, 12, 12,
27, 14, 14, 12, 12, 12, 12, 12,
20, 14, 12, 12, 12, 12, 12, 12,
20, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12]
]},
'medium': {'subsampling': 2, # "4:2:0"
'quantization': [
[12, 8, 8, 12, 17, 21, 24, 17,
8, 9, 9, 11, 15, 19, 12, 12,
8, 9, 10, 12, 19, 12, 12, 12,
12, 11, 12, 21, 12, 12, 12, 12,
17, 15, 19, 12, 12, 12, 12, 12,
21, 19, 12, 12, 12, 12, 12, 12,
24, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12],
[13, 11, 13, 16, 20, 20, 17, 17,
11, 14, 14, 14, 14, 12, 12, 12,
13, 14, 14, 14, 12, 12, 12, 12,
16, 14, 14, 12, 12, 12, 12, 12,
20, 14, 12, 12, 12, 12, 12, 12,
20, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12]
]},
'high': {'subsampling': 0, # "4:4:4"
'quantization': [
[6, 4, 4, 6, 9, 11, 12, 16,
4, 5, 5, 6, 8, 10, 12, 12,
4, 5, 5, 6, 10, 12, 12, 12,
6, 6, 6, 11, 12, 12, 12, 12,
9, 8, 10, 12, 12, 12, 12, 12,
11, 10, 12, 12, 12, 12, 12, 12,
12, 12, 12, 12, 12, 12, 12, 12,
16, 12, 12, 12, 12, 12, 12, 12],
[7, 7, 13, 24, 20, 20, 17, 17,
7, 12, 16, 14, 14, 12, 12, 12,
13, 16, 14, 14, 12, 12, 12, 12,
24, 14, 14, 12, 12, 12, 12, 12,
20, 14, 12, 12, 12, 12, 12, 12,
20, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12]
]},
'maximum': {'subsampling': 0, # "4:4:4"
'quantization': [
[2, 2, 2, 2, 3, 4, 5, 6,
2, 2, 2, 2, 3, 4, 5, 6,
2, 2, 2, 2, 4, 5, 7, 9,
2, 2, 2, 4, 5, 7, 9, 12,
3, 3, 4, 5, 8, 10, 12, 12,
4, 4, 5, 7, 10, 12, 12, 12,
5, 5, 7, 9, 12, 12, 12, 12,
6, 6, 9, 12, 12, 12, 12, 12],
[3, 3, 5, 9, 13, 15, 15, 15,
3, 4, 6, 10, 14, 12, 12, 12,
5, 6, 9, 14, 12, 12, 12, 12,
9, 10, 14, 12, 12, 12, 12, 12,
13, 14, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12]
]},
}
# fmt: on

View File

@@ -1,78 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# Basic McIdas support for PIL
#
# History:
# 1997-05-05 fl Created (8-bit images only)
# 2009-03-08 fl Added 16/32-bit support.
#
# Thanks to Richard Jones and Craig Swank for specs and samples.
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1997.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import struct
from . import Image, ImageFile
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\x00\x00\x00\x00\x00\x00\x00\x04")
##
# Image plugin for McIdas area images.
class McIdasImageFile(ImageFile.ImageFile):
format = "MCIDAS"
format_description = "McIdas area file"
def _open(self) -> None:
# parse area file directory
assert self.fp is not None
s = self.fp.read(256)
if not _accept(s) or len(s) != 256:
msg = "not an McIdas area file"
raise SyntaxError(msg)
self.area_descriptor_raw = s
self.area_descriptor = w = [0, *struct.unpack("!64i", s)]
# get mode
if w[11] == 1:
mode = rawmode = "L"
elif w[11] == 2:
mode = rawmode = "I;16B"
elif w[11] == 4:
# FIXME: add memory map support
mode = "I"
rawmode = "I;32B"
else:
msg = "unsupported McIdas format"
raise SyntaxError(msg)
self._mode = mode
self._size = w[10], w[9]
offset = w[34] + w[15]
stride = w[15] + w[10] * w[11] * w[14]
self.tile = [
ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1))
]
# --------------------------------------------------------------------
# registry
Image.register_open(McIdasImageFile.format, McIdasImageFile, _accept)
# no default extension

View File

@@ -1,103 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# Microsoft Image Composer support for PIL
#
# Notes:
# uses TiffImagePlugin.py to read the actual image streams
#
# History:
# 97-01-20 fl Created
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1997.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import olefile
from . import Image, TiffImagePlugin
#
# --------------------------------------------------------------------
def _accept(prefix: bytes) -> bool:
return prefix.startswith(olefile.MAGIC)
##
# Image plugin for Microsoft's Image Composer file format.
class MicImageFile(TiffImagePlugin.TiffImageFile):
format = "MIC"
format_description = "Microsoft Image Composer"
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
# read the OLE directory and see if this is a likely
# to be a Microsoft Image Composer file
try:
self.ole = olefile.OleFileIO(self.fp)
except OSError as e:
msg = "not an MIC file; invalid OLE file"
raise SyntaxError(msg) from e
# find ACI subfiles with Image members (maybe not the
# best way to identify MIC files, but what the... ;-)
self.images = [
path
for path in self.ole.listdir()
if path[1:] and path[0].endswith(".ACI") and path[1] == "Image"
]
# if we didn't find any images, this is probably not
# an MIC file.
if not self.images:
msg = "not an MIC file; no image entries"
raise SyntaxError(msg)
self.frame = -1
self._n_frames = len(self.images)
self.is_animated = self._n_frames > 1
assert self.fp is not None
self.__fp = self.fp
self.seek(0)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
filename = self.images[frame]
self.fp = self.ole.openstream(filename)
TiffImagePlugin.TiffImageFile._open(self)
self.frame = frame
def tell(self) -> int:
return self.frame
def close(self) -> None:
self.__fp.close()
self.ole.close()
super().close()
def __exit__(self, *args: object) -> None:
self.__fp.close()
self.ole.close()
super().__exit__()
#
# --------------------------------------------------------------------
Image.register_open(MicImageFile.format, MicImageFile, _accept)
Image.register_extension(MicImageFile.format, ".mic")

View File

@@ -1,84 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# MPEG file handling
#
# History:
# 95-09-09 fl Created
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1995.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image, ImageFile
from ._binary import i8
from ._typing import SupportsRead
#
# Bitstream parser
class BitStream:
def __init__(self, fp: SupportsRead[bytes]) -> None:
self.fp = fp
self.bits = 0
self.bitbuffer = 0
def next(self) -> int:
return i8(self.fp.read(1))
def peek(self, bits: int) -> int:
while self.bits < bits:
self.bitbuffer = (self.bitbuffer << 8) + self.next()
self.bits += 8
return self.bitbuffer >> (self.bits - bits) & (1 << bits) - 1
def skip(self, bits: int) -> None:
while self.bits < bits:
self.bitbuffer = (self.bitbuffer << 8) + i8(self.fp.read(1))
self.bits += 8
self.bits = self.bits - bits
def read(self, bits: int) -> int:
v = self.peek(bits)
self.bits = self.bits - bits
return v
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\x00\x00\x01\xb3")
##
# Image plugin for MPEG streams. This plugin can identify a stream,
# but it cannot read it.
class MpegImageFile(ImageFile.ImageFile):
format = "MPEG"
format_description = "MPEG"
def _open(self) -> None:
assert self.fp is not None
s = BitStream(self.fp)
if s.read(32) != 0x1B3:
msg = "not an MPEG file"
raise SyntaxError(msg)
self._mode = "RGB"
self._size = s.read(12), s.read(12)
# --------------------------------------------------------------------
# Registry stuff
Image.register_open(MpegImageFile.format, MpegImageFile, _accept)
Image.register_extensions(MpegImageFile.format, [".mpg", ".mpeg"])
Image.register_mime(MpegImageFile.format, "video/mpeg")

View File

@@ -1,203 +0,0 @@
#
# The Python Imaging Library.
# $Id$
#
# MPO file handling
#
# See "Multi-Picture Format" (CIPA DC-007-Translation 2009, Standard of the
# Camera & Imaging Products Association)
#
# The multi-picture object combines multiple JPEG images (with a modified EXIF
# data format) into a single file. While it can theoretically be used much like
# a GIF animation, it is commonly used to represent 3D photographs and is (as
# of this writing) the most commonly used format by 3D cameras.
#
# History:
# 2014-03-13 Feneric Created
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
import struct
from typing import IO, Any, cast
from . import (
Image,
ImageFile,
ImageSequence,
JpegImagePlugin,
TiffImagePlugin,
)
from ._binary import o32le
from ._util import DeferredError
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
JpegImagePlugin._save(im, fp, filename)
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
append_images = im.encoderinfo.get("append_images", [])
if not append_images and not getattr(im, "is_animated", False):
_save(im, fp, filename)
return
mpf_offset = 28
offsets: list[int] = []
im_sequences = [im, *append_images]
total = sum(getattr(seq, "n_frames", 1) for seq in im_sequences)
for im_sequence in im_sequences:
for im_frame in ImageSequence.Iterator(im_sequence):
if not offsets:
# APP2 marker
ifd_length = 66 + 16 * total
im_frame.encoderinfo["extra"] = (
b"\xff\xe2"
+ struct.pack(">H", 6 + ifd_length)
+ b"MPF\0"
+ b" " * ifd_length
)
if exif := im_frame.encoderinfo.get("exif"):
if isinstance(exif, Image.Exif):
exif = exif.tobytes()
im_frame.encoderinfo["exif"] = exif
mpf_offset += 4 + len(exif)
JpegImagePlugin._save(im_frame, fp, filename)
offsets.append(fp.tell())
else:
encoderinfo = im_frame._attach_default_encoderinfo(im)
im_frame.save(fp, "JPEG")
im_frame.encoderinfo = encoderinfo
offsets.append(fp.tell() - offsets[-1])
ifd = TiffImagePlugin.ImageFileDirectory_v2()
ifd[0xB000] = b"0100"
ifd[0xB001] = len(offsets)
mpentries = b""
data_offset = 0
for i, size in enumerate(offsets):
if i == 0:
mptype = 0x030000 # Baseline MP Primary Image
else:
mptype = 0x000000 # Undefined
mpentries += struct.pack("<LLLHH", mptype, size, data_offset, 0, 0)
if i == 0:
data_offset -= mpf_offset
data_offset += size
ifd[0xB002] = mpentries
fp.seek(mpf_offset)
fp.write(b"II\x2a\x00" + o32le(8) + ifd.tobytes(8))
fp.seek(0, os.SEEK_END)
##
# Image plugin for MPO images.
class MpoImageFile(JpegImagePlugin.JpegImageFile):
format = "MPO"
format_description = "MPO (CIPA DC-007)"
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
assert self.fp is not None
self.fp.seek(0) # prep the fp in order to pass the JPEG test
JpegImagePlugin.JpegImageFile._open(self)
self._after_jpeg_open()
def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None:
self.mpinfo = mpheader if mpheader is not None else self._getmp()
if self.mpinfo is None:
msg = "Image appears to be a malformed MPO file"
raise ValueError(msg)
self.n_frames = self.mpinfo[0xB001]
self.__mpoffsets = [
mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002]
]
self.__mpoffsets[0] = 0
# Note that the following assertion will only be invalid if something
# gets broken within JpegImagePlugin.
assert self.n_frames == len(self.__mpoffsets)
del self.info["mpoffset"] # no longer needed
self.is_animated = self.n_frames > 1
assert self.fp is not None
self._fp = self.fp # FIXME: hack
self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame
self.__frame = 0
self.offset = 0
# for now we can only handle reading and individual frame extraction
self.readonly = 1
def load_seek(self, pos: int) -> None:
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self._fp.seek(pos)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self.fp = self._fp
self.offset = self.__mpoffsets[frame]
original_exif = self.info.get("exif")
if "exif" in self.info:
del self.info["exif"]
self.fp.seek(self.offset + 2) # skip SOI marker
if not self.fp.read(2):
msg = "No data found for frame"
raise ValueError(msg)
self.fp.seek(self.offset)
JpegImagePlugin.JpegImageFile._open(self)
if self.info.get("exif") != original_exif:
self._reload_exif()
self.tile = [
ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])
]
self.__frame = frame
def tell(self) -> int:
return self.__frame
@staticmethod
def adopt(
jpeg_instance: JpegImagePlugin.JpegImageFile,
mpheader: dict[int, Any] | None = None,
) -> MpoImageFile:
"""
Transform the instance of JpegImageFile into
an instance of MpoImageFile.
After the call, the JpegImageFile is extended
to be an MpoImageFile.
This is essentially useful when opening a JPEG
file that reveals itself as an MPO, to avoid
double call to _open.
"""
jpeg_instance.__class__ = MpoImageFile
mpo_instance = cast(MpoImageFile, jpeg_instance)
mpo_instance._after_jpeg_open(mpheader)
return mpo_instance
# ---------------------------------------------------------------------
# Registry stuff
# Note that since MPO shares a factory with JPEG, we do not need to do a
# separate registration for it here.
# Image.register_open(MpoImageFile.format,
# JpegImagePlugin.jpeg_factory, _accept)
Image.register_save(MpoImageFile.format, _save)
Image.register_save_all(MpoImageFile.format, _save_all)
Image.register_extension(MpoImageFile.format, ".mpo")
Image.register_mime(MpoImageFile.format, "image/mpo")

View File

@@ -1,200 +0,0 @@
#
# The Python Imaging Library.
#
# MSP file handling
#
# This is the format used by the Paint program in Windows 1 and 2.
#
# History:
# 95-09-05 fl Created
# 97-01-03 fl Read/write MSP images
# 17-02-21 es Fixed RLE interpretation
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1995-97.
# Copyright (c) Eric Soroos 2017.
#
# See the README file for information on usage and redistribution.
#
# More info on this format: https://archive.org/details/gg243631
# Page 313:
# Figure 205. Windows Paint Version 1: "DanM" Format
# Figure 206. Windows Paint Version 2: "LinS" Format. Used in Windows V2.03
#
# See also: https://www.fileformat.info/format/mspaint/egff.htm
from __future__ import annotations
import io
import struct
from typing import IO
from . import Image, ImageFile
from ._binary import i16le as i16
from ._binary import o16le as o16
#
# read MSP files
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"DanM", b"LinS"))
##
# Image plugin for Windows MSP images. This plugin supports both
# uncompressed (Windows 1.0).
class MspImageFile(ImageFile.ImageFile):
format = "MSP"
format_description = "Windows Paint"
def _open(self) -> None:
# Header
assert self.fp is not None
s = self.fp.read(32)
if not _accept(s):
msg = "not an MSP file"
raise SyntaxError(msg)
# Header checksum
checksum = 0
for i in range(0, 32, 2):
checksum = checksum ^ i16(s, i)
if checksum != 0:
msg = "bad MSP checksum"
raise SyntaxError(msg)
self._mode = "1"
self._size = i16(s, 4), i16(s, 6)
if s.startswith(b"DanM"):
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, "1")]
else:
self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32)]
class MspDecoder(ImageFile.PyDecoder):
# The algo for the MSP decoder is from
# https://www.fileformat.info/format/mspaint/egff.htm
# cc-by-attribution -- That page references is taken from the
# Encyclopedia of Graphics File Formats and is licensed by
# O'Reilly under the Creative Common/Attribution license
#
# For RLE encoded files, the 32byte header is followed by a scan
# line map, encoded as one 16bit word of encoded byte length per
# line.
#
# NOTE: the encoded length of the line can be 0. This was not
# handled in the previous version of this encoder, and there's no
# mention of how to handle it in the documentation. From the few
# examples I've seen, I've assumed that it is a fill of the
# background color, in this case, white.
#
#
# Pseudocode of the decoder:
# Read a BYTE value as the RunType
# If the RunType value is zero
# Read next byte as the RunCount
# Read the next byte as the RunValue
# Write the RunValue byte RunCount times
# If the RunType value is non-zero
# Use this value as the RunCount
# Read and write the next RunCount bytes literally
#
# e.g.:
# 0x00 03 ff 05 00 01 02 03 04
# would yield the bytes:
# 0xff ff ff 00 01 02 03 04
#
# which are then interpreted as a bit packed mode '1' image
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
img = io.BytesIO()
blank_line = bytearray((0xFF,) * ((self.state.xsize + 7) // 8))
try:
self.fd.seek(32)
rowmap = struct.unpack_from(
f"<{self.state.ysize}H", self.fd.read(self.state.ysize * 2)
)
except struct.error as e:
msg = "Truncated MSP file in row map"
raise OSError(msg) from e
for x, rowlen in enumerate(rowmap):
try:
if rowlen == 0:
img.write(blank_line)
continue
row = self.fd.read(rowlen)
if len(row) != rowlen:
msg = f"Truncated MSP file, expected {rowlen} bytes on row {x}"
raise OSError(msg)
idx = 0
while idx < rowlen:
runtype = row[idx]
idx += 1
if runtype == 0:
runcount, runval = struct.unpack_from("Bc", row, idx)
img.write(runval * runcount)
idx += 2
else:
runcount = runtype
img.write(row[idx : idx + runcount])
idx += runcount
except struct.error as e:
msg = f"Corrupted MSP file in row {x}"
raise OSError(msg) from e
self.set_as_raw(img.getvalue(), "1")
return -1, 0
Image.register_decoder("MSP", MspDecoder)
#
# write MSP files (uncompressed only)
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode != "1":
msg = f"cannot write mode {im.mode} as MSP"
raise OSError(msg)
# create MSP header
header = [0] * 16
header[0], header[1] = i16(b"Da"), i16(b"nM") # version 1
header[2], header[3] = im.size
header[4], header[5] = 1, 1
header[6], header[7] = 1, 1
header[8], header[9] = im.size
checksum = 0
for h in header:
checksum = checksum ^ h
header[12] = checksum # FIXME: is this the right field?
# header
for h in header:
fp.write(o16(h))
# image body
ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 32, "1")])
#
# registry
Image.register_open(MspImageFile.format, MspImageFile, _accept)
Image.register_save(MspImageFile.format, _save)
Image.register_extension(MspImageFile.format, ".msp")

Some files were not shown because too many files have changed in this diff Show More